@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 CHANGED
@@ -17,31 +17,33 @@ A new CLI generated with oclif
17
17
  <!-- usage -->
18
18
  ```sh-session
19
19
  $ npm install -g @firenet-designs/fnd-cli
20
- $ fnd-cli COMMAND
20
+ $ fnd COMMAND
21
21
  running command...
22
- $ fnd-cli (--version)
23
- @firenet-designs/fnd-cli/2.1.0 linux-x64 node-v24.12.0
24
- $ fnd-cli --help [COMMAND]
22
+ $ fnd (--version)
23
+ @firenet-designs/fnd-cli/2.2.0 linux-x64 node-v24.18.0
24
+ $ fnd --help [COMMAND]
25
25
  USAGE
26
- $ fnd-cli COMMAND
26
+ $ fnd COMMAND
27
27
  ...
28
28
  ```
29
29
  <!-- usagestop -->
30
30
  # Commands
31
31
  <!-- commands -->
32
- * [`fnd-cli backfill-project [SHOP]`](#fnd-cli-backfill-project-shop)
33
- * [`fnd-cli create-app`](#fnd-cli-create-app)
34
- * [`fnd-cli create-project [SHOP]`](#fnd-cli-create-project-shop)
35
- * [`fnd-cli help [COMMAND]`](#fnd-cli-help-command)
36
- * [`fnd-cli token`](#fnd-cli-token)
32
+ * [`fnd backfill-project [SHOP]`](#fnd-backfill-project-shop)
33
+ * [`fnd create-app`](#fnd-create-app)
34
+ * [`fnd create-project [SHOP]`](#fnd-create-project-shop)
35
+ * [`fnd help [COMMAND]`](#fnd-help-command)
36
+ * [`fnd token`](#fnd-token)
37
+ * [`fnd workspace`](#fnd-workspace)
38
+ * [`fnd workspace cleanup`](#fnd-workspace-cleanup)
37
39
 
38
- ## `fnd-cli backfill-project [SHOP]`
40
+ ## `fnd backfill-project [SHOP]`
39
41
 
40
42
  Backfill missing scaffold files in an EXISTING repo + theme: .gitignore, .shopifyignore, CLAUDE.md. Each is created only if absent — existing files are left untouched. Does NOT git init, pull the theme, commit, or touch GitHub.
41
43
 
42
44
  ```
43
45
  USAGE
44
- $ fnd-cli backfill-project [SHOP...]
46
+ $ fnd backfill-project [SHOP...]
45
47
 
46
48
  ARGUMENTS
47
49
  [SHOP...] Shopify store handle, passed to Claude as a hint
@@ -53,22 +55,22 @@ DESCRIPTION
53
55
  Requires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.
54
56
 
55
57
  EXAMPLES
56
- $ fnd-cli backfill-project
58
+ $ fnd backfill-project
57
59
 
58
- $ fnd-cli backfill-project my-store
60
+ $ fnd backfill-project my-store
59
61
 
60
- $ fnd-cli backfill-project my-store "extra hints"
62
+ $ fnd backfill-project my-store "extra hints"
61
63
  ```
62
64
 
63
- _See code: [src/commands/backfill-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.1.0/src/commands/backfill-project.ts)_
65
+ _See code: [src/commands/backfill-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/backfill-project.ts)_
64
66
 
65
- ## `fnd-cli create-app`
67
+ ## `fnd create-app`
66
68
 
67
69
  sets up a preconfigued vite app directory on for a shopify theme
68
70
 
69
71
  ```
70
72
  USAGE
71
- $ fnd-cli create-app [-d <value>] [-s]
73
+ $ fnd create-app [-d <value>] [-s]
72
74
 
73
75
  FLAGS
74
76
  -d, --dir=<value> [default: src] directory to create the vite app in
@@ -78,18 +80,18 @@ DESCRIPTION
78
80
  sets up a preconfigued vite app directory on for a shopify theme
79
81
 
80
82
  EXAMPLES
81
- $ fnd-cli create-app
83
+ $ fnd create-app
82
84
  ```
83
85
 
84
- _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.1.0/src/commands/create-app.ts)_
86
+ _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/create-app.ts)_
85
87
 
86
- ## `fnd-cli create-project [SHOP]`
88
+ ## `fnd create-project [SHOP]`
87
89
 
88
90
  Scaffold a new client project: git on branch production, ignore files, Shopify theme pull, a Claude-generated CLAUDE.md, then a private GitHub repo under the FireNet-Designs org.
89
91
 
90
92
  ```
91
93
  USAGE
92
- $ fnd-cli create-project [SHOP...]
94
+ $ fnd create-project [SHOP...]
93
95
 
94
96
  ARGUMENTS
95
97
  [SHOP...] Shopify store handle — pulls the live theme from <shop>.myshopify.com (omit to skip)
@@ -104,22 +106,22 @@ DESCRIPTION
104
106
  never stores credentials. Override the org with FND_GH_ORG.
105
107
 
106
108
  EXAMPLES
107
- $ fnd-cli create-project
109
+ $ fnd create-project
108
110
 
109
- $ fnd-cli create-project my-store
111
+ $ fnd create-project my-store
110
112
 
111
- $ fnd-cli create-project my-store "Acme storefront, Klaviyo"
113
+ $ fnd create-project my-store "Acme storefront, Klaviyo"
112
114
  ```
113
115
 
114
- _See code: [src/commands/create-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.1.0/src/commands/create-project.ts)_
116
+ _See code: [src/commands/create-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/create-project.ts)_
115
117
 
116
- ## `fnd-cli help [COMMAND]`
118
+ ## `fnd help [COMMAND]`
117
119
 
118
- Display help for fnd-cli.
120
+ Display help for fnd.
119
121
 
120
122
  ```
121
123
  USAGE
122
- $ fnd-cli help [COMMAND...] [-n]
124
+ $ fnd help [COMMAND...] [-n]
123
125
 
124
126
  ARGUMENTS
125
127
  [COMMAND...] Command to show help for.
@@ -128,18 +130,18 @@ FLAGS
128
130
  -n, --nested-commands Include all nested commands in the output.
129
131
 
130
132
  DESCRIPTION
131
- Display help for fnd-cli.
133
+ Display help for fnd.
132
134
  ```
133
135
 
134
- _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.37/src/commands/help.ts)_
136
+ _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.53/src/commands/help.ts)_
135
137
 
136
- ## `fnd-cli token`
138
+ ## `fnd token`
137
139
 
138
140
  Mint an offline Shopify Admin API access token (shpat_) for a custom app via the OAuth authorization-code grant. Works cross-org, unlike client_credentials.
139
141
 
140
142
  ```
141
143
  USAGE
142
- $ fnd-cli token [-i <value>] [-c <value>] [--no-open] [-p <value>] [--scopes <value>] [-s <value>] [-w
144
+ $ fnd token [-i <value>] [-c <value>] [--no-open] [-p <value>] [--scopes <value>] [-s <value>] [-w
143
145
  <value>]
144
146
 
145
147
  FLAGS
@@ -156,12 +158,74 @@ DESCRIPTION
156
158
  cross-org, unlike client_credentials.
157
159
 
158
160
  EXAMPLES
159
- $ fnd-cli token --store trafalgar-store --client-id 0b42... --client-secret shpss_...
161
+ $ fnd token --store trafalgar-store --client-id 0b42... --client-secret shpss_...
160
162
 
161
- $ fnd-cli token -s mystore -i <id> -c <secret> --scopes read_products,read_orders
163
+ $ fnd token -s mystore -i <id> -c <secret> --scopes read_products,read_orders
162
164
 
163
- $ fnd-cli token -s mystore -i <id> -c <secret> --write ./.mcp.json
165
+ $ fnd token -s mystore -i <id> -c <secret> --write ./.mcp.json
164
166
  ```
165
167
 
166
- _See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.1.0/src/commands/token.ts)_
168
+ _See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.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
+ };
@@ -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.1.0"
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.1.0",
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": "^10",
32
+ "mocha": "^11",
30
33
  "oclif": "^4",
31
- "shx": "^0.3.3",
34
+ "shx": "^0.4.0",
32
35
  "ts-node": "^10",
33
36
  "typescript": "^5"
34
37
  },
35
38
  "engines": {
36
39
  "node": ">=24.0.0"
37
40
  },
41
+ "overrides": {
42
+ "glob": "^13"
43
+ },
38
44
  "files": [
39
45
  "./bin",
40
46
  "./dist",
@@ -53,7 +59,7 @@
53
59
  },
54
60
  "type": "module",
55
61
  "oclif": {
56
- "bin": "fnd-cli",
62
+ "bin": "fnd",
57
63
  "dirname": "fnd-cli",
58
64
  "commands": "./dist/commands",
59
65
  "hooks": {
@@ -68,6 +74,9 @@
68
74
  "topics": {
69
75
  "hello": {
70
76
  "description": "Say hello to the world and others"
77
+ },
78
+ "workspace": {
79
+ "description": "Open a remote reverse-SSHFS workspace and run Claude on it"
71
80
  }
72
81
  }
73
82
  },
@@ -19,7 +19,7 @@ Look at the current directory to infer the fields. Useful signals:
19
19
  - `@shopify/hydrogen` in deps, or `app/routes/` + `remix`/`vite` → **Shopify Hydrogen**.
20
20
  - `next.config.*` or `next` in deps → **Next.js**.
21
21
  - An Express/Fastify/Hono server entry with no frontend → **Node API**.
22
- - Anything else → **Custom** (write 46 conventions tailored to the actual stack).
22
+ - Anything else → **Custom** (write 4-6 conventions tailored to the actual stack).
23
23
  - `tsconfig.json`, lockfile type (`package-lock.json`/`pnpm-lock.yaml`/`yarn.lock`/`bun.lockb`), `.nvmrc`/`engines` → runtime + package manager.
24
24
  - `.env.example` / `.env.sample` → external services (Klaviyo, Stripe, Shopify Admin API, a DB URL, etc.).
25
25
  - Top two levels of the directory tree → Project Structure block.
@@ -70,7 +70,7 @@ These apply to every file touched in this project.
70
70
 
71
71
  ## Conventions
72
72
 
73
- [Insert the matching preset block below for the detected type. For Custom, write 46 stack-specific rules, one per line, imperative voice.]
73
+ [Insert the matching preset block below for the detected type. For Custom, write 4-6 stack-specific rules, one per line, imperative voice.]
74
74
 
75
75
  ---
76
76