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