@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 +110 -37
- package/dist/commands/workspace/cleanup.d.ts +11 -0
- package/dist/commands/workspace/cleanup.js +56 -0
- package/dist/commands/workspace/index.d.ts +17 -0
- package/dist/commands/workspace/index.js +140 -0
- package/dist/lib/workspace.d.ts +133 -0
- package/dist/lib/workspace.js +258 -0
- package/oclif.manifest.json +113 -1
- package/package.json +13 -4
- package/prompts/init-project-auto.md +2 -2
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
|
|
20
|
+
$ fnd COMMAND
|
|
21
21
|
running command...
|
|
22
|
-
$ fnd
|
|
23
|
-
@firenet-designs/fnd-cli/2.
|
|
24
|
-
$ fnd
|
|
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
|
|
26
|
+
$ fnd COMMAND
|
|
27
27
|
...
|
|
28
28
|
```
|
|
29
29
|
<!-- usagestop -->
|
|
30
30
|
# Commands
|
|
31
31
|
<!-- commands -->
|
|
32
|
-
* [`fnd
|
|
33
|
-
* [`fnd
|
|
34
|
-
* [`fnd
|
|
35
|
-
* [`fnd
|
|
36
|
-
* [`fnd
|
|
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
|
|
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
|
|
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
|
|
58
|
+
$ fnd backfill-project
|
|
57
59
|
|
|
58
|
-
$ fnd
|
|
60
|
+
$ fnd backfill-project my-store
|
|
59
61
|
|
|
60
|
-
$ fnd
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
109
|
+
$ fnd create-project
|
|
108
110
|
|
|
109
|
-
$ fnd
|
|
111
|
+
$ fnd create-project my-store
|
|
110
112
|
|
|
111
|
-
$ fnd
|
|
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.
|
|
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
|
|
118
|
+
## `fnd help [COMMAND]`
|
|
117
119
|
|
|
118
|
-
Display help for fnd
|
|
120
|
+
Display help for fnd.
|
|
119
121
|
|
|
120
122
|
```
|
|
121
123
|
USAGE
|
|
122
|
-
$ fnd
|
|
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
|
|
133
|
+
Display help for fnd.
|
|
132
134
|
```
|
|
133
135
|
|
|
134
|
-
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/
|
|
136
|
+
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.53/src/commands/help.ts)_
|
|
135
137
|
|
|
136
|
-
## `fnd
|
|
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
|
|
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
|
|
161
|
+
$ fnd token --store trafalgar-store --client-id 0b42... --client-secret shpss_...
|
|
160
162
|
|
|
161
|
-
$ fnd
|
|
163
|
+
$ fnd token -s mystore -i <id> -c <secret> --scopes read_products,read_orders
|
|
162
164
|
|
|
163
|
-
$ fnd
|
|
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.
|
|
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
|
+
};
|
package/oclif.manifest.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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": "^
|
|
32
|
+
"mocha": "^11",
|
|
30
33
|
"oclif": "^4",
|
|
31
|
-
"shx": "^0.
|
|
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
|
|
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 4
|
|
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 4
|
|
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
|
|