@firenet-designs/fnd-cli 2.1.0 → 2.2.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 +101 -37
- package/dist/commands/workspace/cleanup.d.ts +11 -0
- package/dist/commands/workspace/cleanup.js +46 -0
- package/dist/commands/workspace/index.d.ts +17 -0
- package/dist/commands/workspace/index.js +107 -0
- package/dist/lib/workspace.d.ts +89 -0
- package/dist/lib/workspace.js +214 -0
- package/oclif.manifest.json +109 -1
- package/package.json +13 -4
- package/prompts/init-project-auto.md +2 -2
package/README.md
CHANGED
|
@@ -17,31 +17,33 @@ A new CLI generated with oclif
|
|
|
17
17
|
<!-- usage -->
|
|
18
18
|
```sh-session
|
|
19
19
|
$ npm install -g @firenet-designs/fnd-cli
|
|
20
|
-
$ fnd
|
|
20
|
+
$ fnd COMMAND
|
|
21
21
|
running command...
|
|
22
|
-
$ fnd
|
|
23
|
-
@firenet-designs/fnd-cli/2.
|
|
24
|
-
$ fnd
|
|
22
|
+
$ fnd (--version)
|
|
23
|
+
@firenet-designs/fnd-cli/2.2.0 linux-x64 node-v24.18.0
|
|
24
|
+
$ fnd --help [COMMAND]
|
|
25
25
|
USAGE
|
|
26
|
-
$ fnd
|
|
26
|
+
$ fnd COMMAND
|
|
27
27
|
...
|
|
28
28
|
```
|
|
29
29
|
<!-- usagestop -->
|
|
30
30
|
# Commands
|
|
31
31
|
<!-- commands -->
|
|
32
|
-
* [`fnd
|
|
33
|
-
* [`fnd
|
|
34
|
-
* [`fnd
|
|
35
|
-
* [`fnd
|
|
36
|
-
* [`fnd
|
|
32
|
+
* [`fnd backfill-project [SHOP]`](#fnd-backfill-project-shop)
|
|
33
|
+
* [`fnd create-app`](#fnd-create-app)
|
|
34
|
+
* [`fnd create-project [SHOP]`](#fnd-create-project-shop)
|
|
35
|
+
* [`fnd help [COMMAND]`](#fnd-help-command)
|
|
36
|
+
* [`fnd token`](#fnd-token)
|
|
37
|
+
* [`fnd workspace`](#fnd-workspace)
|
|
38
|
+
* [`fnd workspace cleanup`](#fnd-workspace-cleanup)
|
|
37
39
|
|
|
38
|
-
## `fnd
|
|
40
|
+
## `fnd backfill-project [SHOP]`
|
|
39
41
|
|
|
40
42
|
Backfill missing scaffold files in an EXISTING repo + theme: .gitignore, .shopifyignore, CLAUDE.md. Each is created only if absent — existing files are left untouched. Does NOT git init, pull the theme, commit, or touch GitHub.
|
|
41
43
|
|
|
42
44
|
```
|
|
43
45
|
USAGE
|
|
44
|
-
$ fnd
|
|
46
|
+
$ fnd backfill-project [SHOP...]
|
|
45
47
|
|
|
46
48
|
ARGUMENTS
|
|
47
49
|
[SHOP...] Shopify store handle, passed to Claude as a hint
|
|
@@ -53,22 +55,22 @@ DESCRIPTION
|
|
|
53
55
|
Requires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.
|
|
54
56
|
|
|
55
57
|
EXAMPLES
|
|
56
|
-
$ fnd
|
|
58
|
+
$ fnd backfill-project
|
|
57
59
|
|
|
58
|
-
$ fnd
|
|
60
|
+
$ fnd backfill-project my-store
|
|
59
61
|
|
|
60
|
-
$ fnd
|
|
62
|
+
$ fnd backfill-project my-store "extra hints"
|
|
61
63
|
```
|
|
62
64
|
|
|
63
|
-
_See code: [src/commands/backfill-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.
|
|
65
|
+
_See code: [src/commands/backfill-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/backfill-project.ts)_
|
|
64
66
|
|
|
65
|
-
## `fnd
|
|
67
|
+
## `fnd create-app`
|
|
66
68
|
|
|
67
69
|
sets up a preconfigued vite app directory on for a shopify theme
|
|
68
70
|
|
|
69
71
|
```
|
|
70
72
|
USAGE
|
|
71
|
-
$ fnd
|
|
73
|
+
$ fnd create-app [-d <value>] [-s]
|
|
72
74
|
|
|
73
75
|
FLAGS
|
|
74
76
|
-d, --dir=<value> [default: src] directory to create the vite app in
|
|
@@ -78,18 +80,18 @@ DESCRIPTION
|
|
|
78
80
|
sets up a preconfigued vite app directory on for a shopify theme
|
|
79
81
|
|
|
80
82
|
EXAMPLES
|
|
81
|
-
$ fnd
|
|
83
|
+
$ fnd create-app
|
|
82
84
|
```
|
|
83
85
|
|
|
84
|
-
_See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.
|
|
86
|
+
_See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/create-app.ts)_
|
|
85
87
|
|
|
86
|
-
## `fnd
|
|
88
|
+
## `fnd create-project [SHOP]`
|
|
87
89
|
|
|
88
90
|
Scaffold a new client project: git on branch production, ignore files, Shopify theme pull, a Claude-generated CLAUDE.md, then a private GitHub repo under the FireNet-Designs org.
|
|
89
91
|
|
|
90
92
|
```
|
|
91
93
|
USAGE
|
|
92
|
-
$ fnd
|
|
94
|
+
$ fnd create-project [SHOP...]
|
|
93
95
|
|
|
94
96
|
ARGUMENTS
|
|
95
97
|
[SHOP...] Shopify store handle — pulls the live theme from <shop>.myshopify.com (omit to skip)
|
|
@@ -104,22 +106,22 @@ DESCRIPTION
|
|
|
104
106
|
never stores credentials. Override the org with FND_GH_ORG.
|
|
105
107
|
|
|
106
108
|
EXAMPLES
|
|
107
|
-
$ fnd
|
|
109
|
+
$ fnd create-project
|
|
108
110
|
|
|
109
|
-
$ fnd
|
|
111
|
+
$ fnd create-project my-store
|
|
110
112
|
|
|
111
|
-
$ fnd
|
|
113
|
+
$ fnd create-project my-store "Acme storefront, Klaviyo"
|
|
112
114
|
```
|
|
113
115
|
|
|
114
|
-
_See code: [src/commands/create-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.
|
|
116
|
+
_See code: [src/commands/create-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/create-project.ts)_
|
|
115
117
|
|
|
116
|
-
## `fnd
|
|
118
|
+
## `fnd help [COMMAND]`
|
|
117
119
|
|
|
118
|
-
Display help for fnd
|
|
120
|
+
Display help for fnd.
|
|
119
121
|
|
|
120
122
|
```
|
|
121
123
|
USAGE
|
|
122
|
-
$ fnd
|
|
124
|
+
$ fnd help [COMMAND...] [-n]
|
|
123
125
|
|
|
124
126
|
ARGUMENTS
|
|
125
127
|
[COMMAND...] Command to show help for.
|
|
@@ -128,18 +130,18 @@ FLAGS
|
|
|
128
130
|
-n, --nested-commands Include all nested commands in the output.
|
|
129
131
|
|
|
130
132
|
DESCRIPTION
|
|
131
|
-
Display help for fnd
|
|
133
|
+
Display help for fnd.
|
|
132
134
|
```
|
|
133
135
|
|
|
134
|
-
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/
|
|
136
|
+
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.53/src/commands/help.ts)_
|
|
135
137
|
|
|
136
|
-
## `fnd
|
|
138
|
+
## `fnd token`
|
|
137
139
|
|
|
138
140
|
Mint an offline Shopify Admin API access token (shpat_) for a custom app via the OAuth authorization-code grant. Works cross-org, unlike client_credentials.
|
|
139
141
|
|
|
140
142
|
```
|
|
141
143
|
USAGE
|
|
142
|
-
$ fnd
|
|
144
|
+
$ fnd token [-i <value>] [-c <value>] [--no-open] [-p <value>] [--scopes <value>] [-s <value>] [-w
|
|
143
145
|
<value>]
|
|
144
146
|
|
|
145
147
|
FLAGS
|
|
@@ -156,12 +158,74 @@ DESCRIPTION
|
|
|
156
158
|
cross-org, unlike client_credentials.
|
|
157
159
|
|
|
158
160
|
EXAMPLES
|
|
159
|
-
$ fnd
|
|
161
|
+
$ fnd token --store trafalgar-store --client-id 0b42... --client-secret shpss_...
|
|
160
162
|
|
|
161
|
-
$ fnd
|
|
163
|
+
$ fnd token -s mystore -i <id> -c <secret> --scopes read_products,read_orders
|
|
162
164
|
|
|
163
|
-
$ fnd
|
|
165
|
+
$ fnd token -s mystore -i <id> -c <secret> --write ./.mcp.json
|
|
164
166
|
```
|
|
165
167
|
|
|
166
|
-
_See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.
|
|
168
|
+
_See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/token.ts)_
|
|
169
|
+
|
|
170
|
+
## `fnd workspace`
|
|
171
|
+
|
|
172
|
+
Open a remote workspace: mirror the current directory onto a remote Linux box via reverse SSHFS and drop into a shell there, cleaning everything up on exit.
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
USAGE
|
|
176
|
+
$ fnd workspace --ssh <value> [--local-ssh-port <value>] [--mount-base <value>] [-p <value>]
|
|
177
|
+
|
|
178
|
+
FLAGS
|
|
179
|
+
-p, --port=<value> reverse-tunnel port opened on the remote (random 20000-60000 if omitted)
|
|
180
|
+
--local-ssh-port=<value> [default: 22] port your LOCAL SSH server listens on (the tunnel forwards back to this)
|
|
181
|
+
--mount-base=<value> [default: /home/fnd] base dir on the remote; the mount lands at
|
|
182
|
+
<base>/<local-user>/<dir-name>
|
|
183
|
+
--ssh=<value> (required) remote to connect to, as user@host
|
|
184
|
+
|
|
185
|
+
DESCRIPTION
|
|
186
|
+
Open a remote workspace: mirror the current directory onto a remote Linux box via reverse SSHFS and drop into a shell
|
|
187
|
+
there, cleaning everything up on exit.
|
|
188
|
+
|
|
189
|
+
The remote reaches your machine through the ssh tunnel (-R), so your local SSH server is never exposed to the internet
|
|
190
|
+
or port-forwarded. For the back-connection the remote authenticates with its OWN SSH key, which is temporarily added
|
|
191
|
+
to your local authorized_keys and removed again on exit. Locally you need an SSH *server* running (sshd), not sshfs —
|
|
192
|
+
sshfs runs on the remote.
|
|
193
|
+
|
|
194
|
+
EXAMPLES
|
|
195
|
+
$ fnd workspace --ssh user@203.0.113.4
|
|
196
|
+
|
|
197
|
+
$ fnd workspace --ssh user@host --port 40222
|
|
198
|
+
|
|
199
|
+
$ fnd workspace --ssh user@host --mount-base /home/fnd --local-ssh-port 2222
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
_See code: [src/commands/workspace/index.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/workspace/index.ts)_
|
|
203
|
+
|
|
204
|
+
## `fnd workspace cleanup`
|
|
205
|
+
|
|
206
|
+
Unmount a leftover reverse-SSHFS mount on the remote — use this if a workspace session dropped before it could clean up after itself.
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
USAGE
|
|
210
|
+
$ fnd workspace cleanup --ssh <value> [--mount <value>] [--mount-base <value>]
|
|
211
|
+
|
|
212
|
+
FLAGS
|
|
213
|
+
--mount=<value> exact remote mount path to unmount (defaults to the current dir mapping)
|
|
214
|
+
--mount-base=<value> [default: /home/fnd] base dir on the remote, used to derive the default mount path
|
|
215
|
+
--ssh=<value> (required) remote to connect to, as user@host
|
|
216
|
+
|
|
217
|
+
DESCRIPTION
|
|
218
|
+
Unmount a leftover reverse-SSHFS mount on the remote — use this if a workspace session dropped before it could clean
|
|
219
|
+
up after itself.
|
|
220
|
+
|
|
221
|
+
Unmounts the remote mount and removes the matching entry from your local authorized_keys. With no --mount, it targets
|
|
222
|
+
the same path `workspace` would use for the current directory.
|
|
223
|
+
|
|
224
|
+
EXAMPLES
|
|
225
|
+
$ fnd workspace cleanup --ssh user@host
|
|
226
|
+
|
|
227
|
+
$ fnd workspace cleanup --ssh user@host --mount /home/fnd/cole/fnd-cli
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
_See code: [src/commands/workspace/cleanup.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/workspace/cleanup.ts)_
|
|
167
231
|
<!-- commandsstop -->
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class WorkspaceCleanup extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
mount: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
'mount-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
};
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { buildContext, DEFAULT_MOUNT_BASE, hasSshClient, keyCommentForMount, parseSshTarget, removeAuthorizedKey, runRemoteCleanup, } from '../../lib/workspace.js';
|
|
4
|
+
export default class WorkspaceCleanup extends Command {
|
|
5
|
+
static description = 'Unmount a leftover reverse-SSHFS mount on the remote — use this if a workspace session dropped before it could clean up after itself.\n\nUnmounts the remote mount and removes the matching entry from your local authorized_keys. With no --mount, it targets the same path `workspace` would use for the current directory.';
|
|
6
|
+
static examples = [
|
|
7
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host',
|
|
8
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host --mount /home/fnd/cole/fnd-cli',
|
|
9
|
+
];
|
|
10
|
+
static flags = {
|
|
11
|
+
mount: Flags.string({
|
|
12
|
+
description: 'exact remote mount path to unmount (defaults to the current dir mapping)',
|
|
13
|
+
}),
|
|
14
|
+
'mount-base': Flags.string({
|
|
15
|
+
default: DEFAULT_MOUNT_BASE,
|
|
16
|
+
description: 'base dir on the remote, used to derive the default mount path',
|
|
17
|
+
}),
|
|
18
|
+
ssh: Flags.string({
|
|
19
|
+
description: 'remote to connect to, as user@host',
|
|
20
|
+
required: true,
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
async run() {
|
|
24
|
+
const { flags } = await this.parse(WorkspaceCleanup);
|
|
25
|
+
if (!hasSshClient()) {
|
|
26
|
+
this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
|
|
27
|
+
}
|
|
28
|
+
const target = parseSshTarget(flags.ssh);
|
|
29
|
+
const mount = flags.mount ?? buildContext({ cwd: process.cwd(), mountBase: flags['mount-base'] }).mount;
|
|
30
|
+
this.log(chalk.bold('Cleaning up remote mount'));
|
|
31
|
+
this.log(` ${chalk.dim('remote:')} ${target.user}@${target.host}`);
|
|
32
|
+
this.log(` ${chalk.dim('mount:')} ${mount}`);
|
|
33
|
+
this.log('');
|
|
34
|
+
const code = await runRemoteCleanup(`${target.user}@${target.host}`, mount);
|
|
35
|
+
// Revoke local trust for this workspace's key regardless of the remote result.
|
|
36
|
+
removeAuthorizedKey(keyCommentForMount(mount));
|
|
37
|
+
if (code === 0) {
|
|
38
|
+
this.log(chalk.green('✓ Done. Local authorized_keys entry removed.'));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
this.error(`Cleanup ssh session exited with code ${code} (local authorized_keys entry still removed).`, {
|
|
42
|
+
code: '1',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Workspace extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
'local-ssh-port': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
'mount-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
port: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
};
|
|
11
|
+
run(): Promise<void>;
|
|
12
|
+
/** Verify the local machine can actually host the tunnel before we connect. */
|
|
13
|
+
private preflight;
|
|
14
|
+
private printPlan;
|
|
15
|
+
/** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
|
|
16
|
+
private runSsh;
|
|
17
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { addAuthorizedKey, buildContext, buildRemoteScript, DEFAULT_LOCAL_SSH_PORT, DEFAULT_MOUNT_BASE, fetchRemotePublicKey, hasSshClient, isLocalSshdReachable, parseSshTarget, removeAuthorizedKey, runRemoteCleanup, sshServerInstructions, } from '../../lib/workspace.js';
|
|
5
|
+
export default class Workspace extends Command {
|
|
6
|
+
static description = 'Open a remote workspace: mirror the current directory onto a remote Linux box via reverse SSHFS and drop into a shell there, cleaning everything up on exit.\n\nThe remote reaches your machine through the ssh tunnel (-R), so your local SSH server is never exposed to the internet or port-forwarded. For the back-connection the remote authenticates with its OWN SSH key, which is temporarily added to your local authorized_keys and removed again on exit. Locally you need an SSH *server* running (sshd), not sshfs — sshfs runs on the remote.';
|
|
7
|
+
static examples = [
|
|
8
|
+
'<%= config.bin %> <%= command.id %> --ssh user@203.0.113.4',
|
|
9
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host --port 40222',
|
|
10
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host --mount-base /home/fnd --local-ssh-port 2222',
|
|
11
|
+
];
|
|
12
|
+
static flags = {
|
|
13
|
+
'local-ssh-port': Flags.integer({
|
|
14
|
+
default: DEFAULT_LOCAL_SSH_PORT,
|
|
15
|
+
description: 'port your LOCAL SSH server listens on (the tunnel forwards back to this)',
|
|
16
|
+
}),
|
|
17
|
+
'mount-base': Flags.string({
|
|
18
|
+
default: DEFAULT_MOUNT_BASE,
|
|
19
|
+
description: 'base dir on the remote; the mount lands at <base>/<local-user>/<dir-name>',
|
|
20
|
+
}),
|
|
21
|
+
port: Flags.integer({
|
|
22
|
+
char: 'p',
|
|
23
|
+
description: 'reverse-tunnel port opened on the remote (random 20000-60000 if omitted)',
|
|
24
|
+
}),
|
|
25
|
+
ssh: Flags.string({
|
|
26
|
+
description: 'remote to connect to, as user@host',
|
|
27
|
+
required: true,
|
|
28
|
+
}),
|
|
29
|
+
};
|
|
30
|
+
async run() {
|
|
31
|
+
const { flags } = await this.parse(Workspace);
|
|
32
|
+
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
|
+
const target2 = `${target.user}@${target.host}`;
|
|
38
|
+
this.log(chalk.dim('Fetching the remote SSH key and trusting it locally…'));
|
|
39
|
+
const remotePublicKey = fetchRemotePublicKey(target2);
|
|
40
|
+
addAuthorizedKey(remotePublicKey, ctx.keyComment);
|
|
41
|
+
let code;
|
|
42
|
+
try {
|
|
43
|
+
const script = buildRemoteScript(ctx);
|
|
44
|
+
code = await this.runSsh(target2, ctx.port, flags['local-ssh-port'], script);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
// Tear down from the local side (the remote script has no trap). Best-effort:
|
|
48
|
+
// if the connection is truly gone, unmounting won't reach the remote either.
|
|
49
|
+
this.log('');
|
|
50
|
+
this.log(chalk.dim('Cleaning up the remote mount…'));
|
|
51
|
+
try {
|
|
52
|
+
await runRemoteCleanup(target2, ctx.mount);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
this.log(chalk.yellow(`Could not reach the remote to unmount (${error.message}). ` +
|
|
56
|
+
`Run later:\n fnd workspace cleanup --ssh ${target2} --mount ${ctx.mount}`));
|
|
57
|
+
}
|
|
58
|
+
// Always revoke local trust, even if the session crashed or was killed.
|
|
59
|
+
removeAuthorizedKey(ctx.keyComment);
|
|
60
|
+
}
|
|
61
|
+
this.log('');
|
|
62
|
+
this.log(code === 0
|
|
63
|
+
? chalk.green('✓ Workspace closed. Remote mount unmounted and local trust removed.')
|
|
64
|
+
: chalk.yellow(`Session ended with exit code ${code}. Cleanup attempted above.`));
|
|
65
|
+
}
|
|
66
|
+
/** Verify the local machine can actually host the tunnel before we connect. */
|
|
67
|
+
async preflight(localSshPort) {
|
|
68
|
+
if (!hasSshClient()) {
|
|
69
|
+
this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
|
|
70
|
+
}
|
|
71
|
+
const reachable = await isLocalSshdReachable(localSshPort);
|
|
72
|
+
if (!reachable) {
|
|
73
|
+
this.log(chalk.red(`✗ No local SSH server answering on 127.0.0.1:${localSshPort}.`));
|
|
74
|
+
this.log(chalk.yellow('The remote mounts your files by sshing back through the tunnel, so your machine must be running an SSH server.'));
|
|
75
|
+
this.log('');
|
|
76
|
+
this.log(sshServerInstructions());
|
|
77
|
+
this.log('');
|
|
78
|
+
this.log(chalk.dim('If your sshd listens on another port, pass --local-ssh-port <port>.'));
|
|
79
|
+
this.error('Local SSH server is required. Aborting before connecting.', { code: '1' });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
printPlan(ctx, target) {
|
|
83
|
+
this.log(chalk.bold('Opening remote workspace'));
|
|
84
|
+
this.log(` ${chalk.dim('remote:')} ${target}`);
|
|
85
|
+
this.log(` ${chalk.dim('mirroring:')} ${ctx.localCwd}`);
|
|
86
|
+
this.log(` ${chalk.dim('mounted at:')} ${ctx.mount}`);
|
|
87
|
+
this.log(` ${chalk.dim('tunnel port:')} ${ctx.port}`);
|
|
88
|
+
this.log('');
|
|
89
|
+
}
|
|
90
|
+
/** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
|
|
91
|
+
runSsh(target, port, localSshPort, script) {
|
|
92
|
+
const args = [
|
|
93
|
+
'-t', // allocate a remote PTY for the interactive shell session
|
|
94
|
+
'-o',
|
|
95
|
+
'ExitOnForwardFailure=yes', // fail loudly if the remote can't open the -R port
|
|
96
|
+
'-R',
|
|
97
|
+
`${port}:localhost:${localSshPort}`,
|
|
98
|
+
target,
|
|
99
|
+
script,
|
|
100
|
+
];
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const child = spawn('ssh', args, { stdio: 'inherit' });
|
|
103
|
+
child.once('error', reject);
|
|
104
|
+
child.once('close', (code) => resolve(code ?? 0));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse-SSHFS workspace helpers.
|
|
3
|
+
*
|
|
4
|
+
* The topology this supports:
|
|
5
|
+
*
|
|
6
|
+
* local machine (any OS) remote machine (always Linux)
|
|
7
|
+
* ┌─────────────────────┐ ┌──────────────────────────────┐
|
|
8
|
+
* │ fnd workspace │ ssh -R ───► │ sshd │
|
|
9
|
+
* │ your cwd is served │ │ └─ sshfs -p <port> back ──┐ │
|
|
10
|
+
* │ by the LOCAL sshd ◄─┼── tunnel ──────┼─ localhost:<port> ─────────┘ │
|
|
11
|
+
* │ (port 22 by default) │ │ mounts your cwd at <mount> │
|
|
12
|
+
* └─────────────────────┘ └──────────────────────────────┘
|
|
13
|
+
*
|
|
14
|
+
* Because the remote reaches your machine THROUGH the ssh tunnel (`-R`), your
|
|
15
|
+
* local SSH server never has to be exposed to the internet or port-forwarded.
|
|
16
|
+
*
|
|
17
|
+
* The back-connection authenticates with the REMOTE's own SSH key: we read the
|
|
18
|
+
* remote's public key, add it to your local authorized_keys (tagged with a
|
|
19
|
+
* marker comment), and remove exactly that line on cleanup. We never generate
|
|
20
|
+
* key material and never delete the remote's key.
|
|
21
|
+
*
|
|
22
|
+
* IMPORTANT: locally you need an SSH *server* (sshd) running — NOT sshfs. sshfs
|
|
23
|
+
* runs on the remote, which is why we check for it there, not here.
|
|
24
|
+
*/
|
|
25
|
+
export interface SshTarget {
|
|
26
|
+
host: string;
|
|
27
|
+
user: string;
|
|
28
|
+
}
|
|
29
|
+
export interface WorkspaceContext {
|
|
30
|
+
/** authorized_keys marker tying the trusted key to this workspace (derivable from the mount). */
|
|
31
|
+
keyComment: string;
|
|
32
|
+
/** Absolute path of the current dir on the LOCAL machine (what gets mirrored). */
|
|
33
|
+
localCwd: string;
|
|
34
|
+
/** Basename of the local cwd — the leaf of the remote mount path. */
|
|
35
|
+
localDirName: string;
|
|
36
|
+
/** Username on the LOCAL machine — used both for the mount path and to auth the sshfs back-connection. */
|
|
37
|
+
localUser: string;
|
|
38
|
+
/** Where the mirror is mounted on the REMOTE, e.g. /home/fnd/<localUser>/<localDirName>. */
|
|
39
|
+
mount: string;
|
|
40
|
+
/** The reverse-tunnel port opened on the remote (`-R <port>:localhost:<localSshPort>`). */
|
|
41
|
+
port: number;
|
|
42
|
+
}
|
|
43
|
+
export declare const DEFAULT_MOUNT_BASE = "/home/fnd";
|
|
44
|
+
export declare const DEFAULT_LOCAL_SSH_PORT = 22;
|
|
45
|
+
/** Parse a `user@host` string, throwing a friendly error otherwise. */
|
|
46
|
+
export declare const parseSshTarget: (raw: string) => SshTarget;
|
|
47
|
+
/** The authorized_keys marker comment for a given mount — used to add and later remove the key. */
|
|
48
|
+
export declare const keyCommentForMount: (mount: string) => string;
|
|
49
|
+
/** Build the immutable facts for a workspace session from the local environment + flags. */
|
|
50
|
+
export declare const buildContext: (opts: {
|
|
51
|
+
cwd: string;
|
|
52
|
+
mountBase: string;
|
|
53
|
+
port?: number;
|
|
54
|
+
}) => WorkspaceContext;
|
|
55
|
+
/** POSIX single-quote a string so it can be embedded safely in the remote shell script. */
|
|
56
|
+
export declare const shQuote: (value: string) => string;
|
|
57
|
+
/**
|
|
58
|
+
* Read the remote's SSH public key so we can trust it locally for the back
|
|
59
|
+
* connection. Ensures an ed25519 key exists on the remote (creating one only if
|
|
60
|
+
* the remote has none) and prints it. Returns the single public-key line.
|
|
61
|
+
*/
|
|
62
|
+
export declare const fetchRemotePublicKey: (target: string) => string;
|
|
63
|
+
/** Path to the local user's authorized_keys (cross-platform via os.homedir()). */
|
|
64
|
+
export declare const authorizedKeysPath: () => string;
|
|
65
|
+
/**
|
|
66
|
+
* Trust the remote's public key locally so it can ssh back in. The marker
|
|
67
|
+
* comment is appended so cleanup can remove exactly this line later.
|
|
68
|
+
*/
|
|
69
|
+
export declare const addAuthorizedKey: (publicKey: string, comment: string) => void;
|
|
70
|
+
/** Remove any authorized_keys line carrying the given marker comment. */
|
|
71
|
+
export declare const removeAuthorizedKey: (comment: string) => void;
|
|
72
|
+
/**
|
|
73
|
+
* The bash script the remote runs. It mounts the local cwd via reverse sshfs
|
|
74
|
+
* (authenticating with its own default key) and drops into an interactive
|
|
75
|
+
* shell. Teardown (unmount + rmdir) is driven from the LOCAL side in
|
|
76
|
+
* a `finally` via runRemoteCleanup — no in-script trap, which proved brittle
|
|
77
|
+
* inside the PTY session.
|
|
78
|
+
*/
|
|
79
|
+
export declare const buildRemoteScript: (ctx: WorkspaceContext) => string;
|
|
80
|
+
/** Run the remote-side teardown (unmount + rmdir) over a fresh ssh connection. */
|
|
81
|
+
export declare const runRemoteCleanup: (target: string, mount: string) => Promise<number>;
|
|
82
|
+
/** The remote-side teardown script (unmount only — the key is the remote's own). */
|
|
83
|
+
export declare const buildCleanupScript: (mount: string) => string;
|
|
84
|
+
/** True if an `ssh` client is on PATH (works on Windows, macOS, Linux). */
|
|
85
|
+
export declare const hasSshClient: () => boolean;
|
|
86
|
+
/** Resolve true if a local SSH server is accepting connections on 127.0.0.1:<port>. */
|
|
87
|
+
export declare const isLocalSshdReachable: (port: number, timeoutMs?: number) => Promise<boolean>;
|
|
88
|
+
/** Platform-specific instructions for turning on the local OpenSSH server. */
|
|
89
|
+
export declare const sshServerInstructions: (platform?: NodeJS.Platform) => string;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { randomInt } from 'node:crypto';
|
|
3
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { connect } from 'node:net';
|
|
5
|
+
import { homedir, userInfo } from 'node:os';
|
|
6
|
+
import { basename, join } from 'node:path';
|
|
7
|
+
export const DEFAULT_MOUNT_BASE = '/home/fnd';
|
|
8
|
+
export const DEFAULT_LOCAL_SSH_PORT = 22;
|
|
9
|
+
/** Parse a `user@host` string, throwing a friendly error otherwise. */
|
|
10
|
+
export const parseSshTarget = (raw) => {
|
|
11
|
+
const match = raw.trim().match(/^([^@\s]+)@([^@\s]+)$/);
|
|
12
|
+
if (!match)
|
|
13
|
+
throw new Error(`--ssh must be in the form user@host (got "${raw}")`);
|
|
14
|
+
return { host: match[2], user: match[1] };
|
|
15
|
+
};
|
|
16
|
+
/** The authorized_keys marker comment for a given mount — used to add and later remove the key. */
|
|
17
|
+
export const keyCommentForMount = (mount) => `fnd-workspace:${mount}`;
|
|
18
|
+
/** Build the immutable facts for a workspace session from the local environment + flags. */
|
|
19
|
+
export const buildContext = (opts) => {
|
|
20
|
+
const localUser = userInfo().username;
|
|
21
|
+
const localCwd = opts.cwd;
|
|
22
|
+
const localDirName = basename(localCwd);
|
|
23
|
+
const base = opts.mountBase.replace(/\/+$/, '');
|
|
24
|
+
const mount = `${base}/${localUser}/${localDirName}`;
|
|
25
|
+
// Ephemeral-ish range, kept below 65535 and clear of most well-known ports.
|
|
26
|
+
const port = opts.port ?? randomInt(20_000, 60_000);
|
|
27
|
+
return {
|
|
28
|
+
keyComment: keyCommentForMount(mount),
|
|
29
|
+
localCwd,
|
|
30
|
+
localDirName,
|
|
31
|
+
localUser,
|
|
32
|
+
mount,
|
|
33
|
+
port,
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
/** POSIX single-quote a string so it can be embedded safely in the remote shell script. */
|
|
37
|
+
export const shQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
|
|
38
|
+
/**
|
|
39
|
+
* Read the remote's SSH public key so we can trust it locally for the back
|
|
40
|
+
* connection. Ensures an ed25519 key exists on the remote (creating one only if
|
|
41
|
+
* the remote has none) and prints it. Returns the single public-key line.
|
|
42
|
+
*/
|
|
43
|
+
export const fetchRemotePublicKey = (target) => {
|
|
44
|
+
const remoteScript = [
|
|
45
|
+
'set -e',
|
|
46
|
+
'KEY="$HOME/.ssh/id_ed25519"',
|
|
47
|
+
'mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"',
|
|
48
|
+
'if [ ! -f "$KEY.pub" ]; then ssh-keygen -t ed25519 -N "" -f "$KEY" -q; fi',
|
|
49
|
+
'cat "$KEY.pub"',
|
|
50
|
+
].join('\n');
|
|
51
|
+
// stdout piped (we capture the key); stdin/stderr inherited so any password or
|
|
52
|
+
// passphrase prompt still reaches the user's terminal.
|
|
53
|
+
const result = spawnSync('ssh', [target, remoteScript], {
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
stdio: ['inherit', 'pipe', 'inherit'],
|
|
56
|
+
});
|
|
57
|
+
if (result.error)
|
|
58
|
+
throw new Error(`Could not ssh to ${target}: ${result.error.message}`);
|
|
59
|
+
if (result.status !== 0)
|
|
60
|
+
throw new Error(`Could not read the remote's SSH key (ssh exited ${result.status}).`);
|
|
61
|
+
const key = (result.stdout ?? '').trim();
|
|
62
|
+
if (!key.startsWith('ssh-'))
|
|
63
|
+
throw new Error(`Unexpected remote key output: ${key.slice(0, 80)}`);
|
|
64
|
+
return key;
|
|
65
|
+
};
|
|
66
|
+
/** Path to the local user's authorized_keys (cross-platform via os.homedir()). */
|
|
67
|
+
export const authorizedKeysPath = () => join(homedir(), '.ssh', 'authorized_keys');
|
|
68
|
+
/**
|
|
69
|
+
* Trust the remote's public key locally so it can ssh back in. The marker
|
|
70
|
+
* comment is appended so cleanup can remove exactly this line later.
|
|
71
|
+
*/
|
|
72
|
+
export const addAuthorizedKey = (publicKey, comment) => {
|
|
73
|
+
const sshDir = join(homedir(), '.ssh');
|
|
74
|
+
const file = authorizedKeysPath();
|
|
75
|
+
if (!existsSync(sshDir))
|
|
76
|
+
mkdirSync(sshDir, { mode: 0o700, recursive: true });
|
|
77
|
+
const line = `${publicKey.trim()} ${comment}`;
|
|
78
|
+
const existing = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
79
|
+
const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
80
|
+
appendFileSync(file, `${prefix}${line}\n`);
|
|
81
|
+
// sshd ignores authorized_keys with loose perms (POSIX); best-effort on Windows.
|
|
82
|
+
try {
|
|
83
|
+
chmodSync(file, 0o600);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* Windows uses ACLs, not POSIX modes — nothing to do here. */
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
/** Remove any authorized_keys line carrying the given marker comment. */
|
|
90
|
+
export const removeAuthorizedKey = (comment) => {
|
|
91
|
+
const file = authorizedKeysPath();
|
|
92
|
+
if (!existsSync(file))
|
|
93
|
+
return;
|
|
94
|
+
const kept = readFileSync(file, 'utf8')
|
|
95
|
+
.split('\n')
|
|
96
|
+
.filter((line) => line.length > 0 && !line.includes(comment));
|
|
97
|
+
writeFileSync(file, kept.length > 0 ? `${kept.join('\n')}\n` : '');
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* The bash script the remote runs. It mounts the local cwd via reverse sshfs
|
|
101
|
+
* (authenticating with its own default key) and drops into an interactive
|
|
102
|
+
* shell. Teardown (unmount + rmdir) is driven from the LOCAL side in
|
|
103
|
+
* a `finally` via runRemoteCleanup — no in-script trap, which proved brittle
|
|
104
|
+
* inside the PTY session.
|
|
105
|
+
*/
|
|
106
|
+
export const buildRemoteScript = (ctx) => {
|
|
107
|
+
const mount = shQuote(ctx.mount);
|
|
108
|
+
// `user@localhost:/absolute/local/path` — quoted whole so spaces survive.
|
|
109
|
+
const remoteSource = shQuote(`${ctx.localUser}@localhost:${ctx.localCwd}`);
|
|
110
|
+
const sshfsOpts = [
|
|
111
|
+
// Connect to the -R tunnel's entrance on the REMOTE's loopback (ctx.port),
|
|
112
|
+
// which forwards back to the local sshd. NOT the local sshd port directly.
|
|
113
|
+
`port=${ctx.port}`,
|
|
114
|
+
'StrictHostKeyChecking=accept-new',
|
|
115
|
+
'ServerAliveInterval=15',
|
|
116
|
+
'ServerAliveCountMax=3',
|
|
117
|
+
].join(',');
|
|
118
|
+
return [
|
|
119
|
+
'set -u',
|
|
120
|
+
`MOUNT=${mount}`,
|
|
121
|
+
'AGENT_STARTED=0',
|
|
122
|
+
// Remote must have sshfs — it is always Linux, so give an apt hint.
|
|
123
|
+
'if ! command -v sshfs >/dev/null 2>&1; then',
|
|
124
|
+
' echo "ERROR: sshfs is not installed on the remote. Install it, e.g.: sudo apt install sshfs" >&2',
|
|
125
|
+
' exit 1',
|
|
126
|
+
'fi',
|
|
127
|
+
// sshfs daemonizes, so its ssh child can't reliably prompt for a key
|
|
128
|
+
// passphrase. Load the key into an ssh-agent ONCE (ssh-add prompts cleanly
|
|
129
|
+
// on this PTY); sshfs then authenticates through the agent, no re-prompts.
|
|
130
|
+
// eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
|
|
131
|
+
'if [ -n "${SSH_AUTH_SOCK:-}" ] && ssh-add -l >/dev/null 2>&1; then',
|
|
132
|
+
' :', // an agent with keys is already available — reuse it
|
|
133
|
+
'else',
|
|
134
|
+
// eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
|
|
135
|
+
' if [ -z "${SSH_AUTH_SOCK:-}" ]; then eval "$(ssh-agent -s)" >/dev/null && AGENT_STARTED=1; fi',
|
|
136
|
+
' echo "Loading your SSH key into the agent (enter its passphrase once if prompted)..."',
|
|
137
|
+
' ssh-add </dev/tty || echo "WARNING: ssh-add loaded no key; sshfs may fail to authenticate back to your machine." >&2',
|
|
138
|
+
'fi',
|
|
139
|
+
// Clear any stale mount left by a previous dropped session, then (re)create.
|
|
140
|
+
'fusermount -u "$MOUNT" 2>/dev/null || umount "$MOUNT" 2>/dev/null || true',
|
|
141
|
+
'mkdir -p "$MOUNT" || { echo "ERROR: could not create $MOUNT" >&2; exit 1; }',
|
|
142
|
+
'echo "Mounting your local directory over the reverse tunnel..."',
|
|
143
|
+
`sshfs -o ${sshfsOpts} ${remoteSource} "$MOUNT" || { echo "ERROR: sshfs mount failed" >&2; exit 1; }`,
|
|
144
|
+
// The mount authenticated at this point; if we started an agent just for
|
|
145
|
+
// that, kill it so the decrypted key doesn't sit in memory during the session.
|
|
146
|
+
// eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
|
|
147
|
+
'[ "$AGENT_STARTED" = 1 ] && kill "${SSH_AGENT_PID:-}" 2>/dev/null || true',
|
|
148
|
+
'cd "$MOUNT" || { echo "ERROR: could not enter $MOUNT" >&2; exit 1; }',
|
|
149
|
+
'echo "Workspace ready at $MOUNT — dropping into a shell (exit to clean up)."',
|
|
150
|
+
// eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
|
|
151
|
+
'"${SHELL:-bash}" -l',
|
|
152
|
+
].join('\n');
|
|
153
|
+
};
|
|
154
|
+
/** Run the remote-side teardown (unmount + rmdir) over a fresh ssh connection. */
|
|
155
|
+
export const runRemoteCleanup = (target, mount) => new Promise((resolve, reject) => {
|
|
156
|
+
const child = spawn('ssh', [target, buildCleanupScript(mount)], { stdio: 'inherit' });
|
|
157
|
+
child.once('error', reject);
|
|
158
|
+
child.once('close', (code) => resolve(code ?? 0));
|
|
159
|
+
});
|
|
160
|
+
/** The remote-side teardown script (unmount only — the key is the remote's own). */
|
|
161
|
+
export const buildCleanupScript = (mount) => {
|
|
162
|
+
const quotedMount = shQuote(mount);
|
|
163
|
+
return [
|
|
164
|
+
`MOUNT=${quotedMount}`,
|
|
165
|
+
'if fusermount -u "$MOUNT" 2>/dev/null || fusermount3 -u "$MOUNT" 2>/dev/null || umount "$MOUNT" 2>/dev/null; then',
|
|
166
|
+
' echo "Unmounted $MOUNT"',
|
|
167
|
+
'else',
|
|
168
|
+
' echo "Nothing mounted at $MOUNT (or already unmounted)"',
|
|
169
|
+
'fi',
|
|
170
|
+
'rmdir "$MOUNT" 2>/dev/null && echo "Removed empty $MOUNT" || true',
|
|
171
|
+
].join('\n');
|
|
172
|
+
};
|
|
173
|
+
/** True if an `ssh` client is on PATH (works on Windows, macOS, Linux). */
|
|
174
|
+
export const hasSshClient = () => {
|
|
175
|
+
const result = spawnSync('ssh', ['-V'], { stdio: 'ignore' });
|
|
176
|
+
return !result.error;
|
|
177
|
+
};
|
|
178
|
+
/** Resolve true if a local SSH server is accepting connections on 127.0.0.1:<port>. */
|
|
179
|
+
export const isLocalSshdReachable = (port, timeoutMs = 2000) => new Promise((resolve) => {
|
|
180
|
+
const socket = connect({ host: '127.0.0.1', port });
|
|
181
|
+
const done = (ok) => {
|
|
182
|
+
socket.destroy();
|
|
183
|
+
resolve(ok);
|
|
184
|
+
};
|
|
185
|
+
socket.setTimeout(timeoutMs);
|
|
186
|
+
socket.once('connect', () => done(true));
|
|
187
|
+
socket.once('timeout', () => done(false));
|
|
188
|
+
socket.once('error', () => done(false));
|
|
189
|
+
});
|
|
190
|
+
/** Platform-specific instructions for turning on the local OpenSSH server. */
|
|
191
|
+
export const sshServerInstructions = (platform = process.platform) => {
|
|
192
|
+
if (platform === 'win32') {
|
|
193
|
+
return [
|
|
194
|
+
'Windows — enable the OpenSSH Server (run PowerShell as Administrator):',
|
|
195
|
+
' Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0',
|
|
196
|
+
' Start-Service sshd',
|
|
197
|
+
" Set-Service -Name sshd -StartupType 'Automatic'",
|
|
198
|
+
' New-NetFirewallRule -Name sshd -DisplayName "OpenSSH Server" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22',
|
|
199
|
+
].join('\n');
|
|
200
|
+
}
|
|
201
|
+
if (platform === 'darwin') {
|
|
202
|
+
return [
|
|
203
|
+
'macOS — turn on Remote Login (the built-in SSH server):',
|
|
204
|
+
' sudo systemsetup -setremotelogin on',
|
|
205
|
+
' # or: System Settings → General → Sharing → Remote Login',
|
|
206
|
+
].join('\n');
|
|
207
|
+
}
|
|
208
|
+
return [
|
|
209
|
+
'Linux — install and start the OpenSSH server:',
|
|
210
|
+
' Debian/Ubuntu: sudo apt install openssh-server && sudo systemctl enable --now ssh',
|
|
211
|
+
' Fedora/RHEL: sudo dnf install openssh-server && sudo systemctl enable --now sshd',
|
|
212
|
+
' Arch: sudo pacman -S openssh && sudo systemctl enable --now sshd',
|
|
213
|
+
].join('\n');
|
|
214
|
+
};
|
package/oclif.manifest.json
CHANGED
|
@@ -182,7 +182,115 @@
|
|
|
182
182
|
"commands",
|
|
183
183
|
"token.js"
|
|
184
184
|
]
|
|
185
|
+
},
|
|
186
|
+
"workspace:cleanup": {
|
|
187
|
+
"aliases": [],
|
|
188
|
+
"args": {},
|
|
189
|
+
"description": "Unmount a leftover reverse-SSHFS mount on the remote — use this if a workspace session dropped before it could clean up after itself.\n\nUnmounts the remote mount and removes the matching entry from your local authorized_keys. With no --mount, it targets the same path `workspace` would use for the current directory.",
|
|
190
|
+
"examples": [
|
|
191
|
+
"<%= config.bin %> <%= command.id %> --ssh user@host",
|
|
192
|
+
"<%= config.bin %> <%= command.id %> --ssh user@host --mount /home/fnd/cole/fnd-cli"
|
|
193
|
+
],
|
|
194
|
+
"flags": {
|
|
195
|
+
"mount": {
|
|
196
|
+
"description": "exact remote mount path to unmount (defaults to the current dir mapping)",
|
|
197
|
+
"name": "mount",
|
|
198
|
+
"hasDynamicHelp": false,
|
|
199
|
+
"multiple": false,
|
|
200
|
+
"type": "option"
|
|
201
|
+
},
|
|
202
|
+
"mount-base": {
|
|
203
|
+
"description": "base dir on the remote, used to derive the default mount path",
|
|
204
|
+
"name": "mount-base",
|
|
205
|
+
"default": "/home/fnd",
|
|
206
|
+
"hasDynamicHelp": false,
|
|
207
|
+
"multiple": false,
|
|
208
|
+
"type": "option"
|
|
209
|
+
},
|
|
210
|
+
"ssh": {
|
|
211
|
+
"description": "remote to connect to, as user@host",
|
|
212
|
+
"name": "ssh",
|
|
213
|
+
"required": true,
|
|
214
|
+
"hasDynamicHelp": false,
|
|
215
|
+
"multiple": false,
|
|
216
|
+
"type": "option"
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
"hasDynamicHelp": false,
|
|
220
|
+
"hiddenAliases": [],
|
|
221
|
+
"id": "workspace:cleanup",
|
|
222
|
+
"pluginAlias": "@firenet-designs/fnd-cli",
|
|
223
|
+
"pluginName": "@firenet-designs/fnd-cli",
|
|
224
|
+
"pluginType": "core",
|
|
225
|
+
"strict": true,
|
|
226
|
+
"enableJsonFlag": false,
|
|
227
|
+
"isESM": true,
|
|
228
|
+
"relativePath": [
|
|
229
|
+
"dist",
|
|
230
|
+
"commands",
|
|
231
|
+
"workspace",
|
|
232
|
+
"cleanup.js"
|
|
233
|
+
]
|
|
234
|
+
},
|
|
235
|
+
"workspace": {
|
|
236
|
+
"aliases": [],
|
|
237
|
+
"args": {},
|
|
238
|
+
"description": "Open a remote workspace: mirror the current directory onto a remote Linux box via reverse SSHFS and drop into a shell there, cleaning everything up on exit.\n\nThe remote reaches your machine through the ssh tunnel (-R), so your local SSH server is never exposed to the internet or port-forwarded. For the back-connection the remote authenticates with its OWN SSH key, which is temporarily added to your local authorized_keys and removed again on exit. Locally you need an SSH *server* running (sshd), not sshfs — sshfs runs on the remote.",
|
|
239
|
+
"examples": [
|
|
240
|
+
"<%= config.bin %> <%= command.id %> --ssh user@203.0.113.4",
|
|
241
|
+
"<%= config.bin %> <%= command.id %> --ssh user@host --port 40222",
|
|
242
|
+
"<%= config.bin %> <%= command.id %> --ssh user@host --mount-base /home/fnd --local-ssh-port 2222"
|
|
243
|
+
],
|
|
244
|
+
"flags": {
|
|
245
|
+
"local-ssh-port": {
|
|
246
|
+
"description": "port your LOCAL SSH server listens on (the tunnel forwards back to this)",
|
|
247
|
+
"name": "local-ssh-port",
|
|
248
|
+
"default": 22,
|
|
249
|
+
"hasDynamicHelp": false,
|
|
250
|
+
"multiple": false,
|
|
251
|
+
"type": "option"
|
|
252
|
+
},
|
|
253
|
+
"mount-base": {
|
|
254
|
+
"description": "base dir on the remote; the mount lands at <base>/<local-user>/<dir-name>",
|
|
255
|
+
"name": "mount-base",
|
|
256
|
+
"default": "/home/fnd",
|
|
257
|
+
"hasDynamicHelp": false,
|
|
258
|
+
"multiple": false,
|
|
259
|
+
"type": "option"
|
|
260
|
+
},
|
|
261
|
+
"port": {
|
|
262
|
+
"char": "p",
|
|
263
|
+
"description": "reverse-tunnel port opened on the remote (random 20000-60000 if omitted)",
|
|
264
|
+
"name": "port",
|
|
265
|
+
"hasDynamicHelp": false,
|
|
266
|
+
"multiple": false,
|
|
267
|
+
"type": "option"
|
|
268
|
+
},
|
|
269
|
+
"ssh": {
|
|
270
|
+
"description": "remote to connect to, as user@host",
|
|
271
|
+
"name": "ssh",
|
|
272
|
+
"required": true,
|
|
273
|
+
"hasDynamicHelp": false,
|
|
274
|
+
"multiple": false,
|
|
275
|
+
"type": "option"
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
"hasDynamicHelp": false,
|
|
279
|
+
"hiddenAliases": [],
|
|
280
|
+
"id": "workspace",
|
|
281
|
+
"pluginAlias": "@firenet-designs/fnd-cli",
|
|
282
|
+
"pluginName": "@firenet-designs/fnd-cli",
|
|
283
|
+
"pluginType": "core",
|
|
284
|
+
"strict": true,
|
|
285
|
+
"enableJsonFlag": false,
|
|
286
|
+
"isESM": true,
|
|
287
|
+
"relativePath": [
|
|
288
|
+
"dist",
|
|
289
|
+
"commands",
|
|
290
|
+
"workspace",
|
|
291
|
+
"index.js"
|
|
292
|
+
]
|
|
185
293
|
}
|
|
186
294
|
},
|
|
187
|
-
"version": "2.
|
|
295
|
+
"version": "2.2.0"
|
|
188
296
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firenet-designs/fnd-cli",
|
|
3
3
|
"description": "A new CLI generated with oclif",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"author": "Cole Denslow",
|
|
6
|
+
"contributors": [
|
|
7
|
+
"Justin Schellenberg"
|
|
8
|
+
],
|
|
6
9
|
"bin": {
|
|
7
10
|
"fnd": "bin/run.js"
|
|
8
11
|
},
|
|
@@ -26,15 +29,18 @@
|
|
|
26
29
|
"eslint": "^9",
|
|
27
30
|
"eslint-config-oclif": "^6",
|
|
28
31
|
"eslint-config-prettier": "^10",
|
|
29
|
-
"mocha": "^
|
|
32
|
+
"mocha": "^11",
|
|
30
33
|
"oclif": "^4",
|
|
31
|
-
"shx": "^0.
|
|
34
|
+
"shx": "^0.4.0",
|
|
32
35
|
"ts-node": "^10",
|
|
33
36
|
"typescript": "^5"
|
|
34
37
|
},
|
|
35
38
|
"engines": {
|
|
36
39
|
"node": ">=24.0.0"
|
|
37
40
|
},
|
|
41
|
+
"overrides": {
|
|
42
|
+
"glob": "^13"
|
|
43
|
+
},
|
|
38
44
|
"files": [
|
|
39
45
|
"./bin",
|
|
40
46
|
"./dist",
|
|
@@ -53,7 +59,7 @@
|
|
|
53
59
|
},
|
|
54
60
|
"type": "module",
|
|
55
61
|
"oclif": {
|
|
56
|
-
"bin": "fnd
|
|
62
|
+
"bin": "fnd",
|
|
57
63
|
"dirname": "fnd-cli",
|
|
58
64
|
"commands": "./dist/commands",
|
|
59
65
|
"hooks": {
|
|
@@ -68,6 +74,9 @@
|
|
|
68
74
|
"topics": {
|
|
69
75
|
"hello": {
|
|
70
76
|
"description": "Say hello to the world and others"
|
|
77
|
+
},
|
|
78
|
+
"workspace": {
|
|
79
|
+
"description": "Open a remote reverse-SSHFS workspace and run Claude on it"
|
|
71
80
|
}
|
|
72
81
|
}
|
|
73
82
|
},
|
|
@@ -19,7 +19,7 @@ Look at the current directory to infer the fields. Useful signals:
|
|
|
19
19
|
- `@shopify/hydrogen` in deps, or `app/routes/` + `remix`/`vite` → **Shopify Hydrogen**.
|
|
20
20
|
- `next.config.*` or `next` in deps → **Next.js**.
|
|
21
21
|
- An Express/Fastify/Hono server entry with no frontend → **Node API**.
|
|
22
|
-
- Anything else → **Custom** (write 4
|
|
22
|
+
- Anything else → **Custom** (write 4-6 conventions tailored to the actual stack).
|
|
23
23
|
- `tsconfig.json`, lockfile type (`package-lock.json`/`pnpm-lock.yaml`/`yarn.lock`/`bun.lockb`), `.nvmrc`/`engines` → runtime + package manager.
|
|
24
24
|
- `.env.example` / `.env.sample` → external services (Klaviyo, Stripe, Shopify Admin API, a DB URL, etc.).
|
|
25
25
|
- Top two levels of the directory tree → Project Structure block.
|
|
@@ -70,7 +70,7 @@ These apply to every file touched in this project.
|
|
|
70
70
|
|
|
71
71
|
## Conventions
|
|
72
72
|
|
|
73
|
-
[Insert the matching preset block below for the detected type. For Custom, write 4
|
|
73
|
+
[Insert the matching preset block below for the detected type. For Custom, write 4-6 stack-specific rules, one per line, imperative voice.]
|
|
74
74
|
|
|
75
75
|
---
|
|
76
76
|
|