@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.
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.3 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.3/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.3/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.3/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.3/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,20 +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> [--delete-remote-dir] [--devtools <value>] [--remote-base <value>] [--source
177
- remote|local]
176
+ $ fnd workspace --ssh <value> [--delete-remote-dir] [--devtools <value>] [--ignore-vcs] [--remote-base
177
+ <value>] [--rpc <value>] [--source remote|local]
178
178
 
179
179
  FLAGS
180
- --delete-remote-dir on exit, delete the remote workspace directory instead of leaving the synced copy in place
181
- --devtools=<value> expose your LOCAL browser to Claude on the remote via the chrome-devtools MCP. Value is "port"
182
- (same port both ends) or "remote:local" (local = this machine, where the browser runs). Your
183
- browser must already be listening with --remote-debugging-port=<local>.
184
- --remote-base=<value> [default: /home/fnd] base dir on the remote; the workspace lands at
185
- <base>/<local-user>/<dir-name>
186
- --source=<option> which side wins on conflict: "remote" = this server (where the workspace shell runs), "local" =
187
- the machine you ran fnd workspace from. Omit to flag conflicts instead of auto-resolving them.
188
- <options: remote|local>
189
- --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
190
211
 
191
212
  DESCRIPTION
192
213
  Open a remote workspace: two-way sync the current directory to a remote Linux box with Mutagen and drop into a shell
@@ -205,14 +226,20 @@ EXAMPLES
205
226
 
206
227
  $ fnd workspace --ssh user@host --remote-base /home/fnd
207
228
 
229
+ $ fnd workspace --ssh user@host --ignore-vcs
230
+
208
231
  $ fnd workspace --ssh user@host --devtools 9222
209
232
 
210
233
  $ fnd workspace --ssh user@host --devtools 9333:9222
211
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
+
212
239
  $ fnd workspace --ssh user@host --delete-remote-dir
213
240
  ```
214
241
 
215
- _See code: [src/commands/workspace/index.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.3/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)_
216
243
 
217
244
  ## `fnd workspace cleanup`
218
245
 
@@ -221,7 +248,7 @@ Tear down a leftover workspace — use this if a `workspace` session dropped bef
221
248
  ```
222
249
  USAGE
223
250
  $ fnd workspace cleanup --ssh <value> [--delete-remote-dir] [--devtools] [--remote-base <value>] [--remote-dir
224
- <value>]
251
+ <value>] [--rpc]
225
252
 
226
253
  FLAGS
227
254
  --delete-remote-dir also delete the remote workspace directory (by default the synced files are left in place)
@@ -229,15 +256,16 @@ FLAGS
229
256
  --devtools)
230
257
  --remote-base=<value> [default: /home/fnd] base dir on the remote, used to derive the default remote directory path
231
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)
232
260
  --ssh=<value> (required) remote to connect to, as user@host
233
261
 
234
262
  DESCRIPTION
235
263
  Tear down a leftover workspace — use this if a `workspace` session dropped before it could clean up after itself.
236
264
 
237
- Terminates any Mutagen sync sessions this machine started for the directory. Pass --devtools to also strip the
238
- matching chrome-devtools MCP entry from the remote (only do this if the dropped session used --devtools). With no
239
- --remote-dir, it targets the same path `workspace` would use for the current directory. The synced files themselves
240
- are left in place unless you pass --delete-remote-dir.
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.
241
269
 
242
270
  EXAMPLES
243
271
  $ fnd workspace cleanup --ssh user@host
@@ -246,8 +274,10 @@ EXAMPLES
246
274
 
247
275
  $ fnd workspace cleanup --ssh user@host --devtools
248
276
 
277
+ $ fnd workspace cleanup --ssh user@host --rpc
278
+
249
279
  $ fnd workspace cleanup --ssh user@host --delete-remote-dir
250
280
  ```
251
281
 
252
- _See code: [src/commands/workspace/cleanup.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.3/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)_
253
283
  <!-- commandsstop -->
@@ -7,6 +7,7 @@ export default class WorkspaceCleanup extends Command {
7
7
  devtools: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
8
  'remote-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
9
  'remote-dir': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ rpc: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
12
  };
12
13
  run(): Promise<void>;
@@ -3,11 +3,12 @@ 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. 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.';
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
10
  '<%= config.bin %> <%= command.id %> --ssh user@host --devtools',
11
+ '<%= config.bin %> <%= command.id %> --ssh user@host --rpc',
11
12
  '<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir',
12
13
  ];
13
14
  static flags = {
@@ -26,6 +27,10 @@ export default class WorkspaceCleanup extends Command {
26
27
  'remote-dir': Flags.string({
27
28
  description: 'exact remote directory to target (defaults to the current dir mapping)',
28
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
+ }),
29
34
  ssh: Flags.string({
30
35
  description: 'remote to connect to, as user@host',
31
36
  required: true,
@@ -53,17 +58,21 @@ export default class WorkspaceCleanup extends Command {
53
58
  else {
54
59
  this.log(chalk.yellow('Mutagen CLI not found on PATH — skipping sync termination.'));
55
60
  }
56
- // Strip any chrome-devtools MCP config this workspace left on the remote
57
- // (only when asked), and optionally delete the synced directory itself.
61
+ // Strip any MCP config this workspace left on the remote (only when asked),
62
+ // and optionally delete the synced directory itself.
58
63
  if (flags.devtools) {
59
64
  this.log(chalk.dim('Removing any leftover chrome-devtools MCP config on the remote…'));
60
65
  }
66
+ if (flags.rpc) {
67
+ this.log(chalk.dim('Removing any leftover local-shell MCP config on the remote…'));
68
+ }
61
69
  if (flags['delete-remote-dir']) {
62
70
  this.log(chalk.dim('Deleting the remote workspace directory…'));
63
71
  }
64
72
  const code = await runRemoteCleanup(target2, remoteDir, {
65
73
  deleteRemoteDir: flags['delete-remote-dir'],
66
74
  removeDevtoolsMcp: flags.devtools,
75
+ removeRpcMcp: flags.rpc,
67
76
  });
68
77
  if (code === 0) {
69
78
  this.log(chalk.green('✓ Done.'));
@@ -5,7 +5,9 @@ export default class Workspace extends Command {
5
5
  static flags: {
6
6
  'delete-remote-dir': import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
7
  devtools: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ 'ignore-vcs': import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
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>;
9
11
  source: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
12
  ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
13
  };
@@ -1,15 +1,19 @@
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}`,
13
17
  '<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir',
14
18
  ];
15
19
  static flags = {
@@ -20,10 +24,17 @@ export default class Workspace extends Command {
20
24
  devtools: Flags.string({
21
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>.',
22
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
+ }),
23
31
  'remote-base': Flags.string({
24
32
  default: DEFAULT_MOUNT_BASE,
25
33
  description: 'base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>',
26
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
+ }),
27
38
  source: Flags.string({
28
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.',
29
40
  options: ['remote', 'local'],
@@ -37,14 +48,20 @@ export default class Workspace extends Command {
37
48
  const { flags } = await this.parse(Workspace);
38
49
  const target = parseSshTarget(flags.ssh);
39
50
  const target2 = `${target.user}@${target.host}`;
40
- 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
+ }
41
56
  const ctx = buildContext({
42
57
  cwd: process.cwd(),
43
58
  devtools,
59
+ ignoreVcs: flags['ignore-vcs'],
44
60
  remoteBase: flags['remote-base'],
61
+ rpc,
45
62
  source: flags.source,
46
63
  });
47
- await this.preflight(devtools);
64
+ await this.preflight(devtools, rpc);
48
65
  this.printPlan(ctx, target2);
49
66
  // Start the two-way sync. Mutagen auto-deploys its agent to the remote over SSH.
50
67
  this.log(chalk.dim('Starting the Mutagen sync session…'));
@@ -53,6 +70,7 @@ export default class Workspace extends Command {
53
70
  this.error(`mutagen sync create failed (exit ${createCode}). Check that the remote is reachable over SSH and try again.`, { code: '1' });
54
71
  }
55
72
  const deleteRemoteDir = flags['delete-remote-dir'];
73
+ let rpcServer;
56
74
  let code;
57
75
  try {
58
76
  // Block until the first full sync lands so the files exist before the shell opens.
@@ -61,20 +79,33 @@ export default class Workspace extends Command {
61
79
  if (flushCode !== 0) {
62
80
  this.error(`Initial mutagen sync flush failed (exit ${flushCode}).`, { code: '1' });
63
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
+ }
64
92
  const script = buildRemoteScript(ctx);
65
- code = await this.runSsh(target2, script, ctx.devtools);
93
+ code = await this.runSsh(target2, script, ctx);
66
94
  }
67
95
  finally {
68
- // 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(() => { });
69
99
  this.log('');
70
100
  this.log(chalk.dim('Flushing final changes and stopping the sync…'));
71
101
  await runMutagen(buildMutagenFlushArgs(ctx.syncName)).catch(() => 1);
72
102
  await runMutagen(buildMutagenTerminateArgs(ctx.syncName)).catch(() => 1);
73
- // Reach back to the remote if we left a devtools MCP config there to remove,
74
- // or if we were asked to delete the synced directory.
75
- if (ctx.devtools || deleteRemoteDir) {
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) {
76
106
  const actions = [
77
107
  ctx.devtools ? 'removing the remote chrome-devtools MCP config' : undefined,
108
+ ctx.rpc ? 'removing the remote local-shell MCP config' : undefined,
78
109
  deleteRemoteDir ? 'deleting the remote dir' : undefined,
79
110
  ].filter(Boolean);
80
111
  this.log(chalk.dim(`${actions.join(' and ')}…`.replace(/^./, (c) => c.toUpperCase())));
@@ -82,6 +113,7 @@ export default class Workspace extends Command {
82
113
  await runRemoteCleanup(target2, ctx.remoteDir, {
83
114
  deleteRemoteDir,
84
115
  removeDevtoolsMcp: Boolean(ctx.devtools),
116
+ removeRpcMcp: Boolean(ctx.rpc),
85
117
  });
86
118
  }
87
119
  catch (error) {
@@ -97,7 +129,7 @@ export default class Workspace extends Command {
97
129
  : chalk.yellow(`Session ended with exit code ${code}. Cleanup attempted above.`));
98
130
  }
99
131
  /** Verify this machine can drive the sync before we connect. */
100
- async preflight(devtools) {
132
+ async preflight(devtools, rpc) {
101
133
  if (!hasSshClient()) {
102
134
  this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
103
135
  }
@@ -120,6 +152,13 @@ export default class Workspace extends Command {
120
152
  this.error('Local browser remote-debugging port is required for --devtools. Aborting.', { code: '1' });
121
153
  }
122
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
+ }
123
162
  }
124
163
  printPlan(ctx, target) {
125
164
  const sync = ctx.source === undefined
@@ -130,20 +169,27 @@ export default class Workspace extends Command {
130
169
  this.log(` ${chalk.dim('local dir:')} ${ctx.localCwd}`);
131
170
  this.log(` ${chalk.dim('remote dir:')} ${ctx.remoteDir}`);
132
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
+ }
133
175
  if (ctx.devtools) {
134
176
  this.log(` ${chalk.dim('devtools:')} remote 127.0.0.1:${ctx.devtools.remote} → local browser 127.0.0.1:${ctx.devtools.local}`);
135
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
+ }
136
181
  this.log('');
137
182
  }
138
183
  /** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
139
- 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);
140
189
  const args = [
141
190
  '-t', // allocate a remote PTY for the interactive shell session
142
- // Reverse tunnel for --devtools only: remote 127.0.0.1:<remote> → this
143
- // machine's browser at 127.0.0.1:<local>, so the remote's MCP can reach it.
144
- ...(devtools
145
- ? ['-o', 'ExitOnForwardFailure=yes', '-R', `${devtools.remote}:localhost:${devtools.local}`]
146
- : []),
191
+ ...(forwards.length > 0 ? ['-o', 'ExitOnForwardFailure=yes'] : []),
192
+ ...forwards.flatMap((f) => ['-R', `${f.remote}:localhost:${f.local}`]),
147
193
  target,
148
194
  script,
149
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 {};