@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 CHANGED
@@ -20,7 +20,7 @@ $ npm install -g @firenet-designs/fnd-cli
20
20
  $ fnd COMMAND
21
21
  running command...
22
22
  $ fnd (--version)
23
- @firenet-designs/fnd-cli/2.3.2 linux-x64 node-v24.18.0
23
+ @firenet-designs/fnd-cli/2.4.0 linux-x64 node-v24.18.0
24
24
  $ fnd --help [COMMAND]
25
25
  USAGE
26
26
  $ fnd COMMAND
@@ -62,7 +62,7 @@ EXAMPLES
62
62
  $ fnd backfill-project my-store "extra hints"
63
63
  ```
64
64
 
65
- _See code: [src/commands/backfill-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.2/src/commands/backfill-project.ts)_
65
+ _See code: [src/commands/backfill-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.4.0/src/commands/backfill-project.ts)_
66
66
 
67
67
  ## `fnd create-app`
68
68
 
@@ -83,7 +83,7 @@ EXAMPLES
83
83
  $ fnd create-app
84
84
  ```
85
85
 
86
- _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.2/src/commands/create-app.ts)_
86
+ _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.4.0/src/commands/create-app.ts)_
87
87
 
88
88
  ## `fnd create-project [SHOP]`
89
89
 
@@ -113,7 +113,7 @@ EXAMPLES
113
113
  $ fnd create-project my-store "Acme storefront, Klaviyo"
114
114
  ```
115
115
 
116
- _See code: [src/commands/create-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.2/src/commands/create-project.ts)_
116
+ _See code: [src/commands/create-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.4.0/src/commands/create-project.ts)_
117
117
 
118
118
  ## `fnd help [COMMAND]`
119
119
 
@@ -165,7 +165,7 @@ EXAMPLES
165
165
  $ fnd token -s mystore -i <id> -c <secret> --write ./.mcp.json
166
166
  ```
167
167
 
168
- _See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.2/src/commands/token.ts)_
168
+ _See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.4.0/src/commands/token.ts)_
169
169
 
170
170
  ## `fnd workspace`
171
171
 
@@ -173,18 +173,41 @@ Open a remote workspace: two-way sync the current directory to a remote Linux bo
173
173
 
174
174
  ```
175
175
  USAGE
176
- $ fnd workspace --ssh <value> [--devtools <value>] [--remote-base <value>] [--source remote|local]
176
+ $ fnd workspace --ssh <value> [--delete-remote-dir] [--devtools <value>] [--ignore-vcs] [--remote-base
177
+ <value>] [--rpc <value>] [--source remote|local]
177
178
 
178
179
  FLAGS
179
- --devtools=<value> expose your LOCAL browser to Claude on the remote via the chrome-devtools MCP. Value is "port"
180
- (same port both ends) or "remote:local" (local = this machine, where the browser runs). Your
181
- browser must already be listening with --remote-debugging-port=<local>.
182
- --remote-base=<value> [default: /home/fnd] base dir on the remote; the workspace lands at
183
- <base>/<local-user>/<dir-name>
184
- --source=<option> which side wins on conflict: "remote" = this server (where the workspace shell runs), "local" =
185
- the machine you ran fnd workspace from. Omit to flag conflicts instead of auto-resolving them.
186
- <options: remote|local>
187
- --ssh=<value> (required) remote to connect to, as user@host
180
+ --delete-remote-dir
181
+ on exit, delete the remote workspace directory instead of leaving the synced copy in place
182
+
183
+ --devtools=<value>
184
+ expose your LOCAL browser to Claude on the remote via the chrome-devtools MCP. Value is "port" (same port both ends)
185
+ or "remote:local" (local = this machine, where the browser runs). Your browser must already be listening with
186
+ --remote-debugging-port=<local>.
187
+
188
+ --ignore-vcs
189
+ don't sync paths matched by the project's .gitignore files (node_modules, build output, …) so each side keeps its
190
+ own platform-specific artifacts. Every .gitignore in the tree is honoured relative to its directory, like git does.
191
+ The .git directory itself still syncs, so the remote stays a working repo.
192
+
193
+ --remote-base=<value>
194
+ [default: /home/fnd] base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>
195
+
196
+ --rpc=<value>
197
+ expose a run_local_command MCP tool to Claude on the remote that executes commands back on THIS machine (the one
198
+ running fnd workspace). Value is
199
+ port=<port|remote:local>[,profile=<true|1|false|0>][,shell=<bash|batch|powershell|sh|zsh>] — port opens a reverse
200
+ tunnel (ssh -R <remote>:localhost:<local>) to a command server started here; shell defaults to the shell fnd
201
+ workspace was called from; profile (default true) controls whether the shell loads its startup files — with it on,
202
+ POSIX shells run interactively (-i) so rc files like ~/.bashrc or ~/.zshrc are sourced and tools such as nvm work.
203
+
204
+ --source=<option>
205
+ which side wins on conflict: "remote" = this server (where the workspace shell runs), "local" = the machine you ran
206
+ fnd workspace from. Omit to flag conflicts instead of auto-resolving them.
207
+ <options: remote|local>
208
+
209
+ --ssh=<value>
210
+ (required) remote to connect to, as user@host
188
211
 
189
212
  DESCRIPTION
190
213
  Open a remote workspace: two-way sync the current directory to a remote Linux box with Mutagen and drop into a shell
@@ -203,12 +226,20 @@ EXAMPLES
203
226
 
204
227
  $ fnd workspace --ssh user@host --remote-base /home/fnd
205
228
 
229
+ $ fnd workspace --ssh user@host --ignore-vcs
230
+
206
231
  $ fnd workspace --ssh user@host --devtools 9222
207
232
 
208
233
  $ fnd workspace --ssh user@host --devtools 9333:9222
234
+
235
+ $ fnd workspace --ssh user@host --rpc port=7777:7700
236
+
237
+ $ fnd workspace --ssh user@host --rpc port=7777:7700,profile=false,shell=zsh
238
+
239
+ $ fnd workspace --ssh user@host --delete-remote-dir
209
240
  ```
210
241
 
211
- _See code: [src/commands/workspace/index.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.2/src/commands/workspace/index.ts)_
242
+ _See code: [src/commands/workspace/index.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.4.0/src/commands/workspace/index.ts)_
212
243
 
213
244
  ## `fnd workspace cleanup`
214
245
 
@@ -216,25 +247,37 @@ Tear down a leftover workspace — use this if a `workspace` session dropped bef
216
247
 
217
248
  ```
218
249
  USAGE
219
- $ fnd workspace cleanup --ssh <value> [--remote-base <value>] [--remote-dir <value>]
250
+ $ fnd workspace cleanup --ssh <value> [--delete-remote-dir] [--devtools] [--remote-base <value>] [--remote-dir
251
+ <value>] [--rpc]
220
252
 
221
253
  FLAGS
254
+ --delete-remote-dir also delete the remote workspace directory (by default the synced files are left in place)
255
+ --devtools also strip the chrome-devtools MCP entry from the remote (only if the dropped session used
256
+ --devtools)
222
257
  --remote-base=<value> [default: /home/fnd] base dir on the remote, used to derive the default remote directory path
223
258
  --remote-dir=<value> exact remote directory to target (defaults to the current dir mapping)
259
+ --rpc also strip the local-shell MCP entry from the remote (only if the dropped session used --rpc)
224
260
  --ssh=<value> (required) remote to connect to, as user@host
225
261
 
226
262
  DESCRIPTION
227
263
  Tear down a leftover workspace — use this if a `workspace` session dropped before it could clean up after itself.
228
264
 
229
- Terminates any Mutagen sync sessions this machine started for the directory and strips the matching chrome-devtools
230
- MCP entry from the remote. With no --remote-dir, it targets the same path `workspace` would use for the current
231
- directory. The synced files themselves are left in place.
265
+ Terminates any Mutagen sync sessions this machine started for the directory. Pass --devtools or --rpc to also strip
266
+ the matching MCP entries from the remote (only do this if the dropped session used those flags). With no --remote-dir,
267
+ it targets the same path `workspace` would use for the current directory. The synced files themselves are left in
268
+ place unless you pass --delete-remote-dir.
232
269
 
233
270
  EXAMPLES
234
271
  $ fnd workspace cleanup --ssh user@host
235
272
 
236
273
  $ fnd workspace cleanup --ssh user@host --remote-dir /home/fnd/cole/fnd-cli
274
+
275
+ $ fnd workspace cleanup --ssh user@host --devtools
276
+
277
+ $ fnd workspace cleanup --ssh user@host --rpc
278
+
279
+ $ fnd workspace cleanup --ssh user@host --delete-remote-dir
237
280
  ```
238
281
 
239
- _See code: [src/commands/workspace/cleanup.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.2/src/commands/workspace/cleanup.ts)_
282
+ _See code: [src/commands/workspace/cleanup.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.4.0/src/commands/workspace/cleanup.ts)_
240
283
  <!-- commandsstop -->
@@ -3,8 +3,11 @@ export default class WorkspaceCleanup extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
+ 'delete-remote-dir': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ devtools: import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
8
  'remote-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
9
  'remote-dir': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ rpc: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
11
  ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
12
  };
10
13
  run(): Promise<void>;
@@ -3,12 +3,23 @@ import chalk from 'chalk';
3
3
  import { basename } from 'node:path';
4
4
  import { buildContext, buildMutagenTerminateSelectorArgs, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, parseSshTarget, runMutagen, runRemoteCleanup, slugify, } from '../../lib/workspace.js';
5
5
  export default class WorkspaceCleanup extends Command {
6
- static 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 and strips the matching chrome-devtools MCP entry from the remote. With no --remote-dir, it targets the same path `workspace` would use for the current directory. The synced files themselves are left in place.';
6
+ static 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.';
7
7
  static examples = [
8
8
  '<%= config.bin %> <%= command.id %> --ssh user@host',
9
9
  '<%= config.bin %> <%= command.id %> --ssh user@host --remote-dir /home/fnd/cole/fnd-cli',
10
+ '<%= config.bin %> <%= command.id %> --ssh user@host --devtools',
11
+ '<%= config.bin %> <%= command.id %> --ssh user@host --rpc',
12
+ '<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir',
10
13
  ];
11
14
  static flags = {
15
+ 'delete-remote-dir': Flags.boolean({
16
+ default: false,
17
+ description: 'also delete the remote workspace directory (by default the synced files are left in place)',
18
+ }),
19
+ devtools: Flags.boolean({
20
+ default: false,
21
+ description: 'also strip the chrome-devtools MCP entry from the remote (only if the dropped session used --devtools)',
22
+ }),
12
23
  'remote-base': Flags.string({
13
24
  default: DEFAULT_MOUNT_BASE,
14
25
  description: 'base dir on the remote, used to derive the default remote directory path',
@@ -16,6 +27,10 @@ export default class WorkspaceCleanup extends Command {
16
27
  'remote-dir': Flags.string({
17
28
  description: 'exact remote directory to target (defaults to the current dir mapping)',
18
29
  }),
30
+ rpc: Flags.boolean({
31
+ default: false,
32
+ description: 'also strip the local-shell MCP entry from the remote (only if the dropped session used --rpc)',
33
+ }),
19
34
  ssh: Flags.string({
20
35
  description: 'remote to connect to, as user@host',
21
36
  required: true,
@@ -43,9 +58,22 @@ export default class WorkspaceCleanup extends Command {
43
58
  else {
44
59
  this.log(chalk.yellow('Mutagen CLI not found on PATH — skipping sync termination.'));
45
60
  }
46
- // Strip any chrome-devtools MCP config this workspace left on the remote.
47
- this.log(chalk.dim('Removing any leftover chrome-devtools MCP config on the remote…'));
48
- const code = await runRemoteCleanup(target2, remoteDir);
61
+ // Strip any MCP config this workspace left on the remote (only when asked),
62
+ // and optionally delete the synced directory itself.
63
+ if (flags.devtools) {
64
+ this.log(chalk.dim('Removing any leftover chrome-devtools MCP config on the remote…'));
65
+ }
66
+ if (flags.rpc) {
67
+ this.log(chalk.dim('Removing any leftover local-shell MCP config on the remote…'));
68
+ }
69
+ if (flags['delete-remote-dir']) {
70
+ this.log(chalk.dim('Deleting the remote workspace directory…'));
71
+ }
72
+ const code = await runRemoteCleanup(target2, remoteDir, {
73
+ deleteRemoteDir: flags['delete-remote-dir'],
74
+ removeDevtoolsMcp: flags.devtools,
75
+ removeRpcMcp: flags.rpc,
76
+ });
49
77
  if (code === 0) {
50
78
  this.log(chalk.green('✓ Done.'));
51
79
  }
@@ -3,8 +3,11 @@ export default class Workspace extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
+ 'delete-remote-dir': import("@oclif/core/interfaces").BooleanFlag<boolean>;
6
7
  devtools: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ 'ignore-vcs': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
9
  'remote-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ rpc: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
11
  source: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
12
  ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
13
  };
@@ -1,24 +1,40 @@
1
1
  import { Command, Flags } from '@oclif/core';
2
2
  import chalk from 'chalk';
3
3
  import { spawn } from 'node:child_process';
4
- import { browserDebugInstructions, buildContext, buildMutagenCreateArgs, buildMutagenFlushArgs, buildMutagenTerminateArgs, buildRemoteScript, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, isLocalDebugPortLive, mutagenInstallInstructions, parseDevtoolsPort, parseSshTarget, runMutagen, runRemoteCleanup, } from '../../lib/workspace.js';
4
+ import { hasLocalShell, parseRpcFlag, RPC_FLAG_EXAMPLES, RPC_FLAG_USAGE, startRpcServer } from '../../lib/rpc.js';
5
+ import { browserDebugInstructions, buildContext, buildMutagenCreateArgs, buildMutagenFlushArgs, buildMutagenTerminateArgs, buildRemoteScript, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, isLocalDebugPortLive, mutagenInstallInstructions, parsePortPair, parseSshTarget, runMutagen, runRemoteCleanup, } from '../../lib/workspace.js';
5
6
  export default class Workspace extends Command {
6
7
  static description = 'Open a remote workspace: two-way sync the current directory to a remote Linux box with Mutagen and drop into a shell there, tearing the sync down on exit.\n\nBoth sides keep a real copy on local disk and only deltas cross the network, so the remote reads files at native speed. Conflicting edits on both ends are flagged rather than silently overwritten; pass --source to auto-resolve them in favour of one side. Mutagen connects the normal direction (this machine → remote over SSH) and auto-deploys its agent to the remote, so no local SSH server, reverse tunnel, or authorized_keys trust is required. You need the Mutagen CLI installed on THIS machine.';
7
8
  static examples = [
8
9
  '<%= config.bin %> <%= command.id %> --ssh user@203.0.113.4',
9
10
  '<%= config.bin %> <%= command.id %> --ssh user@host --source local',
10
11
  '<%= config.bin %> <%= command.id %> --ssh user@host --remote-base /home/fnd',
12
+ '<%= config.bin %> <%= command.id %> --ssh user@host --ignore-vcs',
11
13
  '<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9222',
12
14
  '<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9333:9222',
15
+ `<%= config.bin %> <%= command.id %> --ssh user@host --rpc ${RPC_FLAG_EXAMPLES.required}`,
16
+ `<%= config.bin %> <%= command.id %> --ssh user@host --rpc ${RPC_FLAG_EXAMPLES.full}`,
17
+ '<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir',
13
18
  ];
14
19
  static flags = {
20
+ 'delete-remote-dir': Flags.boolean({
21
+ default: false,
22
+ description: 'on exit, delete the remote workspace directory instead of leaving the synced copy in place',
23
+ }),
15
24
  devtools: Flags.string({
16
25
  description: 'expose your LOCAL browser to Claude on the remote via the chrome-devtools MCP. Value is "port" (same port both ends) or "remote:local" (local = this machine, where the browser runs). Your browser must already be listening with --remote-debugging-port=<local>.',
17
26
  }),
27
+ 'ignore-vcs': Flags.boolean({
28
+ default: false,
29
+ 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.",
30
+ }),
18
31
  'remote-base': Flags.string({
19
32
  default: DEFAULT_MOUNT_BASE,
20
33
  description: 'base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>',
21
34
  }),
35
+ rpc: Flags.string({
36
+ 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 ${RPC_FLAG_USAGE} — 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.`,
37
+ }),
22
38
  source: Flags.string({
23
39
  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.',
24
40
  options: ['remote', 'local'],
@@ -32,14 +48,20 @@ export default class Workspace extends Command {
32
48
  const { flags } = await this.parse(Workspace);
33
49
  const target = parseSshTarget(flags.ssh);
34
50
  const target2 = `${target.user}@${target.host}`;
35
- const devtools = flags.devtools === undefined ? undefined : parseDevtoolsPort(flags.devtools);
51
+ const devtools = flags.devtools === undefined ? undefined : parsePortPair(flags.devtools, '--devtools');
52
+ const rpc = flags.rpc === undefined ? undefined : parseRpcFlag(flags.rpc);
53
+ if (devtools && rpc && devtools.remote === rpc.ports.remote) {
54
+ this.error(`--devtools and --rpc cannot share remote port ${rpc.ports.remote}.`, { code: '1' });
55
+ }
36
56
  const ctx = buildContext({
37
57
  cwd: process.cwd(),
38
58
  devtools,
59
+ ignoreVcs: flags['ignore-vcs'],
39
60
  remoteBase: flags['remote-base'],
61
+ rpc,
40
62
  source: flags.source,
41
63
  });
42
- await this.preflight(devtools);
64
+ await this.preflight(devtools, rpc);
43
65
  this.printPlan(ctx, target2);
44
66
  // Start the two-way sync. Mutagen auto-deploys its agent to the remote over SSH.
45
67
  this.log(chalk.dim('Starting the Mutagen sync session…'));
@@ -47,6 +69,8 @@ export default class Workspace extends Command {
47
69
  if (createCode !== 0) {
48
70
  this.error(`mutagen sync create failed (exit ${createCode}). Check that the remote is reachable over SSH and try again.`, { code: '1' });
49
71
  }
72
+ const deleteRemoteDir = flags['delete-remote-dir'];
73
+ let rpcServer;
50
74
  let code;
51
75
  try {
52
76
  // Block until the first full sync lands so the files exist before the shell opens.
@@ -55,33 +79,57 @@ export default class Workspace extends Command {
55
79
  if (flushCode !== 0) {
56
80
  this.error(`Initial mutagen sync flush failed (exit ${flushCode}).`, { code: '1' });
57
81
  }
82
+ // Start the local command server the --rpc tunnel points back to.
83
+ if (ctx.rpc) {
84
+ try {
85
+ rpcServer = await startRpcServer(ctx.rpc, ctx.localCwd);
86
+ this.log(chalk.dim(`Local RPC command server (${ctx.rpc.shell}) listening on 127.0.0.1:${ctx.rpc.ports.local}.`));
87
+ }
88
+ catch (error) {
89
+ this.error(`Could not start the local RPC server on 127.0.0.1:${ctx.rpc.ports.local} (${error.message}). Is the port already in use?`, { code: '1' });
90
+ }
91
+ }
58
92
  const script = buildRemoteScript(ctx);
59
- code = await this.runSsh(target2, script, ctx.devtools);
93
+ code = await this.runSsh(target2, script, ctx);
60
94
  }
61
95
  finally {
62
- // Best-effort: flush the last edits back, then tear the session down.
96
+ // Best-effort: stop serving local commands, flush the last edits back, then
97
+ // tear the session down.
98
+ await rpcServer?.close().catch(() => { });
63
99
  this.log('');
64
100
  this.log(chalk.dim('Flushing final changes and stopping the sync…'));
65
101
  await runMutagen(buildMutagenFlushArgs(ctx.syncName)).catch(() => 1);
66
102
  await runMutagen(buildMutagenTerminateArgs(ctx.syncName)).catch(() => 1);
67
- // Only reach back to the remote if we put a devtools MCP config there to remove.
68
- if (ctx.devtools) {
69
- this.log(chalk.dim('Removing the remote chrome-devtools MCP config…'));
103
+ // Reach back to the remote if we left MCP config there to remove, or if we
104
+ // were asked to delete the synced directory.
105
+ if (ctx.devtools || ctx.rpc || deleteRemoteDir) {
106
+ const actions = [
107
+ ctx.devtools ? 'removing the remote chrome-devtools MCP config' : undefined,
108
+ ctx.rpc ? 'removing the remote local-shell MCP config' : undefined,
109
+ deleteRemoteDir ? 'deleting the remote dir' : undefined,
110
+ ].filter(Boolean);
111
+ this.log(chalk.dim(`${actions.join(' and ')}…`.replace(/^./, (c) => c.toUpperCase())));
70
112
  try {
71
- await runRemoteCleanup(target2, ctx.remoteDir);
113
+ await runRemoteCleanup(target2, ctx.remoteDir, {
114
+ deleteRemoteDir,
115
+ removeDevtoolsMcp: Boolean(ctx.devtools),
116
+ removeRpcMcp: Boolean(ctx.rpc),
117
+ });
72
118
  }
73
119
  catch (error) {
74
- this.log(chalk.yellow(`Could not reach the remote to remove the devtools config (${error.message}).`));
120
+ this.log(chalk.yellow(`Could not reach the remote to clean up (${error.message}).`));
75
121
  }
76
122
  }
77
123
  }
78
124
  this.log('');
79
125
  this.log(code === 0
80
- ? chalk.green('✓ Workspace closed. Sync stopped; the remote copy is left in place.')
126
+ ? chalk.green(deleteRemoteDir
127
+ ? '✓ Workspace closed. Sync stopped; the remote dir was deleted.'
128
+ : '✓ Workspace closed. Sync stopped; the remote copy is left in place.')
81
129
  : chalk.yellow(`Session ended with exit code ${code}. Cleanup attempted above.`));
82
130
  }
83
131
  /** Verify this machine can drive the sync before we connect. */
84
- async preflight(devtools) {
132
+ async preflight(devtools, rpc) {
85
133
  if (!hasSshClient()) {
86
134
  this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
87
135
  }
@@ -104,6 +152,13 @@ export default class Workspace extends Command {
104
152
  this.error('Local browser remote-debugging port is required for --devtools. Aborting.', { code: '1' });
105
153
  }
106
154
  }
155
+ // With --rpc, confirm the chosen shell is runnable on THIS machine — it's the
156
+ // shell the remote AI's commands will execute under, right here.
157
+ if (rpc && !hasLocalShell(rpc.shell, rpc.profile)) {
158
+ this.error(`--rpc shell "${rpc.shell}" is not runnable on this machine. Aborting before connecting.`, {
159
+ code: '1',
160
+ });
161
+ }
107
162
  }
108
163
  printPlan(ctx, target) {
109
164
  const sync = ctx.source === undefined
@@ -114,20 +169,27 @@ export default class Workspace extends Command {
114
169
  this.log(` ${chalk.dim('local dir:')} ${ctx.localCwd}`);
115
170
  this.log(` ${chalk.dim('remote dir:')} ${ctx.remoteDir}`);
116
171
  this.log(` ${chalk.dim('sync:')} ${sync}`);
172
+ if (ctx.ignores) {
173
+ this.log(` ${chalk.dim('ignoring:')} ${ctx.ignores.length} pattern${ctx.ignores.length === 1 ? '' : 's'} from .gitignore files (--ignore-vcs)`);
174
+ }
117
175
  if (ctx.devtools) {
118
176
  this.log(` ${chalk.dim('devtools:')} remote 127.0.0.1:${ctx.devtools.remote} → local browser 127.0.0.1:${ctx.devtools.local}`);
119
177
  }
178
+ if (ctx.rpc) {
179
+ this.log(` ${chalk.dim('rpc:')} remote 127.0.0.1:${ctx.rpc.ports.remote} → local ${ctx.rpc.shell} commands via 127.0.0.1:${ctx.rpc.ports.local}`);
180
+ }
120
181
  this.log('');
121
182
  }
122
183
  /** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
123
- runSsh(target, script, devtools) {
184
+ runSsh(target, script, ctx) {
185
+ // Reverse tunnels: remote 127.0.0.1:<remote> → this machine's 127.0.0.1:<local>.
186
+ // --devtools points one at the local browser's debug port; --rpc points one at
187
+ // the local command server, so the remote's MCPs can reach them.
188
+ const forwards = [ctx.devtools, ctx.rpc?.ports].filter((f) => f !== undefined);
124
189
  const args = [
125
190
  '-t', // allocate a remote PTY for the interactive shell session
126
- // Reverse tunnel for --devtools only: remote 127.0.0.1:<remote> → this
127
- // machine's browser at 127.0.0.1:<local>, so the remote's MCP can reach it.
128
- ...(devtools
129
- ? ['-o', 'ExitOnForwardFailure=yes', '-R', `${devtools.remote}:localhost:${devtools.local}`]
130
- : []),
191
+ ...(forwards.length > 0 ? ['-o', 'ExitOnForwardFailure=yes'] : []),
192
+ ...forwards.flatMap((f) => ['-R', `${f.remote}:localhost:${f.local}`]),
131
193
  target,
132
194
  script,
133
195
  ];
@@ -9,7 +9,7 @@ const hook = async function (opts) {
9
9
  });
10
10
  });
11
11
  if (newestVersion !== opts.config.pjson.version) {
12
- console.log(chalk.yellow('💡 Version', chalk.green(newestVersion), 'available! Run', chalk.green('`npm i -g git@github.com:FireNet-Designs/fnd-cli.git`'), 'to update to the latest version!\n'));
12
+ console.log(chalk.yellow('💡 Version', chalk.green(newestVersion), 'available! Run', chalk.green('`npm i -g @firenet-designs/fnd-cli`'), 'to update to the latest version!\n'));
13
13
  }
14
14
  };
15
15
  export default hook;
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+ type KvShape = Record<string, z.ZodType>;
3
+ /** Usage string for the schema, required keys first: `a=<...>,b=<...>[,c=<...>]`. */
4
+ export declare const kvUsage: (schema: z.ZodObject<KvShape>) => string;
5
+ /** Example flag value built from each field's example meta (falls back to its hint), required keys first. */
6
+ export declare const kvExample: (schema: z.ZodObject<KvShape>, opts?: {
7
+ requiredOnly?: boolean;
8
+ }) => string;
9
+ /**
10
+ * Parse a key=value flag string against the schema. Keys are case-insensitive
11
+ * and order-independent; unknown keys, duplicate keys, and missing required
12
+ * keys are hard errors naming the flag and its usage.
13
+ */
14
+ export declare const parseKvFlag: <T extends KvShape>(flag: string, raw: string, schema: z.ZodObject<T>) => z.output<z.ZodObject<T>>;
15
+ export {};
@@ -0,0 +1,75 @@
1
+ const fieldMeta = (field) => (field.meta() ?? {});
2
+ const isOptional = (field) => field.def.type === 'default' || field.def.type === 'optional';
3
+ /** Unwrap default/optional/preprocess wrappers down to the schema describing the value. */
4
+ const unwrap = (field) => {
5
+ const { def } = field;
6
+ if (def.type === 'default' || def.type === 'optional') {
7
+ return unwrap(def.innerType);
8
+ }
9
+ if (def.type === 'pipe') {
10
+ return unwrap(def.out);
11
+ }
12
+ return field;
13
+ };
14
+ /** The <placeholder> for a field's value: explicit hint meta, enum options, or "value". */
15
+ const fieldHint = (field) => {
16
+ const { hint } = fieldMeta(field);
17
+ if (hint)
18
+ return hint;
19
+ const inner = unwrap(field);
20
+ if (inner.def.type === 'enum') {
21
+ return Object.values(inner.def.entries).join('|');
22
+ }
23
+ return 'value';
24
+ };
25
+ /** Usage string for the schema, required keys first: `a=<...>,b=<...>[,c=<...>]`. */
26
+ export const kvUsage = (schema) => {
27
+ const fields = Object.entries(schema.shape);
28
+ const required = fields.filter(([, f]) => !isOptional(f));
29
+ const optional = fields.filter(([, f]) => isOptional(f));
30
+ const pair = ([key, field]) => `${key}=<${fieldHint(field)}>`;
31
+ return required.map((f) => pair(f)).join(',') + optional.map((f) => `[,${pair(f)}]`).join('');
32
+ };
33
+ /** Example flag value built from each field's example meta (falls back to its hint), required keys first. */
34
+ export const kvExample = (schema, opts) => {
35
+ const fields = Object.entries(schema.shape).filter(([, field]) => !(opts?.requiredOnly && isOptional(field)));
36
+ return [...fields.filter(([, f]) => !isOptional(f)), ...fields.filter(([, f]) => isOptional(f))]
37
+ .map(([key, field]) => `${key}=${fieldMeta(field).example ?? fieldHint(field)}`)
38
+ .join(',');
39
+ };
40
+ /**
41
+ * Parse a key=value flag string against the schema. Keys are case-insensitive
42
+ * and order-independent; unknown keys, duplicate keys, and missing required
43
+ * keys are hard errors naming the flag and its usage.
44
+ */
45
+ export const parseKvFlag = (flag, raw, schema) => {
46
+ const usage = `${flag} takes comma-separated key=value pairs: ${kvUsage(schema)}`;
47
+ const known = Object.keys(schema.shape);
48
+ const pairs = new Map();
49
+ for (const part of raw.trim().split(',')) {
50
+ const eq = part.indexOf('=');
51
+ if (eq === -1) {
52
+ throw new Error(`${usage} (got "${raw}")`);
53
+ }
54
+ const key = part.slice(0, eq).trim().toLowerCase();
55
+ if (!known.includes(key)) {
56
+ throw new Error(`${flag} has no "${key}" option. ${usage}`);
57
+ }
58
+ if (pairs.has(key)) {
59
+ throw new Error(`${flag} "${key}" was given more than once (got "${raw}")`);
60
+ }
61
+ pairs.set(key, part.slice(eq + 1).trim());
62
+ }
63
+ for (const key of known) {
64
+ if (!isOptional(schema.shape[key]) && !pairs.has(key)) {
65
+ throw new Error(`${flag} needs "${key}". ${usage}`);
66
+ }
67
+ }
68
+ const result = schema.safeParse(Object.fromEntries(pairs));
69
+ if (!result.success) {
70
+ const issue = result.error.issues[0];
71
+ const at = issue.path.join('.');
72
+ throw new Error(`${flag}${at ? ` ${at}` : ''}: ${issue.message}. ${usage}`);
73
+ }
74
+ return result.data;
75
+ };
@@ -0,0 +1,69 @@
1
+ import { z } from 'zod';
2
+ import { PortPair } from './workspace.js';
3
+ /**
4
+ * The --rpc local-command server.
5
+ *
6
+ * Topology: this machine (the one that ran `fnd workspace`) runs a tiny MCP
7
+ * server — Streamable HTTP transport, implemented on node:http with no
8
+ * dependencies — bound to 127.0.0.1:<local>. The workspace's `ssh -R` reverse
9
+ * tunnel exposes it on the REMOTE at 127.0.0.1:<remote>, where the `claude` CLI
10
+ * registers it as an HTTP MCP server. When the AI on the remote calls the
11
+ * `run_local_command` tool, the command executes HERE, on the calling machine,
12
+ * under the shell chosen in the flag.
13
+ *
14
+ * The server binds loopback only; the sole way in from outside is the reverse
15
+ * tunnel, which lives exactly as long as the ssh session.
16
+ */
17
+ declare const ShellSchema: z.ZodEnum<{
18
+ zsh: "zsh";
19
+ bash: "bash";
20
+ batch: "batch";
21
+ powershell: "powershell";
22
+ sh: "sh";
23
+ }>;
24
+ export declare const RPC_SHELLS: ("zsh" | "bash" | "batch" | "powershell" | "sh")[];
25
+ export type RpcShell = z.infer<typeof ShellSchema>;
26
+ /** Usage and example strings for the --rpc flag, derived from the schema. */
27
+ export declare const RPC_FLAG_USAGE: string;
28
+ export declare const RPC_FLAG_EXAMPLES: {
29
+ full: string;
30
+ required: string;
31
+ };
32
+ export interface RpcConfig {
33
+ /** local = port the server binds on this machine; remote = port opened on the workspace host via `ssh -R`. */
34
+ ports: PortPair;
35
+ /** Whether the shell loads its startup files (rc/profile). POSIX shells run with -i, powershell without -NoProfile. */
36
+ profile: boolean;
37
+ /** Shell used to execute commands on this machine. */
38
+ shell: RpcShell;
39
+ }
40
+ /** Handle for a running RPC server. */
41
+ export interface RpcServer {
42
+ close: () => Promise<void>;
43
+ }
44
+ export declare const RPC_TOOL_NAME = "run_local_command";
45
+ /**
46
+ * The shell `fnd workspace` was called from: the parent process when it is a
47
+ * supported shell, else $SHELL, else undefined (e.g. invoked from a script).
48
+ */
49
+ export declare const detectCallingShell: () => RpcShell | undefined;
50
+ /**
51
+ * Parse the --rpc value: comma-separated key=value pairs per RpcFlagSchema.
52
+ * port=<port> | port=<remote>:<local> — required; `local` is this machine
53
+ * (where commands run), `remote` the port opened on the workspace host.
54
+ * The single-port form uses the same port on both ends.
55
+ * shell=<bash|batch|powershell|sh|zsh> — optional, defaults to the shell
56
+ * `fnd workspace` was called from.
57
+ * profile=<true|1|false|0> — optional, default true: the shell loads its
58
+ * startup files (rc/profile), so tools like nvm are available.
59
+ */
60
+ export declare const parseRpcFlag: (raw: string) => RpcConfig;
61
+ /** True if the chosen shell is runnable on this machine. */
62
+ export declare const hasLocalShell: (shell: RpcShell, profile: boolean) => boolean;
63
+ /**
64
+ * Start the local RPC (MCP) server on 127.0.0.1:<ports.local>. Commands execute
65
+ * with `cwd` as their working directory — the local side of the workspace sync.
66
+ * Resolves once the port is bound; rejects if binding fails (e.g. port in use).
67
+ */
68
+ export declare const startRpcServer: (config: RpcConfig, cwd: string) => Promise<RpcServer>;
69
+ export {};