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