@firenet-designs/fnd-cli 2.2.0 → 2.3.1

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.2.0 linux-x64 node-v24.18.0
23
+ @firenet-designs/fnd-cli/2.3.1 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.2.0/src/commands/backfill-project.ts)_
65
+ _See code: [src/commands/backfill-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.1/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.2.0/src/commands/create-app.ts)_
86
+ _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.1/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.2.0/src/commands/create-project.ts)_
116
+ _See code: [src/commands/create-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.1/src/commands/create-project.ts)_
117
117
 
118
118
  ## `fnd help [COMMAND]`
119
119
 
@@ -165,67 +165,76 @@ 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.2.0/src/commands/token.ts)_
168
+ _See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.1/src/commands/token.ts)_
169
169
 
170
170
  ## `fnd workspace`
171
171
 
172
- Open a remote workspace: mirror the current directory onto a remote Linux box via reverse SSHFS and drop into a shell there, cleaning everything up on exit.
172
+ 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.
173
173
 
174
174
  ```
175
175
  USAGE
176
- $ fnd workspace --ssh <value> [--local-ssh-port <value>] [--mount-base <value>] [-p <value>]
176
+ $ fnd workspace --ssh <value> [--devtools <value>] [--remote-base <value>] [--source remote|local]
177
177
 
178
178
  FLAGS
179
- -p, --port=<value> reverse-tunnel port opened on the remote (random 20000-60000 if omitted)
180
- --local-ssh-port=<value> [default: 22] port your LOCAL SSH server listens on (the tunnel forwards back to this)
181
- --mount-base=<value> [default: /home/fnd] base dir on the remote; the mount lands at
182
- <base>/<local-user>/<dir-name>
183
- --ssh=<value> (required) remote to connect to, as user@host
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
184
188
 
185
189
  DESCRIPTION
186
- Open a remote workspace: mirror the current directory onto a remote Linux box via reverse SSHFS and drop into a shell
187
- there, cleaning everything up on exit.
190
+ Open a remote workspace: two-way sync the current directory to a remote Linux box with Mutagen and drop into a shell
191
+ there, tearing the sync down on exit.
188
192
 
189
- The remote reaches your machine through the ssh tunnel (-R), so your local SSH server is never exposed to the internet
190
- or port-forwarded. For the back-connection the remote authenticates with its OWN SSH key, which is temporarily added
191
- to your local authorized_keys and removed again on exit. Locally you need an SSH *server* running (sshd), not sshfs
192
- sshfs runs on the remote.
193
+ Both sides keep a real copy on local disk and only deltas cross the network, so the remote reads files at native
194
+ speed. Conflicting edits on both ends are flagged rather than silently overwritten; pass --source to auto-resolve them
195
+ in favour of one side. Mutagen connects the normal direction (this machine remote over SSH) and auto-deploys its
196
+ agent to the remote, so no local SSH server, reverse tunnel, or authorized_keys trust is required. You need the
197
+ Mutagen CLI installed on THIS machine.
193
198
 
194
199
  EXAMPLES
195
200
  $ fnd workspace --ssh user@203.0.113.4
196
201
 
197
- $ fnd workspace --ssh user@host --port 40222
202
+ $ fnd workspace --ssh user@host --source local
198
203
 
199
- $ fnd workspace --ssh user@host --mount-base /home/fnd --local-ssh-port 2222
204
+ $ fnd workspace --ssh user@host --remote-base /home/fnd
205
+
206
+ $ fnd workspace --ssh user@host --devtools 9222
207
+
208
+ $ fnd workspace --ssh user@host --devtools 9333:9222
200
209
  ```
201
210
 
202
- _See code: [src/commands/workspace/index.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/workspace/index.ts)_
211
+ _See code: [src/commands/workspace/index.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.1/src/commands/workspace/index.ts)_
203
212
 
204
213
  ## `fnd workspace cleanup`
205
214
 
206
- Unmount a leftover reverse-SSHFS mount on the remote — use this if a workspace session dropped before it could clean up after itself.
215
+ Tear down a leftover workspace — use this if a `workspace` session dropped before it could clean up after itself.
207
216
 
208
217
  ```
209
218
  USAGE
210
- $ fnd workspace cleanup --ssh <value> [--mount <value>] [--mount-base <value>]
219
+ $ fnd workspace cleanup --ssh <value> [--remote-base <value>] [--remote-dir <value>]
211
220
 
212
221
  FLAGS
213
- --mount=<value> exact remote mount path to unmount (defaults to the current dir mapping)
214
- --mount-base=<value> [default: /home/fnd] base dir on the remote, used to derive the default mount path
215
- --ssh=<value> (required) remote to connect to, as user@host
222
+ --remote-base=<value> [default: /home/fnd] base dir on the remote, used to derive the default remote directory path
223
+ --remote-dir=<value> exact remote directory to target (defaults to the current dir mapping)
224
+ --ssh=<value> (required) remote to connect to, as user@host
216
225
 
217
226
  DESCRIPTION
218
- Unmount a leftover reverse-SSHFS mount on the remote — use this if a workspace session dropped before it could clean
219
- up after itself.
227
+ Tear down a leftover workspace — use this if a `workspace` session dropped before it could clean up after itself.
220
228
 
221
- Unmounts the remote mount and removes the matching entry from your local authorized_keys. With no --mount, it targets
222
- the same path `workspace` would use for the current directory.
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.
223
232
 
224
233
  EXAMPLES
225
234
  $ fnd workspace cleanup --ssh user@host
226
235
 
227
- $ fnd workspace cleanup --ssh user@host --mount /home/fnd/cole/fnd-cli
236
+ $ fnd workspace cleanup --ssh user@host --remote-dir /home/fnd/cole/fnd-cli
228
237
  ```
229
238
 
230
- _See code: [src/commands/workspace/cleanup.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/workspace/cleanup.ts)_
239
+ _See code: [src/commands/workspace/cleanup.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.1/src/commands/workspace/cleanup.ts)_
231
240
  <!-- commandsstop -->
@@ -3,8 +3,8 @@ export default class WorkspaceCleanup extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
- mount: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
- 'mount-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
6
+ 'remote-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
+ 'remote-dir': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
8
  ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
9
  };
10
10
  run(): Promise<void>;
@@ -1,19 +1,20 @@
1
1
  import { Command, Flags } from '@oclif/core';
2
2
  import chalk from 'chalk';
3
- import { buildContext, DEFAULT_MOUNT_BASE, hasSshClient, keyCommentForMount, parseSshTarget, removeAuthorizedKey, runRemoteCleanup, } from '../../lib/workspace.js';
3
+ import { basename } from 'node:path';
4
+ import { buildContext, buildMutagenTerminateSelectorArgs, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, parseSshTarget, runMutagen, runRemoteCleanup, slugify, } from '../../lib/workspace.js';
4
5
  export default class WorkspaceCleanup extends Command {
5
- static description = 'Unmount a leftover reverse-SSHFS mount on the remote — use this if a workspace session dropped before it could clean up after itself.\n\nUnmounts the remote mount and removes the matching entry from your local authorized_keys. With no --mount, it targets the same path `workspace` would use for the current directory.';
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
7
  static examples = [
7
8
  '<%= config.bin %> <%= command.id %> --ssh user@host',
8
- '<%= config.bin %> <%= command.id %> --ssh user@host --mount /home/fnd/cole/fnd-cli',
9
+ '<%= config.bin %> <%= command.id %> --ssh user@host --remote-dir /home/fnd/cole/fnd-cli',
9
10
  ];
10
11
  static flags = {
11
- mount: Flags.string({
12
- description: 'exact remote mount path to unmount (defaults to the current dir mapping)',
13
- }),
14
- 'mount-base': Flags.string({
12
+ 'remote-base': Flags.string({
15
13
  default: DEFAULT_MOUNT_BASE,
16
- description: 'base dir on the remote, used to derive the default mount path',
14
+ description: 'base dir on the remote, used to derive the default remote directory path',
15
+ }),
16
+ 'remote-dir': Flags.string({
17
+ description: 'exact remote directory to target (defaults to the current dir mapping)',
17
18
  }),
18
19
  ssh: Flags.string({
19
20
  description: 'remote to connect to, as user@host',
@@ -26,21 +27,30 @@ export default class WorkspaceCleanup extends Command {
26
27
  this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
27
28
  }
28
29
  const target = parseSshTarget(flags.ssh);
29
- const mount = flags.mount ?? buildContext({ cwd: process.cwd(), mountBase: flags['mount-base'] }).mount;
30
- this.log(chalk.bold('Cleaning up remote mount'));
31
- this.log(` ${chalk.dim('remote:')} ${target.user}@${target.host}`);
32
- this.log(` ${chalk.dim('mount:')} ${mount}`);
30
+ const target2 = `${target.user}@${target.host}`;
31
+ const remoteDir = flags['remote-dir'] ??
32
+ buildContext({ cwd: process.cwd(), remoteBase: flags['remote-base'] }).remoteDir;
33
+ const slug = slugify(basename(remoteDir));
34
+ this.log(chalk.bold('Cleaning up workspace'));
35
+ this.log(` ${chalk.dim('remote:')} ${target2}`);
36
+ this.log(` ${chalk.dim('remote dir:')} ${remoteDir}`);
33
37
  this.log('');
34
- const code = await runRemoteCleanup(`${target.user}@${target.host}`, mount);
35
- // Revoke local trust for this workspace's key regardless of the remote result.
36
- removeAuthorizedKey(keyCommentForMount(mount));
38
+ // Terminate any lingering sync sessions for this directory (a local Mutagen op).
39
+ if (hasMutagen()) {
40
+ this.log(chalk.dim('Terminating any leftover Mutagen sync sessions…'));
41
+ await runMutagen(buildMutagenTerminateSelectorArgs(slug)).catch(() => 1);
42
+ }
43
+ else {
44
+ this.log(chalk.yellow('Mutagen CLI not found on PATH — skipping sync termination.'));
45
+ }
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);
37
49
  if (code === 0) {
38
- this.log(chalk.green('✓ Done. Local authorized_keys entry removed.'));
50
+ this.log(chalk.green('✓ Done.'));
39
51
  }
40
52
  else {
41
- this.error(`Cleanup ssh session exited with code ${code} (local authorized_keys entry still removed).`, {
42
- code: '1',
43
- });
53
+ this.error(`Remote cleanup ssh session exited with code ${code}.`, { code: '1' });
44
54
  }
45
55
  }
46
56
  }
@@ -3,13 +3,13 @@ export default class Workspace extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
- 'local-ssh-port': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
7
- 'mount-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
- port: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
+ devtools: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ 'remote-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ source: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
9
  ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
10
  };
11
11
  run(): Promise<void>;
12
- /** Verify the local machine can actually host the tunnel before we connect. */
12
+ /** Verify this machine can drive the sync before we connect. */
13
13
  private preflight;
14
14
  private printPlan;
15
15
  /** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
@@ -1,26 +1,27 @@
1
1
  import { Command, Flags } from '@oclif/core';
2
2
  import chalk from 'chalk';
3
3
  import { spawn } from 'node:child_process';
4
- import { addAuthorizedKey, buildContext, buildRemoteScript, DEFAULT_LOCAL_SSH_PORT, DEFAULT_MOUNT_BASE, fetchRemotePublicKey, hasSshClient, isLocalSshdReachable, parseSshTarget, removeAuthorizedKey, runRemoteCleanup, sshServerInstructions, } from '../../lib/workspace.js';
4
+ import { browserDebugInstructions, buildContext, buildMutagenCreateArgs, buildMutagenFlushArgs, buildMutagenTerminateArgs, buildRemoteScript, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, isLocalDebugPortLive, mutagenInstallInstructions, parseDevtoolsPort, parseSshTarget, runMutagen, runRemoteCleanup, } from '../../lib/workspace.js';
5
5
  export default class Workspace extends Command {
6
- static description = 'Open a remote workspace: mirror the current directory onto a remote Linux box via reverse SSHFS and drop into a shell there, cleaning everything up on exit.\n\nThe remote reaches your machine through the ssh tunnel (-R), so your local SSH server is never exposed to the internet or port-forwarded. For the back-connection the remote authenticates with its OWN SSH key, which is temporarily added to your local authorized_keys and removed again on exit. Locally you need an SSH *server* running (sshd), not sshfs sshfs runs on the remote.';
6
+ 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
7
  static examples = [
8
8
  '<%= config.bin %> <%= command.id %> --ssh user@203.0.113.4',
9
- '<%= config.bin %> <%= command.id %> --ssh user@host --port 40222',
10
- '<%= config.bin %> <%= command.id %> --ssh user@host --mount-base /home/fnd --local-ssh-port 2222',
9
+ '<%= config.bin %> <%= command.id %> --ssh user@host --source local',
10
+ '<%= config.bin %> <%= command.id %> --ssh user@host --remote-base /home/fnd',
11
+ '<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9222',
12
+ '<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9333:9222',
11
13
  ];
12
14
  static flags = {
13
- 'local-ssh-port': Flags.integer({
14
- default: DEFAULT_LOCAL_SSH_PORT,
15
- description: 'port your LOCAL SSH server listens on (the tunnel forwards back to this)',
15
+ devtools: Flags.string({
16
+ 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>.',
16
17
  }),
17
- 'mount-base': Flags.string({
18
+ 'remote-base': Flags.string({
18
19
  default: DEFAULT_MOUNT_BASE,
19
- description: 'base dir on the remote; the mount lands at <base>/<local-user>/<dir-name>',
20
+ description: 'base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>',
20
21
  }),
21
- port: Flags.integer({
22
- char: 'p',
23
- description: 'reverse-tunnel port opened on the remote (random 20000-60000 if omitted)',
22
+ source: Flags.string({
23
+ 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
+ options: ['remote', 'local'],
24
25
  }),
25
26
  ssh: Flags.string({
26
27
  description: 'remote to connect to, as user@host',
@@ -30,71 +31,103 @@ export default class Workspace extends Command {
30
31
  async run() {
31
32
  const { flags } = await this.parse(Workspace);
32
33
  const target = parseSshTarget(flags.ssh);
33
- const ctx = buildContext({ cwd: process.cwd(), mountBase: flags['mount-base'], port: flags.port });
34
- await this.preflight(flags['local-ssh-port']);
35
- this.printPlan(ctx, `${target.user}@${target.host}`);
36
- // Read the remote's own public key and trust it locally for the back-connection.
37
34
  const target2 = `${target.user}@${target.host}`;
38
- this.log(chalk.dim('Fetching the remote SSH key and trusting it locally…'));
39
- const remotePublicKey = fetchRemotePublicKey(target2);
40
- addAuthorizedKey(remotePublicKey, ctx.keyComment);
35
+ const devtools = flags.devtools === undefined ? undefined : parseDevtoolsPort(flags.devtools);
36
+ const ctx = buildContext({
37
+ cwd: process.cwd(),
38
+ devtools,
39
+ remoteBase: flags['remote-base'],
40
+ source: flags.source,
41
+ });
42
+ await this.preflight(devtools);
43
+ this.printPlan(ctx, target2);
44
+ // Start the two-way sync. Mutagen auto-deploys its agent to the remote over SSH.
45
+ this.log(chalk.dim('Starting the Mutagen sync session…'));
46
+ const createCode = await runMutagen(buildMutagenCreateArgs(ctx, target2));
47
+ if (createCode !== 0) {
48
+ this.error(`mutagen sync create failed (exit ${createCode}). Check that the remote is reachable over SSH and try again.`, { code: '1' });
49
+ }
41
50
  let code;
42
51
  try {
52
+ // Block until the first full sync lands so the files exist before the shell opens.
53
+ this.log(chalk.dim('Performing the initial sync…'));
54
+ const flushCode = await runMutagen(buildMutagenFlushArgs(ctx.syncName));
55
+ if (flushCode !== 0) {
56
+ this.error(`Initial mutagen sync flush failed (exit ${flushCode}).`, { code: '1' });
57
+ }
43
58
  const script = buildRemoteScript(ctx);
44
- code = await this.runSsh(target2, ctx.port, flags['local-ssh-port'], script);
59
+ code = await this.runSsh(target2, script, ctx.devtools);
45
60
  }
46
61
  finally {
47
- // Tear down from the local side (the remote script has no trap). Best-effort:
48
- // if the connection is truly gone, unmounting won't reach the remote either.
62
+ // Best-effort: flush the last edits back, then tear the session down.
49
63
  this.log('');
50
- this.log(chalk.dim('Cleaning up the remote mount…'));
51
- try {
52
- await runRemoteCleanup(target2, ctx.mount);
53
- }
54
- catch (error) {
55
- this.log(chalk.yellow(`Could not reach the remote to unmount (${error.message}). ` +
56
- `Run later:\n fnd workspace cleanup --ssh ${target2} --mount ${ctx.mount}`));
64
+ this.log(chalk.dim('Flushing final changes and stopping the sync…'));
65
+ await runMutagen(buildMutagenFlushArgs(ctx.syncName)).catch(() => 1);
66
+ 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…'));
70
+ try {
71
+ await runRemoteCleanup(target2, ctx.remoteDir);
72
+ }
73
+ catch (error) {
74
+ this.log(chalk.yellow(`Could not reach the remote to remove the devtools config (${error.message}).`));
75
+ }
57
76
  }
58
- // Always revoke local trust, even if the session crashed or was killed.
59
- removeAuthorizedKey(ctx.keyComment);
60
77
  }
61
78
  this.log('');
62
79
  this.log(code === 0
63
- ? chalk.green('✓ Workspace closed. Remote mount unmounted and local trust removed.')
80
+ ? chalk.green('✓ Workspace closed. Sync stopped; the remote copy is left in place.')
64
81
  : chalk.yellow(`Session ended with exit code ${code}. Cleanup attempted above.`));
65
82
  }
66
- /** Verify the local machine can actually host the tunnel before we connect. */
67
- async preflight(localSshPort) {
83
+ /** Verify this machine can drive the sync before we connect. */
84
+ async preflight(devtools) {
68
85
  if (!hasSshClient()) {
69
86
  this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
70
87
  }
71
- const reachable = await isLocalSshdReachable(localSshPort);
72
- if (!reachable) {
73
- this.log(chalk.red(`✗ No local SSH server answering on 127.0.0.1:${localSshPort}.`));
74
- this.log(chalk.yellow('The remote mounts your files by sshing back through the tunnel, so your machine must be running an SSH server.'));
88
+ if (!hasMutagen()) {
89
+ this.log(chalk.red('✗ Mutagen CLI not found on PATH.'));
90
+ this.log(chalk.yellow('`fnd workspace` uses Mutagen to two-way sync your files to the remote.'));
75
91
  this.log('');
76
- this.log(sshServerInstructions());
77
- this.log('');
78
- this.log(chalk.dim('If your sshd listens on another port, pass --local-ssh-port <port>.'));
79
- this.error('Local SSH server is required. Aborting before connecting.', { code: '1' });
92
+ this.log(mutagenInstallInstructions());
93
+ this.error('Mutagen is required on this machine. Aborting before connecting.', { code: '1' });
94
+ }
95
+ // With --devtools, confirm the local browser's debug port is live BEFORE we
96
+ // connect — otherwise the tunneled MCP would have nothing to reach.
97
+ if (devtools) {
98
+ const live = await isLocalDebugPortLive(devtools.local);
99
+ if (!live) {
100
+ this.log(chalk.red(`✗ No browser debug endpoint answering on 127.0.0.1:${devtools.local}.`));
101
+ this.log(chalk.yellow('The chrome-devtools MCP on the remote drives your LOCAL browser through the tunnel.'));
102
+ this.log('');
103
+ this.log(browserDebugInstructions(devtools.local));
104
+ this.error('Local browser remote-debugging port is required for --devtools. Aborting.', { code: '1' });
105
+ }
80
106
  }
81
107
  }
82
108
  printPlan(ctx, target) {
109
+ const sync = ctx.source === undefined
110
+ ? 'two-way, conflicts flagged (pass --source to auto-resolve)'
111
+ : `two-way, conflicts resolved in favour of ${ctx.source === 'remote' ? 'the remote (this server)' : 'local (the machine you called from)'}`;
83
112
  this.log(chalk.bold('Opening remote workspace'));
84
113
  this.log(` ${chalk.dim('remote:')} ${target}`);
85
- this.log(` ${chalk.dim('mirroring:')} ${ctx.localCwd}`);
86
- this.log(` ${chalk.dim('mounted at:')} ${ctx.mount}`);
87
- this.log(` ${chalk.dim('tunnel port:')} ${ctx.port}`);
114
+ this.log(` ${chalk.dim('local dir:')} ${ctx.localCwd}`);
115
+ this.log(` ${chalk.dim('remote dir:')} ${ctx.remoteDir}`);
116
+ this.log(` ${chalk.dim('sync:')} ${sync}`);
117
+ if (ctx.devtools) {
118
+ this.log(` ${chalk.dim('devtools:')} remote 127.0.0.1:${ctx.devtools.remote} → local browser 127.0.0.1:${ctx.devtools.local}`);
119
+ }
88
120
  this.log('');
89
121
  }
90
122
  /** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
91
- runSsh(target, port, localSshPort, script) {
123
+ runSsh(target, script, devtools) {
92
124
  const args = [
93
125
  '-t', // allocate a remote PTY for the interactive shell session
94
- '-o',
95
- 'ExitOnForwardFailure=yes', // fail loudly if the remote can't open the -R port
96
- '-R',
97
- `${port}:localhost:${localSshPort}`,
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
+ : []),
98
131
  target,
99
132
  script,
100
133
  ];
@@ -1,89 +1,134 @@
1
1
  /**
2
- * Reverse-SSHFS workspace helpers.
2
+ * Mutagen-backed workspace helpers.
3
3
  *
4
4
  * The topology this supports:
5
5
  *
6
- * local machine (any OS) remote machine (always Linux)
6
+ * local machine (any OS) Internet remote machine (always Linux)
7
7
  * ┌─────────────────────┐ ┌──────────────────────────────┐
8
- * │ fnd workspace ssh -R ───► │ sshd
9
- * │ your cwd is served │ │ └─ sshfs -p <port> back ──┐
10
- * │ by the LOCAL sshd ◄─┼── tunnel ──────┼─ localhost:<port> ─────────┘
11
- * │ (port 22 by default) │ │ mounts your cwd at <mount>
8
+ * │ fnd workspace │ sshd
9
+ * │ └─ mutagen CLI ────┼── ssh ────────►│ └─ mutagen-agent
10
+ * │ (orchestrator) │ (two-way sync)│ real copy on local disk
11
+ * │ your cwd (a real │◄───────────────┼─ <base>/<user>/<dir>
12
+ * │ copy on disk) │ deltas only │ │
12
13
  * └─────────────────────┘ └──────────────────────────────┘
13
14
  *
14
- * Because the remote reaches your machine THROUGH the ssh tunnel (`-R`), your
15
- * local SSH server never has to be exposed to the internet or port-forwarded.
15
+ * BOTH sides hold a real copy on local disk and Mutagen propagates only the
16
+ * deltas. The remote therefore reads files at native disk speed (no per-file
17
+ * network round-trips), so lint/build/test on the remote are not IO-bound on
18
+ * the tunnel.
16
19
  *
17
- * The back-connection authenticates with the REMOTE's own SSH key: we read the
18
- * remote's public key, add it to your local authorized_keys (tagged with a
19
- * marker comment), and remove exactly that line on cleanup. We never generate
20
- * key material and never delete the remote's key.
20
+ * Mutagen connects the normal direction (local remote over SSH), so we need
21
+ * NO local SSH server, NO reverse tunnel, and NO authorized_keys trust dance.
22
+ * Mutagen auto-deploys its agent to the remote over that same SSH connection;
23
+ * nothing to install on the remote by hand.
21
24
  *
22
- * IMPORTANT: locally you need an SSH *server* (sshd) running NOT sshfs. sshfs
23
- * runs on the remote, which is why we check for it there, not here.
25
+ * The one reverse tunnel that remains is optional and unrelated to files: with
26
+ * --devtools we open `ssh -R` so the remote's chrome-devtools MCP can reach the
27
+ * caller's LOCAL browser.
24
28
  */
25
29
  export interface SshTarget {
26
30
  host: string;
27
31
  user: string;
28
32
  }
33
+ export interface DevtoolsPorts {
34
+ /** Remote-debugging port of the browser on the caller's LOCAL machine. */
35
+ local: number;
36
+ /** Port opened on the REMOTE (via `ssh -R`) that tunnels back to the local browser. */
37
+ remote: number;
38
+ }
39
+ /**
40
+ * Which side wins when the same path changed on both ends since the last sync.
41
+ * `remote` = this server (the box where the workspace shell runs); `local` = the
42
+ * machine `fnd workspace` was invoked from. Maps to Mutagen's alpha endpoint in
43
+ * two-way-resolved mode (alpha always wins conflicts). When left undefined the
44
+ * session uses two-way-safe mode, which flags conflicts instead of resolving them.
45
+ */
46
+ export type SyncSource = 'local' | 'remote';
29
47
  export interface WorkspaceContext {
30
- /** authorized_keys marker tying the trusted key to this workspace (derivable from the mount). */
31
- keyComment: string;
32
- /** Absolute path of the current dir on the LOCAL machine (what gets mirrored). */
48
+ /** Chrome DevTools MCP tunnel, when --devtools was passed; undefined otherwise. */
49
+ devtools?: DevtoolsPorts;
50
+ /** Absolute path of the current dir on the LOCAL machine (one side of the sync). */
33
51
  localCwd: string;
34
- /** Basename of the local cwd — the leaf of the remote mount path. */
52
+ /** Basename of the local cwd — the leaf of the remote directory path. */
35
53
  localDirName: string;
36
- /** Username on the LOCAL machine — used both for the mount path and to auth the sshfs back-connection. */
54
+ /** Username on the LOCAL machine — used to namespace the remote directory path. */
37
55
  localUser: string;
38
- /** Where the mirror is mounted on the REMOTE, e.g. /home/fnd/<localUser>/<localDirName>. */
39
- mount: string;
40
- /** The reverse-tunnel port opened on the remote (`-R <port>:localhost:<localSshPort>`). */
41
- port: number;
56
+ /** Where the mirror lives on the REMOTE, e.g. /home/fnd/<localUser>/<localDirName>. */
57
+ remoteDir: string;
58
+ /** Which endpoint wins conflicts (the Mutagen alpha in two-way-resolved); undefined flags conflicts instead. */
59
+ source?: SyncSource;
60
+ /** Unique Mutagen session name for this workspace. */
61
+ syncName: string;
42
62
  }
43
63
  export declare const DEFAULT_MOUNT_BASE = "/home/fnd";
44
- export declare const DEFAULT_LOCAL_SSH_PORT = 22;
45
64
  /** Parse a `user@host` string, throwing a friendly error otherwise. */
46
65
  export declare const parseSshTarget: (raw: string) => SshTarget;
47
- /** The authorized_keys marker comment for a given mount — used to add and later remove the key. */
48
- export declare const keyCommentForMount: (mount: string) => string;
66
+ /**
67
+ * Turn an arbitrary directory name into a Mutagen-safe slug (lowercase, only
68
+ * letters/digits/hyphens). Used both in the session name and the `dir` label so
69
+ * cleanup can find sessions for a directory without knowing the random suffix.
70
+ */
71
+ export declare const slugify: (value: string) => string;
72
+ /** A unique Mutagen session name for a workspace on the given local directory. */
73
+ export declare const buildSyncName: (dirName: string) => string;
74
+ /**
75
+ * Parse the --devtools value. Accepts `port` (same port on both ends) or
76
+ * `remote:local`, where `local` is the caller's machine (where the browser runs)
77
+ * and `remote` is the port opened on the workspace host.
78
+ */
79
+ export declare const parseDevtoolsPort: (raw: string) => DevtoolsPorts;
49
80
  /** Build the immutable facts for a workspace session from the local environment + flags. */
50
81
  export declare const buildContext: (opts: {
51
82
  cwd: string;
52
- mountBase: string;
53
- port?: number;
83
+ devtools?: DevtoolsPorts;
84
+ remoteBase: string;
85
+ source?: SyncSource;
54
86
  }) => WorkspaceContext;
55
87
  /** POSIX single-quote a string so it can be embedded safely in the remote shell script. */
56
88
  export declare const shQuote: (value: string) => string;
57
89
  /**
58
- * Read the remote's SSH public key so we can trust it locally for the back
59
- * connection. Ensures an ed25519 key exists on the remote (creating one only if
60
- * the remote has none) and prints it. Returns the single public-key line.
90
+ * The bash script the remote runs for the interactive session. Mutagen already
91
+ * keeps the directory in sync, so this just makes sure the directory exists,
92
+ * optionally wires up the chrome-devtools MCP, and drops into a login shell.
61
93
  */
62
- export declare const fetchRemotePublicKey: (target: string) => string;
63
- /** Path to the local user's authorized_keys (cross-platform via os.homedir()). */
64
- export declare const authorizedKeysPath: () => string;
65
- /**
66
- * Trust the remote's public key locally so it can ssh back in. The marker
67
- * comment is appended so cleanup can remove exactly this line later.
68
- */
69
- export declare const addAuthorizedKey: (publicKey: string, comment: string) => void;
70
- /** Remove any authorized_keys line carrying the given marker comment. */
71
- export declare const removeAuthorizedKey: (comment: string) => void;
94
+ export declare const buildRemoteScript: (ctx: WorkspaceContext) => string;
95
+ /** Run the remote-side teardown (strip this project's chrome-devtools MCP) over a fresh ssh connection. */
96
+ export declare const runRemoteCleanup: (target: string, remoteDir: string) => Promise<number>;
72
97
  /**
73
- * The bash script the remote runs. It mounts the local cwd via reverse sshfs
74
- * (authenticating with its own default key) and drops into an interactive
75
- * shell. Teardown (unmount + rmdir) is driven from the LOCAL side in
76
- * a `finally` via runRemoteCleanupno in-script trap, which proved brittle
77
- * inside the PTY session.
98
+ * The remote-side teardown script: strip this project's chrome-devtools MCP via
99
+ * the `claude` CLI. Runs from inside the workspace dir so local scope resolves to
100
+ * the right project. A no-op if there is nothing to undo. The synced files
101
+ * themselves are left in place they are a real copy, not a mount.
78
102
  */
79
- export declare const buildRemoteScript: (ctx: WorkspaceContext) => string;
80
- /** Run the remote-side teardown (unmount + rmdir) over a fresh ssh connection. */
81
- export declare const runRemoteCleanup: (target: string, mount: string) => Promise<number>;
82
- /** The remote-side teardown script (unmount only — the key is the remote's own). */
83
- export declare const buildCleanupScript: (mount: string) => string;
103
+ export declare const buildCleanupScript: (remoteDir: string) => string;
84
104
  /** True if an `ssh` client is on PATH (works on Windows, macOS, Linux). */
85
105
  export declare const hasSshClient: () => boolean;
86
- /** Resolve true if a local SSH server is accepting connections on 127.0.0.1:<port>. */
87
- export declare const isLocalSshdReachable: (port: number, timeoutMs?: number) => Promise<boolean>;
88
- /** Platform-specific instructions for turning on the local OpenSSH server. */
89
- export declare const sshServerInstructions: (platform?: NodeJS.Platform) => string;
106
+ /** True if the Mutagen CLI is on PATH and runnable. */
107
+ export declare const hasMutagen: () => boolean;
108
+ /**
109
+ * Arguments for `mutagen sync create`. With no `source`, the session runs in
110
+ * two-way-safe mode, which halts and flags conflicts rather than picking a
111
+ * winner. When a `source` is given, it becomes the Mutagen alpha endpoint and
112
+ * the mode switches to two-way-resolved (alpha always wins conflicts), so
113
+ * `--source remote` puts the server first and `--source local` puts this
114
+ * machine first. Labels let `workspace cleanup` find and terminate orphans.
115
+ */
116
+ export declare const buildMutagenCreateArgs: (ctx: WorkspaceContext, target: string) => string[];
117
+ /** Arguments for `mutagen sync flush <name>` — block until one full sync completes. */
118
+ export declare const buildMutagenFlushArgs: (name: string) => string[];
119
+ /** Arguments for `mutagen sync terminate <name>` — stop this exact session. */
120
+ export declare const buildMutagenTerminateArgs: (name: string) => string[];
121
+ /** Arguments for terminating any orphaned workspace sessions for a given directory slug. */
122
+ export declare const buildMutagenTerminateSelectorArgs: (dirSlug: string) => string[];
123
+ /** Run a Mutagen CLI command, inheriting stdio so its progress reaches the user. */
124
+ export declare const runMutagen: (args: string[]) => Promise<number>;
125
+ /**
126
+ * Resolve true if a Chrome/Chromium remote-debugging endpoint answers on
127
+ * 127.0.0.1:<port> — GET /json/version returns HTTP 200. This is the caller's
128
+ * LOCAL browser, checked before we open the workspace.
129
+ */
130
+ export declare const isLocalDebugPortLive: (port: number, timeoutMs?: number) => Promise<boolean>;
131
+ /** Hint for launching a browser with remote debugging enabled on the given port. */
132
+ export declare const browserDebugInstructions: (port: number) => string;
133
+ /** Platform-specific instructions for installing the Mutagen CLI on the machine running `fnd workspace`. */
134
+ export declare const mutagenInstallInstructions: (platform?: NodeJS.Platform) => string;
@@ -1,11 +1,8 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { randomInt } from 'node:crypto';
3
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { connect } from 'node:net';
5
- import { homedir, userInfo } from 'node:os';
6
- import { basename, join } from 'node:path';
3
+ import { userInfo } from 'node:os';
4
+ import { basename } from 'node:path';
7
5
  export const DEFAULT_MOUNT_BASE = '/home/fnd';
8
- export const DEFAULT_LOCAL_SSH_PORT = 22;
9
6
  /** Parse a `user@host` string, throwing a friendly error otherwise. */
10
7
  export const parseSshTarget = (raw) => {
11
8
  const match = raw.trim().match(/^([^@\s]+)@([^@\s]+)$/);
@@ -13,202 +10,234 @@ export const parseSshTarget = (raw) => {
13
10
  throw new Error(`--ssh must be in the form user@host (got "${raw}")`);
14
11
  return { host: match[2], user: match[1] };
15
12
  };
16
- /** The authorized_keys marker comment for a given mount — used to add and later remove the key. */
17
- export const keyCommentForMount = (mount) => `fnd-workspace:${mount}`;
13
+ /**
14
+ * Turn an arbitrary directory name into a Mutagen-safe slug (lowercase, only
15
+ * letters/digits/hyphens). Used both in the session name and the `dir` label so
16
+ * cleanup can find sessions for a directory without knowing the random suffix.
17
+ */
18
+ export const slugify = (value) => value
19
+ .toLowerCase()
20
+ .replaceAll(/[^a-z0-9]+/g, '-')
21
+ .replaceAll(/^-+|-+$/g, '')
22
+ .slice(0, 40) || 'workspace';
23
+ /** A unique Mutagen session name for a workspace on the given local directory. */
24
+ export const buildSyncName = (dirName) => `fnd-ws-${slugify(dirName)}-${randomInt(0, 1_000_000).toString(36)}`;
25
+ /**
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.
29
+ */
30
+ export const parseDevtoolsPort = (raw) => {
31
+ const toPort = (value) => {
32
+ const n = Number(value);
33
+ if (!Number.isInteger(n) || n < 1 || n > 65_535) {
34
+ throw new Error(`--devtools port must be an integer 1-65535 (got "${value}")`);
35
+ }
36
+ return n;
37
+ };
38
+ const parts = raw.trim().split(':');
39
+ if (parts.length === 1) {
40
+ const p = toPort(parts[0]);
41
+ return { local: p, remote: p };
42
+ }
43
+ if (parts.length === 2) {
44
+ return { local: toPort(parts[1]), remote: toPort(parts[0]) };
45
+ }
46
+ throw new Error(`--devtools must be "port" or "remote:local" (got "${raw}")`);
47
+ };
18
48
  /** Build the immutable facts for a workspace session from the local environment + flags. */
19
49
  export const buildContext = (opts) => {
20
50
  const localUser = userInfo().username;
21
51
  const localCwd = opts.cwd;
22
52
  const localDirName = basename(localCwd);
23
- const base = opts.mountBase.replace(/\/+$/, '');
24
- const mount = `${base}/${localUser}/${localDirName}`;
25
- // Ephemeral-ish range, kept below 65535 and clear of most well-known ports.
26
- const port = opts.port ?? randomInt(20_000, 60_000);
53
+ const base = opts.remoteBase.replace(/\/+$/, '');
54
+ const remoteDir = `${base}/${localUser}/${localDirName}`;
27
55
  return {
28
- keyComment: keyCommentForMount(mount),
56
+ devtools: opts.devtools,
29
57
  localCwd,
30
58
  localDirName,
31
59
  localUser,
32
- mount,
33
- port,
60
+ remoteDir,
61
+ source: opts.source,
62
+ syncName: buildSyncName(localDirName),
34
63
  };
35
64
  };
36
65
  /** POSIX single-quote a string so it can be embedded safely in the remote shell script. */
37
66
  export const shQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
67
+ /** MCP server name registered for the workspace's chrome-devtools tunnel. */
68
+ const DEVTOOLS_MCP_NAME = 'chrome-devtools';
38
69
  /**
39
- * Read the remote's SSH public key so we can trust it locally for the back
40
- * connection. Ensures an ed25519 key exists on the remote (creating one only if
41
- * the remote has none) and prints it. Returns the single public-key line.
70
+ * Bash lines (run on the REMOTE, from inside the workspace dir) that register the
71
+ * chrome-devtools MCP with the `claude` CLI. Local scope keys off the current
72
+ * directory, so this only affects the workspace project never a global server.
73
+ * A prior entry is cleared first so a re-connect after a crashed session is
74
+ * idempotent. Skips gracefully if the claude CLI is missing.
75
+ *
76
+ * The block runs inside a login shell (`$SHELL -lc`): the outer script arrives as
77
+ * a non-login ssh command whose PATH lacks the node/nvm/volta/Homebrew dirs that
78
+ * login profiles add, so a bare `command -v claude` misses an installed CLI. A
79
+ * login shell reproduces the same PATH the interactive session below gets.
42
80
  */
43
- export const fetchRemotePublicKey = (target) => {
44
- const remoteScript = [
45
- 'set -e',
46
- 'KEY="$HOME/.ssh/id_ed25519"',
47
- 'mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"',
48
- 'if [ ! -f "$KEY.pub" ]; then ssh-keygen -t ed25519 -N "" -f "$KEY" -q; fi',
49
- 'cat "$KEY.pub"',
81
+ const claudeDevtoolsAddScript = (remotePort, okMessage) => {
82
+ const body = [
83
+ 'if command -v claude >/dev/null 2>&1; then',
84
+ ` claude mcp remove ${DEVTOOLS_MCP_NAME} >/dev/null 2>&1 || true`,
85
+ ` claude mcp add ${DEVTOOLS_MCP_NAME} -- npx -y chrome-devtools-mcp@latest --browserUrl http://127.0.0.1:${remotePort}`,
86
+ ` echo ${shQuote(okMessage)}`,
87
+ 'else',
88
+ ' echo "WARNING: claude CLI not found on the remote; skipped chrome-devtools MCP config." >&2',
89
+ 'fi',
50
90
  ].join('\n');
51
- // stdout piped (we capture the key); stdin/stderr inherited so any password or
52
- // passphrase prompt still reaches the user's terminal.
53
- const result = spawnSync('ssh', [target, remoteScript], {
54
- encoding: 'utf8',
55
- stdio: ['inherit', 'pipe', 'inherit'],
56
- });
57
- if (result.error)
58
- throw new Error(`Could not ssh to ${target}: ${result.error.message}`);
59
- if (result.status !== 0)
60
- throw new Error(`Could not read the remote's SSH key (ssh exited ${result.status}).`);
61
- const key = (result.stdout ?? '').trim();
62
- if (!key.startsWith('ssh-'))
63
- throw new Error(`Unexpected remote key output: ${key.slice(0, 80)}`);
64
- return key;
91
+ return [`"\${SHELL:-bash}" -lc ${shQuote(body)}`];
65
92
  };
66
- /** Path to the local user's authorized_keys (cross-platform via os.homedir()). */
67
- export const authorizedKeysPath = () => join(homedir(), '.ssh', 'authorized_keys');
68
93
  /**
69
- * Trust the remote's public key locally so it can ssh back in. The marker
70
- * comment is appended so cleanup can remove exactly this line later.
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
96
+ * directory, so it targets only the workspace project. No-op if the entry or the
97
+ * claude CLI is absent. Runs under a login shell for the same PATH reason as
98
+ * `claudeDevtoolsAddScript`.
71
99
  */
72
- export const addAuthorizedKey = (publicKey, comment) => {
73
- const sshDir = join(homedir(), '.ssh');
74
- const file = authorizedKeysPath();
75
- if (!existsSync(sshDir))
76
- mkdirSync(sshDir, { mode: 0o700, recursive: true });
77
- const line = `${publicKey.trim()} ${comment}`;
78
- const existing = existsSync(file) ? readFileSync(file, 'utf8') : '';
79
- const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
80
- appendFileSync(file, `${prefix}${line}\n`);
81
- // sshd ignores authorized_keys with loose perms (POSIX); best-effort on Windows.
82
- try {
83
- chmodSync(file, 0o600);
84
- }
85
- catch {
86
- /* Windows uses ACLs, not POSIX modes — nothing to do here. */
87
- }
88
- };
89
- /** Remove any authorized_keys line carrying the given marker comment. */
90
- export const removeAuthorizedKey = (comment) => {
91
- const file = authorizedKeysPath();
92
- if (!existsSync(file))
93
- return;
94
- const kept = readFileSync(file, 'utf8')
95
- .split('\n')
96
- .filter((line) => line.length > 0 && !line.includes(comment));
97
- writeFileSync(file, kept.length > 0 ? `${kept.join('\n')}\n` : '');
100
+ const claudeDevtoolsRemoveScript = () => {
101
+ const body = ['if command -v claude >/dev/null 2>&1; then', ` claude mcp remove ${DEVTOOLS_MCP_NAME}`, 'fi'].join('\n');
102
+ return [`"\${SHELL:-bash}" -lc ${shQuote(body)}`];
98
103
  };
99
104
  /**
100
- * The bash script the remote runs. It mounts the local cwd via reverse sshfs
101
- * (authenticating with its own default key) and drops into an interactive
102
- * shell. Teardown (unmount + rmdir) is driven from the LOCAL side in
103
- * a `finally` via runRemoteCleanup — no in-script trap, which proved brittle
104
- * inside the PTY session.
105
+ * The bash script the remote runs for the interactive session. Mutagen already
106
+ * keeps the directory in sync, so this just makes sure the directory exists,
107
+ * optionally wires up the chrome-devtools MCP, and drops into a login shell.
105
108
  */
106
109
  export const buildRemoteScript = (ctx) => {
107
- const mount = shQuote(ctx.mount);
108
- // `user@localhost:/absolute/local/path` quoted whole so spaces survive.
109
- const remoteSource = shQuote(`${ctx.localUser}@localhost:${ctx.localCwd}`);
110
- const sshfsOpts = [
111
- // Connect to the -R tunnel's entrance on the REMOTE's loopback (ctx.port),
112
- // which forwards back to the local sshd. NOT the local sshd port directly.
113
- `port=${ctx.port}`,
114
- 'StrictHostKeyChecking=accept-new',
115
- 'ServerAliveInterval=15',
116
- 'ServerAliveCountMax=3',
117
- ].join(',');
110
+ const dir = shQuote(ctx.remoteDir);
111
+ const devtoolsSetup = ctx.devtools
112
+ ? claudeDevtoolsAddScript(ctx.devtools.remote, `Configured chrome-devtools MCP for this workspace (browser via 127.0.0.1:${ctx.devtools.remote}).`)
113
+ : [];
118
114
  return [
119
115
  'set -u',
120
- `MOUNT=${mount}`,
121
- 'AGENT_STARTED=0',
122
- // Remote must have sshfs it is always Linux, so give an apt hint.
123
- 'if ! command -v sshfs >/dev/null 2>&1; then',
124
- ' echo "ERROR: sshfs is not installed on the remote. Install it, e.g.: sudo apt install sshfs" >&2',
125
- ' exit 1',
126
- 'fi',
127
- // sshfs daemonizes, so its ssh child can't reliably prompt for a key
128
- // passphrase. Load the key into an ssh-agent ONCE (ssh-add prompts cleanly
129
- // on this PTY); sshfs then authenticates through the agent, no re-prompts.
130
- // eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
131
- 'if [ -n "${SSH_AUTH_SOCK:-}" ] && ssh-add -l >/dev/null 2>&1; then',
132
- ' :', // an agent with keys is already available — reuse it
133
- 'else',
134
- // eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
135
- ' if [ -z "${SSH_AUTH_SOCK:-}" ]; then eval "$(ssh-agent -s)" >/dev/null && AGENT_STARTED=1; fi',
136
- ' echo "Loading your SSH key into the agent (enter its passphrase once if prompted)..."',
137
- ' ssh-add </dev/tty || echo "WARNING: ssh-add loaded no key; sshfs may fail to authenticate back to your machine." >&2',
138
- 'fi',
139
- // Clear any stale mount left by a previous dropped session, then (re)create.
140
- 'fusermount -u "$MOUNT" 2>/dev/null || umount "$MOUNT" 2>/dev/null || true',
141
- 'mkdir -p "$MOUNT" || { echo "ERROR: could not create $MOUNT" >&2; exit 1; }',
142
- 'echo "Mounting your local directory over the reverse tunnel..."',
143
- `sshfs -o ${sshfsOpts} ${remoteSource} "$MOUNT" || { echo "ERROR: sshfs mount failed" >&2; exit 1; }`,
144
- // The mount authenticated at this point; if we started an agent just for
145
- // that, kill it so the decrypted key doesn't sit in memory during the session.
146
- // eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
147
- '[ "$AGENT_STARTED" = 1 ] && kill "${SSH_AGENT_PID:-}" 2>/dev/null || true',
148
- 'cd "$MOUNT" || { echo "ERROR: could not enter $MOUNT" >&2; exit 1; }',
149
- 'echo "Workspace ready at $MOUNT — dropping into a shell (exit to clean up)."',
116
+ `DIR=${dir}`,
117
+ // Mutagen creates the sync root, but ensure it exists so `cd` never races it.
118
+ 'mkdir -p "$DIR" || { echo "ERROR: could not create $DIR" >&2; exit 1; }',
119
+ '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.
121
+ ...devtoolsSetup,
122
+ 'echo "Workspace ready at $DIR — files sync in the background (exit to stop syncing)."',
150
123
  // eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
151
124
  '"${SHELL:-bash}" -l',
152
125
  ].join('\n');
153
126
  };
154
- /** Run the remote-side teardown (unmount + rmdir) over a fresh ssh connection. */
155
- export const runRemoteCleanup = (target, mount) => new Promise((resolve, reject) => {
156
- const child = spawn('ssh', [target, buildCleanupScript(mount)], { stdio: 'inherit' });
127
+ /** Run the remote-side teardown (strip this project's chrome-devtools MCP) over a fresh ssh connection. */
128
+ export const runRemoteCleanup = (target, remoteDir) => new Promise((resolve, reject) => {
129
+ const child = spawn('ssh', [target, buildCleanupScript(remoteDir)], { stdio: 'inherit' });
157
130
  child.once('error', reject);
158
131
  child.once('close', (code) => resolve(code ?? 0));
159
132
  });
160
- /** The remote-side teardown script (unmount only — the key is the remote's own). */
161
- export const buildCleanupScript = (mount) => {
162
- const quotedMount = shQuote(mount);
163
- return [
164
- `MOUNT=${quotedMount}`,
165
- 'if fusermount -u "$MOUNT" 2>/dev/null || fusermount3 -u "$MOUNT" 2>/dev/null || umount "$MOUNT" 2>/dev/null; then',
166
- ' echo "Unmounted $MOUNT"',
167
- 'else',
168
- ' echo "Nothing mounted at $MOUNT (or already unmounted)"',
169
- 'fi',
170
- 'rmdir "$MOUNT" 2>/dev/null && echo "Removed empty $MOUNT" || true',
171
- ].join('\n');
172
- };
133
+ /**
134
+ * The remote-side teardown script: strip this project's chrome-devtools MCP via
135
+ * the `claude` CLI. Runs from inside the workspace dir so local scope resolves to
136
+ * the right project. A no-op if there is nothing to undo. The synced files
137
+ * themselves are left in place — they are a real copy, not a mount.
138
+ */
139
+ export const buildCleanupScript = (remoteDir) => [
140
+ `DIR=${shQuote(remoteDir)}`,
141
+ 'cd "$DIR" 2>/dev/null || { echo "Nothing to clean up: $DIR is gone." >&2; exit 0; }',
142
+ ...claudeDevtoolsRemoveScript(),
143
+ ].join('\n');
173
144
  /** True if an `ssh` client is on PATH (works on Windows, macOS, Linux). */
174
145
  export const hasSshClient = () => {
175
146
  const result = spawnSync('ssh', ['-V'], { stdio: 'ignore' });
176
147
  return !result.error;
177
148
  };
178
- /** Resolve true if a local SSH server is accepting connections on 127.0.0.1:<port>. */
179
- export const isLocalSshdReachable = (port, timeoutMs = 2000) => new Promise((resolve) => {
180
- const socket = connect({ host: '127.0.0.1', port });
181
- const done = (ok) => {
182
- socket.destroy();
183
- resolve(ok);
184
- };
185
- socket.setTimeout(timeoutMs);
186
- socket.once('connect', () => done(true));
187
- socket.once('timeout', () => done(false));
188
- socket.once('error', () => done(false));
149
+ /** True if the Mutagen CLI is on PATH and runnable. */
150
+ export const hasMutagen = () => {
151
+ const result = spawnSync('mutagen', ['version'], { stdio: 'ignore' });
152
+ return !result.error && result.status === 0;
153
+ };
154
+ /**
155
+ * Arguments for `mutagen sync create`. With no `source`, the session runs in
156
+ * two-way-safe mode, which halts and flags conflicts rather than picking a
157
+ * winner. When a `source` is given, it becomes the Mutagen alpha endpoint and
158
+ * the mode switches to two-way-resolved (alpha always wins conflicts), so
159
+ * `--source remote` puts the server first and `--source local` puts this
160
+ * machine first. Labels let `workspace cleanup` find and terminate orphans.
161
+ */
162
+ export const buildMutagenCreateArgs = (ctx, target) => {
163
+ const local = ctx.localCwd;
164
+ const remote = `${target}:${ctx.remoteDir}`;
165
+ // Alpha only matters as the conflict winner in resolved mode; in safe mode the
166
+ // order is cosmetic, so keep local-first for a stable, readable command.
167
+ const [alpha, beta] = ctx.source === 'remote' ? [remote, local] : [local, remote];
168
+ const syncMode = ctx.source === undefined ? 'two-way-safe' : 'two-way-resolved';
169
+ return [
170
+ 'sync',
171
+ 'create',
172
+ `--name=${ctx.syncName}`,
173
+ '--label=managed-by=fnd-workspace',
174
+ `--label=dir=${slugify(ctx.localDirName)}`,
175
+ `--sync-mode=${syncMode}`,
176
+ alpha,
177
+ beta,
178
+ ];
179
+ };
180
+ /** Arguments for `mutagen sync flush <name>` — block until one full sync completes. */
181
+ export const buildMutagenFlushArgs = (name) => ['sync', 'flush', name];
182
+ /** Arguments for `mutagen sync terminate <name>` — stop this exact session. */
183
+ export const buildMutagenTerminateArgs = (name) => ['sync', 'terminate', name];
184
+ /** Arguments for terminating any orphaned workspace sessions for a given directory slug. */
185
+ export const buildMutagenTerminateSelectorArgs = (dirSlug) => [
186
+ 'sync',
187
+ 'terminate',
188
+ `--label-selector=managed-by=fnd-workspace,dir=${dirSlug}`,
189
+ ];
190
+ /** Run a Mutagen CLI command, inheriting stdio so its progress reaches the user. */
191
+ export const runMutagen = (args) => new Promise((resolve, reject) => {
192
+ const child = spawn('mutagen', args, { stdio: 'inherit' });
193
+ child.once('error', reject);
194
+ child.once('close', (code) => resolve(code ?? 0));
189
195
  });
190
- /** Platform-specific instructions for turning on the local OpenSSH server. */
191
- export const sshServerInstructions = (platform = process.platform) => {
196
+ /**
197
+ * Resolve true if a Chrome/Chromium remote-debugging endpoint answers on
198
+ * 127.0.0.1:<port> — GET /json/version returns HTTP 200. This is the caller's
199
+ * LOCAL browser, checked before we open the workspace.
200
+ */
201
+ export const isLocalDebugPortLive = async (port, timeoutMs = 2000) => {
202
+ const controller = new AbortController();
203
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
204
+ try {
205
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: controller.signal });
206
+ return res.status === 200;
207
+ }
208
+ catch {
209
+ return false;
210
+ }
211
+ finally {
212
+ clearTimeout(timer);
213
+ }
214
+ };
215
+ /** Hint for launching a browser with remote debugging enabled on the given port. */
216
+ export const browserDebugInstructions = (port) => [
217
+ `Start your browser with remote debugging on port ${port}, e.g.:`,
218
+ ` Chrome/Chromium: google-chrome --remote-debugging-port=${port}`,
219
+ ` macOS: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=${port}`,
220
+ `Then confirm it answers: curl http://127.0.0.1:${port}/json/version`,
221
+ ].join('\n');
222
+ /** Platform-specific instructions for installing the Mutagen CLI on the machine running `fnd workspace`. */
223
+ export const mutagenInstallInstructions = (platform = process.platform) => {
192
224
  if (platform === 'win32') {
193
225
  return [
194
- 'Windows — enable the OpenSSH Server (run PowerShell as Administrator):',
195
- ' Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0',
196
- ' Start-Service sshd',
197
- " Set-Service -Name sshd -StartupType 'Automatic'",
198
- ' New-NetFirewallRule -Name sshd -DisplayName "OpenSSH Server" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22',
226
+ 'Windows — install the Mutagen CLI:',
227
+ ' scoop install mutagen',
228
+ ' # or: choco install mutagen',
229
+ ' # or download from https://github.com/mutagen-io/mutagen/releases and put mutagen.exe on PATH',
199
230
  ].join('\n');
200
231
  }
201
232
  if (platform === 'darwin') {
202
233
  return [
203
- 'macOS — turn on Remote Login (the built-in SSH server):',
204
- ' sudo systemsetup -setremotelogin on',
205
- ' # or: System Settings → General → Sharing → Remote Login',
234
+ 'macOS — install the Mutagen CLI:',
235
+ ' brew install mutagen-io/mutagen/mutagen',
206
236
  ].join('\n');
207
237
  }
208
238
  return [
209
- 'Linux — install and start the OpenSSH server:',
210
- ' Debian/Ubuntu: sudo apt install openssh-server && sudo systemctl enable --now ssh',
211
- ' Fedora/RHEL: sudo dnf install openssh-server && sudo systemctl enable --now sshd',
212
- ' Arch: sudo pacman -S openssh && sudo systemctl enable --now sshd',
239
+ 'Linux — install the Mutagen CLI:',
240
+ ' brew install mutagen-io/mutagen/mutagen',
241
+ ' # or download from https://github.com/mutagen-io/mutagen/releases and put mutagen on PATH',
213
242
  ].join('\n');
214
243
  };
@@ -186,23 +186,23 @@
186
186
  "workspace:cleanup": {
187
187
  "aliases": [],
188
188
  "args": {},
189
- "description": "Unmount a leftover reverse-SSHFS mount on the remote — use this if a workspace session dropped before it could clean up after itself.\n\nUnmounts the remote mount and removes the matching entry from your local authorized_keys. With no --mount, it targets the same path `workspace` would use for the current directory.",
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 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.",
190
190
  "examples": [
191
191
  "<%= config.bin %> <%= command.id %> --ssh user@host",
192
- "<%= config.bin %> <%= command.id %> --ssh user@host --mount /home/fnd/cole/fnd-cli"
192
+ "<%= config.bin %> <%= command.id %> --ssh user@host --remote-dir /home/fnd/cole/fnd-cli"
193
193
  ],
194
194
  "flags": {
195
- "mount": {
196
- "description": "exact remote mount path to unmount (defaults to the current dir mapping)",
197
- "name": "mount",
195
+ "remote-base": {
196
+ "description": "base dir on the remote, used to derive the default remote directory path",
197
+ "name": "remote-base",
198
+ "default": "/home/fnd",
198
199
  "hasDynamicHelp": false,
199
200
  "multiple": false,
200
201
  "type": "option"
201
202
  },
202
- "mount-base": {
203
- "description": "base dir on the remote, used to derive the default mount path",
204
- "name": "mount-base",
205
- "default": "/home/fnd",
203
+ "remote-dir": {
204
+ "description": "exact remote directory to target (defaults to the current dir mapping)",
205
+ "name": "remote-dir",
206
206
  "hasDynamicHelp": false,
207
207
  "multiple": false,
208
208
  "type": "option"
@@ -235,35 +235,39 @@
235
235
  "workspace": {
236
236
  "aliases": [],
237
237
  "args": {},
238
- "description": "Open a remote workspace: mirror the current directory onto a remote Linux box via reverse SSHFS and drop into a shell there, cleaning everything up on exit.\n\nThe remote reaches your machine through the ssh tunnel (-R), so your local SSH server is never exposed to the internet or port-forwarded. For the back-connection the remote authenticates with its OWN SSH key, which is temporarily added to your local authorized_keys and removed again on exit. Locally you need an SSH *server* running (sshd), not sshfs sshfs runs on the remote.",
238
+ "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.",
239
239
  "examples": [
240
240
  "<%= config.bin %> <%= command.id %> --ssh user@203.0.113.4",
241
- "<%= config.bin %> <%= command.id %> --ssh user@host --port 40222",
242
- "<%= config.bin %> <%= command.id %> --ssh user@host --mount-base /home/fnd --local-ssh-port 2222"
241
+ "<%= config.bin %> <%= command.id %> --ssh user@host --source local",
242
+ "<%= config.bin %> <%= command.id %> --ssh user@host --remote-base /home/fnd",
243
+ "<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9222",
244
+ "<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9333:9222"
243
245
  ],
244
246
  "flags": {
245
- "local-ssh-port": {
246
- "description": "port your LOCAL SSH server listens on (the tunnel forwards back to this)",
247
- "name": "local-ssh-port",
248
- "default": 22,
247
+ "devtools": {
248
+ "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>.",
249
+ "name": "devtools",
249
250
  "hasDynamicHelp": false,
250
251
  "multiple": false,
251
252
  "type": "option"
252
253
  },
253
- "mount-base": {
254
- "description": "base dir on the remote; the mount lands at <base>/<local-user>/<dir-name>",
255
- "name": "mount-base",
254
+ "remote-base": {
255
+ "description": "base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>",
256
+ "name": "remote-base",
256
257
  "default": "/home/fnd",
257
258
  "hasDynamicHelp": false,
258
259
  "multiple": false,
259
260
  "type": "option"
260
261
  },
261
- "port": {
262
- "char": "p",
263
- "description": "reverse-tunnel port opened on the remote (random 20000-60000 if omitted)",
264
- "name": "port",
262
+ "source": {
263
+ "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.",
264
+ "name": "source",
265
265
  "hasDynamicHelp": false,
266
266
  "multiple": false,
267
+ "options": [
268
+ "remote",
269
+ "local"
270
+ ],
267
271
  "type": "option"
268
272
  },
269
273
  "ssh": {
@@ -292,5 +296,5 @@
292
296
  ]
293
297
  }
294
298
  },
295
- "version": "2.2.0"
299
+ "version": "2.3.1"
296
300
  }
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.2.0",
4
+ "version": "2.3.1",
5
5
  "author": "Cole Denslow",
6
6
  "contributors": [
7
7
  "Justin Schellenberg"
@@ -76,7 +76,7 @@
76
76
  "description": "Say hello to the world and others"
77
77
  },
78
78
  "workspace": {
79
- "description": "Open a remote reverse-SSHFS workspace and run Claude on it"
79
+ "description": "Open a remote Mutagen workspace and run Claude on it"
80
80
  }
81
81
  }
82
82
  },
@@ -87,10 +87,12 @@
87
87
  "scripts": {
88
88
  "build": "shx rm -rf dist && tsc -b",
89
89
  "lint": "eslint",
90
+ "pack": "npm run prepack && npm run postpack",
90
91
  "postpack": "shx rm -f oclif.manifest.json",
91
92
  "posttest": "npm run lint",
92
93
  "prepack": "oclif manifest && oclif readme",
93
94
  "version": "oclif readme && git add README.md",
95
+ "prerelease": "npm run build && npm run prepack",
94
96
  "release": "npm run build && npm run prepack && npm publish --access public"
95
97
  },
96
98
  "types": "dist/index.d.ts"