@firenet-designs/fnd-cli 2.1.0 → 2.3.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
@@ -17,31 +17,33 @@ A new CLI generated with oclif
17
17
  <!-- usage -->
18
18
  ```sh-session
19
19
  $ npm install -g @firenet-designs/fnd-cli
20
- $ fnd-cli COMMAND
20
+ $ fnd COMMAND
21
21
  running command...
22
- $ fnd-cli (--version)
23
- @firenet-designs/fnd-cli/2.1.0 linux-x64 node-v24.12.0
24
- $ fnd-cli --help [COMMAND]
22
+ $ fnd (--version)
23
+ @firenet-designs/fnd-cli/2.3.0 linux-x64 node-v24.18.0
24
+ $ fnd --help [COMMAND]
25
25
  USAGE
26
- $ fnd-cli COMMAND
26
+ $ fnd COMMAND
27
27
  ...
28
28
  ```
29
29
  <!-- usagestop -->
30
30
  # Commands
31
31
  <!-- commands -->
32
- * [`fnd-cli backfill-project [SHOP]`](#fnd-cli-backfill-project-shop)
33
- * [`fnd-cli create-app`](#fnd-cli-create-app)
34
- * [`fnd-cli create-project [SHOP]`](#fnd-cli-create-project-shop)
35
- * [`fnd-cli help [COMMAND]`](#fnd-cli-help-command)
36
- * [`fnd-cli token`](#fnd-cli-token)
32
+ * [`fnd backfill-project [SHOP]`](#fnd-backfill-project-shop)
33
+ * [`fnd create-app`](#fnd-create-app)
34
+ * [`fnd create-project [SHOP]`](#fnd-create-project-shop)
35
+ * [`fnd help [COMMAND]`](#fnd-help-command)
36
+ * [`fnd token`](#fnd-token)
37
+ * [`fnd workspace`](#fnd-workspace)
38
+ * [`fnd workspace cleanup`](#fnd-workspace-cleanup)
37
39
 
38
- ## `fnd-cli backfill-project [SHOP]`
40
+ ## `fnd backfill-project [SHOP]`
39
41
 
40
42
  Backfill missing scaffold files in an EXISTING repo + theme: .gitignore, .shopifyignore, CLAUDE.md. Each is created only if absent — existing files are left untouched. Does NOT git init, pull the theme, commit, or touch GitHub.
41
43
 
42
44
  ```
43
45
  USAGE
44
- $ fnd-cli backfill-project [SHOP...]
46
+ $ fnd backfill-project [SHOP...]
45
47
 
46
48
  ARGUMENTS
47
49
  [SHOP...] Shopify store handle, passed to Claude as a hint
@@ -53,22 +55,22 @@ DESCRIPTION
53
55
  Requires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.
54
56
 
55
57
  EXAMPLES
56
- $ fnd-cli backfill-project
58
+ $ fnd backfill-project
57
59
 
58
- $ fnd-cli backfill-project my-store
60
+ $ fnd backfill-project my-store
59
61
 
60
- $ fnd-cli backfill-project my-store "extra hints"
62
+ $ fnd backfill-project my-store "extra hints"
61
63
  ```
62
64
 
63
- _See code: [src/commands/backfill-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.1.0/src/commands/backfill-project.ts)_
65
+ _See code: [src/commands/backfill-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.0/src/commands/backfill-project.ts)_
64
66
 
65
- ## `fnd-cli create-app`
67
+ ## `fnd create-app`
66
68
 
67
69
  sets up a preconfigued vite app directory on for a shopify theme
68
70
 
69
71
  ```
70
72
  USAGE
71
- $ fnd-cli create-app [-d <value>] [-s]
73
+ $ fnd create-app [-d <value>] [-s]
72
74
 
73
75
  FLAGS
74
76
  -d, --dir=<value> [default: src] directory to create the vite app in
@@ -78,18 +80,18 @@ DESCRIPTION
78
80
  sets up a preconfigued vite app directory on for a shopify theme
79
81
 
80
82
  EXAMPLES
81
- $ fnd-cli create-app
83
+ $ fnd create-app
82
84
  ```
83
85
 
84
- _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.1.0/src/commands/create-app.ts)_
86
+ _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.0/src/commands/create-app.ts)_
85
87
 
86
- ## `fnd-cli create-project [SHOP]`
88
+ ## `fnd create-project [SHOP]`
87
89
 
88
90
  Scaffold a new client project: git on branch production, ignore files, Shopify theme pull, a Claude-generated CLAUDE.md, then a private GitHub repo under the FireNet-Designs org.
89
91
 
90
92
  ```
91
93
  USAGE
92
- $ fnd-cli create-project [SHOP...]
94
+ $ fnd create-project [SHOP...]
93
95
 
94
96
  ARGUMENTS
95
97
  [SHOP...] Shopify store handle — pulls the live theme from <shop>.myshopify.com (omit to skip)
@@ -104,22 +106,22 @@ DESCRIPTION
104
106
  never stores credentials. Override the org with FND_GH_ORG.
105
107
 
106
108
  EXAMPLES
107
- $ fnd-cli create-project
109
+ $ fnd create-project
108
110
 
109
- $ fnd-cli create-project my-store
111
+ $ fnd create-project my-store
110
112
 
111
- $ fnd-cli create-project my-store "Acme storefront, Klaviyo"
113
+ $ fnd create-project my-store "Acme storefront, Klaviyo"
112
114
  ```
113
115
 
114
- _See code: [src/commands/create-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.1.0/src/commands/create-project.ts)_
116
+ _See code: [src/commands/create-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.0/src/commands/create-project.ts)_
115
117
 
116
- ## `fnd-cli help [COMMAND]`
118
+ ## `fnd help [COMMAND]`
117
119
 
118
- Display help for fnd-cli.
120
+ Display help for fnd.
119
121
 
120
122
  ```
121
123
  USAGE
122
- $ fnd-cli help [COMMAND...] [-n]
124
+ $ fnd help [COMMAND...] [-n]
123
125
 
124
126
  ARGUMENTS
125
127
  [COMMAND...] Command to show help for.
@@ -128,18 +130,18 @@ FLAGS
128
130
  -n, --nested-commands Include all nested commands in the output.
129
131
 
130
132
  DESCRIPTION
131
- Display help for fnd-cli.
133
+ Display help for fnd.
132
134
  ```
133
135
 
134
- _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.37/src/commands/help.ts)_
136
+ _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.53/src/commands/help.ts)_
135
137
 
136
- ## `fnd-cli token`
138
+ ## `fnd token`
137
139
 
138
140
  Mint an offline Shopify Admin API access token (shpat_) for a custom app via the OAuth authorization-code grant. Works cross-org, unlike client_credentials.
139
141
 
140
142
  ```
141
143
  USAGE
142
- $ fnd-cli token [-i <value>] [-c <value>] [--no-open] [-p <value>] [--scopes <value>] [-s <value>] [-w
144
+ $ fnd token [-i <value>] [-c <value>] [--no-open] [-p <value>] [--scopes <value>] [-s <value>] [-w
143
145
  <value>]
144
146
 
145
147
  FLAGS
@@ -156,12 +158,83 @@ DESCRIPTION
156
158
  cross-org, unlike client_credentials.
157
159
 
158
160
  EXAMPLES
159
- $ fnd-cli token --store trafalgar-store --client-id 0b42... --client-secret shpss_...
161
+ $ fnd token --store trafalgar-store --client-id 0b42... --client-secret shpss_...
160
162
 
161
- $ fnd-cli token -s mystore -i <id> -c <secret> --scopes read_products,read_orders
163
+ $ fnd token -s mystore -i <id> -c <secret> --scopes read_products,read_orders
162
164
 
163
- $ fnd-cli token -s mystore -i <id> -c <secret> --write ./.mcp.json
165
+ $ fnd token -s mystore -i <id> -c <secret> --write ./.mcp.json
164
166
  ```
165
167
 
166
- _See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.1.0/src/commands/token.ts)_
168
+ _See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.0/src/commands/token.ts)_
169
+
170
+ ## `fnd workspace`
171
+
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
+
174
+ ```
175
+ USAGE
176
+ $ fnd workspace --ssh <value> [--devtools <value>] [--remote-base <value>] [--source remote|local]
177
+
178
+ FLAGS
179
+ --devtools=<value> expose your LOCAL browser to Claude on the remote via the chrome-devtools MCP. Value is "port"
180
+ (same port both ends) or "remote:local" (local = this machine, where the browser runs). Your
181
+ browser must already be listening with --remote-debugging-port=<local>.
182
+ --remote-base=<value> [default: /home/fnd] base dir on the remote; the workspace lands at
183
+ <base>/<local-user>/<dir-name>
184
+ --source=<option> which side wins on conflict: "remote" = this server (where the workspace shell runs), "local" =
185
+ the machine you ran fnd workspace from. Omit to flag conflicts instead of auto-resolving them.
186
+ <options: remote|local>
187
+ --ssh=<value> (required) remote to connect to, as user@host
188
+
189
+ DESCRIPTION
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.
192
+
193
+ Both sides keep a real copy on local disk and only deltas cross the network, so the remote reads files at native speed
194
+ (unlike reverse SSHFS). Conflicting edits on both ends are flagged rather than silently overwritten; pass --source to
195
+ auto-resolve them in favour of one side. Mutagen connects the normal direction (this machine → remote over SSH) and
196
+ auto-deploys its agent to the remote, so no local SSH server, reverse tunnel, or authorized_keys trust is required.
197
+ You need the Mutagen CLI installed on THIS machine.
198
+
199
+ EXAMPLES
200
+ $ fnd workspace --ssh user@203.0.113.4
201
+
202
+ $ fnd workspace --ssh user@host --source local
203
+
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
209
+ ```
210
+
211
+ _See code: [src/commands/workspace/index.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.0/src/commands/workspace/index.ts)_
212
+
213
+ ## `fnd workspace cleanup`
214
+
215
+ Tear down a leftover workspace — use this if a `workspace` session dropped before it could clean up after itself.
216
+
217
+ ```
218
+ USAGE
219
+ $ fnd workspace cleanup --ssh <value> [--remote-base <value>] [--remote-dir <value>]
220
+
221
+ FLAGS
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
225
+
226
+ DESCRIPTION
227
+ Tear down a leftover workspace — use this if a `workspace` session dropped before it could clean up after itself.
228
+
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.
232
+
233
+ EXAMPLES
234
+ $ fnd workspace cleanup --ssh user@host
235
+
236
+ $ fnd workspace cleanup --ssh user@host --remote-dir /home/fnd/cole/fnd-cli
237
+ ```
238
+
239
+ _See code: [src/commands/workspace/cleanup.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.3.0/src/commands/workspace/cleanup.ts)_
167
240
  <!-- commandsstop -->
@@ -0,0 +1,11 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class WorkspaceCleanup extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
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
+ ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ };
10
+ run(): Promise<void>;
11
+ }
@@ -0,0 +1,56 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import { basename } from 'node:path';
4
+ import { buildContext, buildMutagenTerminateSelectorArgs, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, parseSshTarget, runMutagen, runRemoteCleanup, slugify, } from '../../lib/workspace.js';
5
+ export default class WorkspaceCleanup extends Command {
6
+ static description = 'Tear down a leftover workspace — use this if a `workspace` session dropped before it could clean up after itself.\n\nTerminates any Mutagen sync sessions this machine started for the directory and strips the matching chrome-devtools MCP entry from the remote. With no --remote-dir, it targets the same path `workspace` would use for the current directory. The synced files themselves are left in place.';
7
+ static examples = [
8
+ '<%= config.bin %> <%= command.id %> --ssh user@host',
9
+ '<%= config.bin %> <%= command.id %> --ssh user@host --remote-dir /home/fnd/cole/fnd-cli',
10
+ ];
11
+ static flags = {
12
+ 'remote-base': Flags.string({
13
+ default: DEFAULT_MOUNT_BASE,
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)',
18
+ }),
19
+ ssh: Flags.string({
20
+ description: 'remote to connect to, as user@host',
21
+ required: true,
22
+ }),
23
+ };
24
+ async run() {
25
+ const { flags } = await this.parse(WorkspaceCleanup);
26
+ if (!hasSshClient()) {
27
+ this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
28
+ }
29
+ const target = parseSshTarget(flags.ssh);
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}`);
37
+ this.log('');
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);
49
+ if (code === 0) {
50
+ this.log(chalk.green('✓ Done.'));
51
+ }
52
+ else {
53
+ this.error(`Remote cleanup ssh session exited with code ${code}.`, { code: '1' });
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,17 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Workspace extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
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
+ ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ };
11
+ run(): Promise<void>;
12
+ /** Verify this machine can drive the sync before we connect. */
13
+ private preflight;
14
+ private printPlan;
15
+ /** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
16
+ private runSsh;
17
+ }
@@ -0,0 +1,140 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
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';
5
+ export default class Workspace extends Command {
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 (unlike reverse SSHFS). 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
+ static examples = [
8
+ '<%= config.bin %> <%= command.id %> --ssh user@203.0.113.4',
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',
13
+ ];
14
+ static flags = {
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>.',
17
+ }),
18
+ 'remote-base': Flags.string({
19
+ default: DEFAULT_MOUNT_BASE,
20
+ description: 'base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>',
21
+ }),
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'],
25
+ }),
26
+ ssh: Flags.string({
27
+ description: 'remote to connect to, as user@host',
28
+ required: true,
29
+ }),
30
+ };
31
+ async run() {
32
+ const { flags } = await this.parse(Workspace);
33
+ const target = parseSshTarget(flags.ssh);
34
+ const target2 = `${target.user}@${target.host}`;
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
+ }
50
+ let code;
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
+ }
58
+ const script = buildRemoteScript(ctx);
59
+ code = await this.runSsh(target2, script, ctx.devtools);
60
+ }
61
+ finally {
62
+ // Best-effort: flush the last edits back, then tear the session down.
63
+ this.log('');
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
+ }
76
+ }
77
+ }
78
+ this.log('');
79
+ this.log(code === 0
80
+ ? chalk.green('✓ Workspace closed. Sync stopped; the remote copy is left in place.')
81
+ : chalk.yellow(`Session ended with exit code ${code}. Cleanup attempted above.`));
82
+ }
83
+ /** Verify this machine can drive the sync before we connect. */
84
+ async preflight(devtools) {
85
+ if (!hasSshClient()) {
86
+ this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
87
+ }
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.'));
91
+ this.log('');
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
+ }
106
+ }
107
+ }
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)'}`;
112
+ this.log(chalk.bold('Opening remote workspace'));
113
+ this.log(` ${chalk.dim('remote:')} ${target}`);
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
+ }
120
+ this.log('');
121
+ }
122
+ /** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
123
+ runSsh(target, script, devtools) {
124
+ const args = [
125
+ '-t', // allocate a remote PTY for the interactive shell session
126
+ // Reverse tunnel for --devtools only: remote 127.0.0.1:<remote> → this
127
+ // machine's browser at 127.0.0.1:<local>, so the remote's MCP can reach it.
128
+ ...(devtools
129
+ ? ['-o', 'ExitOnForwardFailure=yes', '-R', `${devtools.remote}:localhost:${devtools.local}`]
130
+ : []),
131
+ target,
132
+ script,
133
+ ];
134
+ return new Promise((resolve, reject) => {
135
+ const child = spawn('ssh', args, { stdio: 'inherit' });
136
+ child.once('error', reject);
137
+ child.once('close', (code) => resolve(code ?? 0));
138
+ });
139
+ }
140
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Mutagen-backed workspace helpers.
3
+ *
4
+ * The topology this supports:
5
+ *
6
+ * local machine (any OS) Internet remote machine (always Linux)
7
+ * ┌─────────────────────┐ ┌──────────────────────────────┐
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 │ │
13
+ * └─────────────────────┘ └──────────────────────────────┘
14
+ *
15
+ * Unlike the old reverse-SSHFS approach, BOTH sides hold a real copy on local
16
+ * disk and Mutagen propagates only the deltas. The remote therefore reads files
17
+ * at native disk speed (no per-file network round-trips), which is the whole
18
+ * point — lint/build/test on the remote stop being IO-bound on the tunnel.
19
+ *
20
+ * Mutagen connects the normal direction (local → remote over SSH), so — unlike
21
+ * SSHFS — we need NO local SSH server, NO reverse tunnel, and NO authorized_keys
22
+ * trust dance. Mutagen auto-deploys its agent to the remote over that same SSH
23
+ * connection; nothing to install on the remote by hand.
24
+ *
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.
28
+ */
29
+ export interface SshTarget {
30
+ host: string;
31
+ user: string;
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';
47
+ export interface WorkspaceContext {
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). */
51
+ localCwd: string;
52
+ /** Basename of the local cwd — the leaf of the remote directory path. */
53
+ localDirName: string;
54
+ /** Username on the LOCAL machine — used to namespace the remote directory path. */
55
+ localUser: string;
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;
62
+ }
63
+ export declare const DEFAULT_MOUNT_BASE = "/home/fnd";
64
+ /** Parse a `user@host` string, throwing a friendly error otherwise. */
65
+ export declare const parseSshTarget: (raw: string) => SshTarget;
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;
80
+ /** Build the immutable facts for a workspace session from the local environment + flags. */
81
+ export declare const buildContext: (opts: {
82
+ cwd: string;
83
+ devtools?: DevtoolsPorts;
84
+ remoteBase: string;
85
+ source?: SyncSource;
86
+ }) => WorkspaceContext;
87
+ /** POSIX single-quote a string so it can be embedded safely in the remote shell script. */
88
+ export declare const shQuote: (value: string) => string;
89
+ /**
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.
93
+ */
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>;
97
+ /**
98
+ * The remote-side teardown script: strip this project's chrome-devtools MCP from
99
+ * the remote's ~/.claude.json. A no-op if there is nothing to undo. The synced
100
+ * files themselves are left in place — they are a real copy, not a mount.
101
+ */
102
+ export declare const buildCleanupScript: (remoteDir: string) => string;
103
+ /** True if an `ssh` client is on PATH (works on Windows, macOS, Linux). */
104
+ export declare const hasSshClient: () => boolean;
105
+ /** True if the Mutagen CLI is on PATH and runnable. */
106
+ export declare const hasMutagen: () => boolean;
107
+ /**
108
+ * Arguments for `mutagen sync create`. With no `source`, the session runs in
109
+ * two-way-safe mode, which halts and flags conflicts rather than picking a
110
+ * winner. When a `source` is given, it becomes the Mutagen alpha endpoint and
111
+ * the mode switches to two-way-resolved (alpha always wins conflicts), so
112
+ * `--source remote` puts the server first and `--source local` puts this
113
+ * machine first. Labels let `workspace cleanup` find and terminate orphans.
114
+ */
115
+ export declare const buildMutagenCreateArgs: (ctx: WorkspaceContext, target: string) => string[];
116
+ /** Arguments for `mutagen sync flush <name>` — block until one full sync completes. */
117
+ export declare const buildMutagenFlushArgs: (name: string) => string[];
118
+ /** Arguments for `mutagen sync terminate <name>` — stop this exact session. */
119
+ export declare const buildMutagenTerminateArgs: (name: string) => string[];
120
+ /** Arguments for terminating any orphaned workspace sessions for a given directory slug. */
121
+ export declare const buildMutagenTerminateSelectorArgs: (dirSlug: string) => string[];
122
+ /** Run a Mutagen CLI command, inheriting stdio so its progress reaches the user. */
123
+ export declare const runMutagen: (args: string[]) => Promise<number>;
124
+ /**
125
+ * Resolve true if a Chrome/Chromium remote-debugging endpoint answers on
126
+ * 127.0.0.1:<port> — GET /json/version returns HTTP 200. This is the caller's
127
+ * LOCAL browser, checked before we open the workspace.
128
+ */
129
+ export declare const isLocalDebugPortLive: (port: number, timeoutMs?: number) => Promise<boolean>;
130
+ /** Hint for launching a browser with remote debugging enabled on the given port. */
131
+ export declare const browserDebugInstructions: (port: number) => string;
132
+ /** Platform-specific instructions for installing the Mutagen CLI on the machine running `fnd workspace`. */
133
+ export declare const mutagenInstallInstructions: (platform?: NodeJS.Platform) => string;
@@ -0,0 +1,258 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { randomInt } from 'node:crypto';
3
+ import { userInfo } from 'node:os';
4
+ import { basename } from 'node:path';
5
+ export const DEFAULT_MOUNT_BASE = '/home/fnd';
6
+ /** Parse a `user@host` string, throwing a friendly error otherwise. */
7
+ export const parseSshTarget = (raw) => {
8
+ const match = raw.trim().match(/^([^@\s]+)@([^@\s]+)$/);
9
+ if (!match)
10
+ throw new Error(`--ssh must be in the form user@host (got "${raw}")`);
11
+ return { host: match[2], user: match[1] };
12
+ };
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
+ };
48
+ /** Build the immutable facts for a workspace session from the local environment + flags. */
49
+ export const buildContext = (opts) => {
50
+ const localUser = userInfo().username;
51
+ const localCwd = opts.cwd;
52
+ const localDirName = basename(localCwd);
53
+ const base = opts.remoteBase.replace(/\/+$/, '');
54
+ const remoteDir = `${base}/${localUser}/${localDirName}`;
55
+ return {
56
+ devtools: opts.devtools,
57
+ localCwd,
58
+ localDirName,
59
+ localUser,
60
+ remoteDir,
61
+ source: opts.source,
62
+ syncName: buildSyncName(localDirName),
63
+ };
64
+ };
65
+ /** POSIX single-quote a string so it can be embedded safely in the remote shell script. */
66
+ export const shQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
67
+ /**
68
+ * Node program (run on the REMOTE) that adds the chrome-devtools MCP to the
69
+ * remote's ~/.claude.json under this project (the remote dir). Project-scoped,
70
+ * never the top-level mcpServers, so it is not globally exposed. Reads the
71
+ * remote dir and remote tunnel port from the environment.
72
+ */
73
+ const claudeDevtoolsAddProgram = () => [
74
+ "const fs = require('node:fs');",
75
+ "const os = require('node:os');",
76
+ "const path = require('node:path');",
77
+ "const file = path.join(os.homedir(), '.claude.json');",
78
+ 'let data = {};',
79
+ "try { data = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { data = {}; }",
80
+ 'const mount = process.env.FND_MOUNT;',
81
+ 'const port = process.env.FND_DEVTOOLS_PORT;',
82
+ 'if (!data.projects) data.projects = {};',
83
+ 'if (!data.projects[mount]) data.projects[mount] = {};',
84
+ 'const project = data.projects[mount];',
85
+ 'if (!project.mcpServers) project.mcpServers = {};',
86
+ "project.mcpServers['chrome-devtools'] = { command: 'npx', args: ['-y', 'chrome-devtools-mcp@latest', '--browserUrl', 'http://127.0.0.1:' + port] };",
87
+ String.raw `fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');`,
88
+ ];
89
+ /**
90
+ * Node program (run on the REMOTE) that removes the chrome-devtools MCP this
91
+ * workspace added for its project from ~/.claude.json, dropping an emptied
92
+ * mcpServers map. No-op if the file or entry is absent.
93
+ */
94
+ const claudeDevtoolsRemoveProgram = () => [
95
+ "const fs = require('node:fs');",
96
+ "const os = require('node:os');",
97
+ "const path = require('node:path');",
98
+ "const file = path.join(os.homedir(), '.claude.json');",
99
+ 'let data;',
100
+ "try { data = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { process.exit(0); }",
101
+ 'const mount = process.env.FND_MOUNT;',
102
+ 'const project = data.projects && data.projects[mount];',
103
+ "if (project && project.mcpServers && project.mcpServers['chrome-devtools']) {",
104
+ " delete project.mcpServers['chrome-devtools'];",
105
+ ' if (Object.keys(project.mcpServers).length === 0) delete project.mcpServers;',
106
+ String.raw ` fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');`,
107
+ " console.log('Removed chrome-devtools MCP config for ' + mount + '.');",
108
+ '}',
109
+ ];
110
+ /**
111
+ * Wrap a node program in a bash snippet that runs it on the remote with FND_MOUNT
112
+ * (and optionally FND_DEVTOOLS_PORT) in the environment, skipping gracefully if
113
+ * node is missing.
114
+ */
115
+ const runNodeOnRemote = (program, env, okMessage) => [
116
+ 'if command -v node >/dev/null 2>&1; then',
117
+ ` ${env} node <<'FND_NODE_EOF'`,
118
+ ...program,
119
+ 'FND_NODE_EOF',
120
+ ...(okMessage ? [` echo ${shQuote(okMessage)}`] : []),
121
+ 'else',
122
+ ' echo "WARNING: node not found on the remote; skipped chrome-devtools MCP config." >&2',
123
+ 'fi',
124
+ ];
125
+ /**
126
+ * The bash script the remote runs for the interactive session. Mutagen already
127
+ * keeps the directory in sync, so this just makes sure the directory exists,
128
+ * optionally wires up the chrome-devtools MCP, and drops into a login shell.
129
+ */
130
+ export const buildRemoteScript = (ctx) => {
131
+ const dir = shQuote(ctx.remoteDir);
132
+ const devtoolsSetup = ctx.devtools
133
+ ? runNodeOnRemote(claudeDevtoolsAddProgram(), `FND_MOUNT=${dir} FND_DEVTOOLS_PORT=${ctx.devtools.remote}`, `Configured chrome-devtools MCP for this workspace (browser via 127.0.0.1:${ctx.devtools.remote}).`)
134
+ : [];
135
+ return [
136
+ 'set -u',
137
+ `DIR=${dir}`,
138
+ // Mutagen creates the sync root, but ensure it exists so `cd` never races it.
139
+ 'mkdir -p "$DIR" || { echo "ERROR: could not create $DIR" >&2; exit 1; }',
140
+ ...devtoolsSetup,
141
+ 'cd "$DIR" || { echo "ERROR: could not enter $DIR" >&2; exit 1; }',
142
+ 'echo "Workspace ready at $DIR — files sync in the background (exit to stop syncing)."',
143
+ // eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
144
+ '"${SHELL:-bash}" -l',
145
+ ].join('\n');
146
+ };
147
+ /** Run the remote-side teardown (strip this project's chrome-devtools MCP) over a fresh ssh connection. */
148
+ export const runRemoteCleanup = (target, remoteDir) => new Promise((resolve, reject) => {
149
+ const child = spawn('ssh', [target, buildCleanupScript(remoteDir)], { stdio: 'inherit' });
150
+ child.once('error', reject);
151
+ child.once('close', (code) => resolve(code ?? 0));
152
+ });
153
+ /**
154
+ * The remote-side teardown script: strip this project's chrome-devtools MCP from
155
+ * the remote's ~/.claude.json. A no-op if there is nothing to undo. The synced
156
+ * files themselves are left in place — they are a real copy, not a mount.
157
+ */
158
+ export const buildCleanupScript = (remoteDir) => runNodeOnRemote(claudeDevtoolsRemoveProgram(), `FND_MOUNT=${shQuote(remoteDir)}`).join('\n');
159
+ /** True if an `ssh` client is on PATH (works on Windows, macOS, Linux). */
160
+ export const hasSshClient = () => {
161
+ const result = spawnSync('ssh', ['-V'], { stdio: 'ignore' });
162
+ return !result.error;
163
+ };
164
+ /** True if the Mutagen CLI is on PATH and runnable. */
165
+ export const hasMutagen = () => {
166
+ const result = spawnSync('mutagen', ['version'], { stdio: 'ignore' });
167
+ return !result.error && result.status === 0;
168
+ };
169
+ /**
170
+ * Arguments for `mutagen sync create`. With no `source`, the session runs in
171
+ * two-way-safe mode, which halts and flags conflicts rather than picking a
172
+ * winner. When a `source` is given, it becomes the Mutagen alpha endpoint and
173
+ * the mode switches to two-way-resolved (alpha always wins conflicts), so
174
+ * `--source remote` puts the server first and `--source local` puts this
175
+ * machine first. Labels let `workspace cleanup` find and terminate orphans.
176
+ */
177
+ export const buildMutagenCreateArgs = (ctx, target) => {
178
+ const local = ctx.localCwd;
179
+ const remote = `${target}:${ctx.remoteDir}`;
180
+ // Alpha only matters as the conflict winner in resolved mode; in safe mode the
181
+ // order is cosmetic, so keep local-first for a stable, readable command.
182
+ const [alpha, beta] = ctx.source === 'remote' ? [remote, local] : [local, remote];
183
+ const syncMode = ctx.source === undefined ? 'two-way-safe' : 'two-way-resolved';
184
+ return [
185
+ 'sync',
186
+ 'create',
187
+ `--name=${ctx.syncName}`,
188
+ '--label=managed-by=fnd-workspace',
189
+ `--label=dir=${slugify(ctx.localDirName)}`,
190
+ `--sync-mode=${syncMode}`,
191
+ alpha,
192
+ beta,
193
+ ];
194
+ };
195
+ /** Arguments for `mutagen sync flush <name>` — block until one full sync completes. */
196
+ export const buildMutagenFlushArgs = (name) => ['sync', 'flush', name];
197
+ /** Arguments for `mutagen sync terminate <name>` — stop this exact session. */
198
+ export const buildMutagenTerminateArgs = (name) => ['sync', 'terminate', name];
199
+ /** Arguments for terminating any orphaned workspace sessions for a given directory slug. */
200
+ export const buildMutagenTerminateSelectorArgs = (dirSlug) => [
201
+ 'sync',
202
+ 'terminate',
203
+ `--label-selector=managed-by=fnd-workspace,dir=${dirSlug}`,
204
+ ];
205
+ /** Run a Mutagen CLI command, inheriting stdio so its progress reaches the user. */
206
+ export const runMutagen = (args) => new Promise((resolve, reject) => {
207
+ const child = spawn('mutagen', args, { stdio: 'inherit' });
208
+ child.once('error', reject);
209
+ child.once('close', (code) => resolve(code ?? 0));
210
+ });
211
+ /**
212
+ * Resolve true if a Chrome/Chromium remote-debugging endpoint answers on
213
+ * 127.0.0.1:<port> — GET /json/version returns HTTP 200. This is the caller's
214
+ * LOCAL browser, checked before we open the workspace.
215
+ */
216
+ export const isLocalDebugPortLive = async (port, timeoutMs = 2000) => {
217
+ const controller = new AbortController();
218
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
219
+ try {
220
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: controller.signal });
221
+ return res.status === 200;
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ finally {
227
+ clearTimeout(timer);
228
+ }
229
+ };
230
+ /** Hint for launching a browser with remote debugging enabled on the given port. */
231
+ export const browserDebugInstructions = (port) => [
232
+ `Start your browser with remote debugging on port ${port}, e.g.:`,
233
+ ` Chrome/Chromium: google-chrome --remote-debugging-port=${port}`,
234
+ ` macOS: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=${port}`,
235
+ `Then confirm it answers: curl http://127.0.0.1:${port}/json/version`,
236
+ ].join('\n');
237
+ /** Platform-specific instructions for installing the Mutagen CLI on the machine running `fnd workspace`. */
238
+ export const mutagenInstallInstructions = (platform = process.platform) => {
239
+ if (platform === 'win32') {
240
+ return [
241
+ 'Windows — install the Mutagen CLI:',
242
+ ' scoop install mutagen',
243
+ ' # or: choco install mutagen',
244
+ ' # or download from https://github.com/mutagen-io/mutagen/releases and put mutagen.exe on PATH',
245
+ ].join('\n');
246
+ }
247
+ if (platform === 'darwin') {
248
+ return [
249
+ 'macOS — install the Mutagen CLI:',
250
+ ' brew install mutagen-io/mutagen/mutagen',
251
+ ].join('\n');
252
+ }
253
+ return [
254
+ 'Linux — install the Mutagen CLI:',
255
+ ' brew install mutagen-io/mutagen/mutagen',
256
+ ' # or download from https://github.com/mutagen-io/mutagen/releases and put mutagen on PATH',
257
+ ].join('\n');
258
+ };
@@ -182,7 +182,119 @@
182
182
  "commands",
183
183
  "token.js"
184
184
  ]
185
+ },
186
+ "workspace:cleanup": {
187
+ "aliases": [],
188
+ "args": {},
189
+ "description": "Tear down a leftover workspace — use this if a `workspace` session dropped before it could clean up after itself.\n\nTerminates any Mutagen sync sessions this machine started for the directory 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
+ "examples": [
191
+ "<%= config.bin %> <%= command.id %> --ssh user@host",
192
+ "<%= config.bin %> <%= command.id %> --ssh user@host --remote-dir /home/fnd/cole/fnd-cli"
193
+ ],
194
+ "flags": {
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",
199
+ "hasDynamicHelp": false,
200
+ "multiple": false,
201
+ "type": "option"
202
+ },
203
+ "remote-dir": {
204
+ "description": "exact remote directory to target (defaults to the current dir mapping)",
205
+ "name": "remote-dir",
206
+ "hasDynamicHelp": false,
207
+ "multiple": false,
208
+ "type": "option"
209
+ },
210
+ "ssh": {
211
+ "description": "remote to connect to, as user@host",
212
+ "name": "ssh",
213
+ "required": true,
214
+ "hasDynamicHelp": false,
215
+ "multiple": false,
216
+ "type": "option"
217
+ }
218
+ },
219
+ "hasDynamicHelp": false,
220
+ "hiddenAliases": [],
221
+ "id": "workspace:cleanup",
222
+ "pluginAlias": "@firenet-designs/fnd-cli",
223
+ "pluginName": "@firenet-designs/fnd-cli",
224
+ "pluginType": "core",
225
+ "strict": true,
226
+ "enableJsonFlag": false,
227
+ "isESM": true,
228
+ "relativePath": [
229
+ "dist",
230
+ "commands",
231
+ "workspace",
232
+ "cleanup.js"
233
+ ]
234
+ },
235
+ "workspace": {
236
+ "aliases": [],
237
+ "args": {},
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 (unlike reverse SSHFS). 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
+ "examples": [
240
+ "<%= config.bin %> <%= command.id %> --ssh user@203.0.113.4",
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"
245
+ ],
246
+ "flags": {
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",
250
+ "hasDynamicHelp": false,
251
+ "multiple": false,
252
+ "type": "option"
253
+ },
254
+ "remote-base": {
255
+ "description": "base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>",
256
+ "name": "remote-base",
257
+ "default": "/home/fnd",
258
+ "hasDynamicHelp": false,
259
+ "multiple": false,
260
+ "type": "option"
261
+ },
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
+ "hasDynamicHelp": false,
266
+ "multiple": false,
267
+ "options": [
268
+ "remote",
269
+ "local"
270
+ ],
271
+ "type": "option"
272
+ },
273
+ "ssh": {
274
+ "description": "remote to connect to, as user@host",
275
+ "name": "ssh",
276
+ "required": true,
277
+ "hasDynamicHelp": false,
278
+ "multiple": false,
279
+ "type": "option"
280
+ }
281
+ },
282
+ "hasDynamicHelp": false,
283
+ "hiddenAliases": [],
284
+ "id": "workspace",
285
+ "pluginAlias": "@firenet-designs/fnd-cli",
286
+ "pluginName": "@firenet-designs/fnd-cli",
287
+ "pluginType": "core",
288
+ "strict": true,
289
+ "enableJsonFlag": false,
290
+ "isESM": true,
291
+ "relativePath": [
292
+ "dist",
293
+ "commands",
294
+ "workspace",
295
+ "index.js"
296
+ ]
185
297
  }
186
298
  },
187
- "version": "2.1.0"
299
+ "version": "2.3.0"
188
300
  }
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@firenet-designs/fnd-cli",
3
3
  "description": "A new CLI generated with oclif",
4
- "version": "2.1.0",
4
+ "version": "2.3.0",
5
5
  "author": "Cole Denslow",
6
+ "contributors": [
7
+ "Justin Schellenberg"
8
+ ],
6
9
  "bin": {
7
10
  "fnd": "bin/run.js"
8
11
  },
@@ -26,15 +29,18 @@
26
29
  "eslint": "^9",
27
30
  "eslint-config-oclif": "^6",
28
31
  "eslint-config-prettier": "^10",
29
- "mocha": "^10",
32
+ "mocha": "^11",
30
33
  "oclif": "^4",
31
- "shx": "^0.3.3",
34
+ "shx": "^0.4.0",
32
35
  "ts-node": "^10",
33
36
  "typescript": "^5"
34
37
  },
35
38
  "engines": {
36
39
  "node": ">=24.0.0"
37
40
  },
41
+ "overrides": {
42
+ "glob": "^13"
43
+ },
38
44
  "files": [
39
45
  "./bin",
40
46
  "./dist",
@@ -53,7 +59,7 @@
53
59
  },
54
60
  "type": "module",
55
61
  "oclif": {
56
- "bin": "fnd-cli",
62
+ "bin": "fnd",
57
63
  "dirname": "fnd-cli",
58
64
  "commands": "./dist/commands",
59
65
  "hooks": {
@@ -68,6 +74,9 @@
68
74
  "topics": {
69
75
  "hello": {
70
76
  "description": "Say hello to the world and others"
77
+ },
78
+ "workspace": {
79
+ "description": "Open a remote reverse-SSHFS workspace and run Claude on it"
71
80
  }
72
81
  }
73
82
  },
@@ -19,7 +19,7 @@ Look at the current directory to infer the fields. Useful signals:
19
19
  - `@shopify/hydrogen` in deps, or `app/routes/` + `remix`/`vite` → **Shopify Hydrogen**.
20
20
  - `next.config.*` or `next` in deps → **Next.js**.
21
21
  - An Express/Fastify/Hono server entry with no frontend → **Node API**.
22
- - Anything else → **Custom** (write 46 conventions tailored to the actual stack).
22
+ - Anything else → **Custom** (write 4-6 conventions tailored to the actual stack).
23
23
  - `tsconfig.json`, lockfile type (`package-lock.json`/`pnpm-lock.yaml`/`yarn.lock`/`bun.lockb`), `.nvmrc`/`engines` → runtime + package manager.
24
24
  - `.env.example` / `.env.sample` → external services (Klaviyo, Stripe, Shopify Admin API, a DB URL, etc.).
25
25
  - Top two levels of the directory tree → Project Structure block.
@@ -70,7 +70,7 @@ These apply to every file touched in this project.
70
70
 
71
71
  ## Conventions
72
72
 
73
- [Insert the matching preset block below for the detected type. For Custom, write 46 stack-specific rules, one per line, imperative voice.]
73
+ [Insert the matching preset block below for the detected type. For Custom, write 4-6 stack-specific rules, one per line, imperative voice.]
74
74
 
75
75
  ---
76
76