@firenet-designs/fnd-cli 2.0.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.
@@ -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,35 @@
1
+ /**
2
+ * Shared scaffolding pieces for `create-project` and `backfill-project`.
3
+ *
4
+ * SECURITY: nothing in this module (or the commands that use it) may ever
5
+ * contain, read into a string, or print a credential. GitHub auth comes from
6
+ * the user's own environment (GH_TOKEN / GITHUB_TOKEN exported in their shell,
7
+ * or `gh auth login`) — we only check that SOME auth exists, never the value.
8
+ */
9
+ export declare const GITIGNORE_CONTENT = ".idea/\nnode_modules/\nconfig.yml\n";
10
+ export declare const SHOPIFYIGNORE_CONTENT = "src/\nscripts/\nsrc\nscripts\nconfig.yml\n";
11
+ /**
12
+ * Prompt that drives CLAUDE.md generation. Ships with this package in
13
+ * prompts/ (resolved relative to this module: dist/lib -> <pkg root>/prompts).
14
+ * Set FND_INIT_PROMPT_PATH to test a local prompt without republishing.
15
+ */
16
+ export declare const initPromptPath: () => string;
17
+ /**
18
+ * Runs a command with stdio inherited so the user sees live output and can
19
+ * answer interactive prompts (shopify auth, claude, gh, git/ssh).
20
+ */
21
+ export declare const run: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<number>;
22
+ /** Resolves a binary on PATH, or null if not found. */
23
+ export declare const which: (bin: string) => null | string;
24
+ /**
25
+ * Finds the shopify CLI: PATH first, then nvm node versions (newest wins) —
26
+ * shopify is often installed per nvm node version that isn't currently active.
27
+ */
28
+ export declare const findShopifyBin: () => null | string;
29
+ /**
30
+ * Builds the CLAUDE.md generation prompt and runs `claude -p` with it.
31
+ * Returns 'claude-not-installed' when the claude CLI isn't on PATH,
32
+ * 'missing-prompt' when the bundled prompt file is gone (broken install),
33
+ * otherwise 'written'/'not-written' depending on whether CLAUDE.md appeared.
34
+ */
35
+ export declare const generateClaudeMd: (shop: string | undefined, extraHints: string) => Promise<"claude-not-installed" | "missing-prompt" | "not-written" | "written">;
@@ -0,0 +1,101 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ /**
7
+ * Shared scaffolding pieces for `create-project` and `backfill-project`.
8
+ *
9
+ * SECURITY: nothing in this module (or the commands that use it) may ever
10
+ * contain, read into a string, or print a credential. GitHub auth comes from
11
+ * the user's own environment (GH_TOKEN / GITHUB_TOKEN exported in their shell,
12
+ * or `gh auth login`) — we only check that SOME auth exists, never the value.
13
+ */
14
+ export const GITIGNORE_CONTENT = `.idea/
15
+ node_modules/
16
+ config.yml
17
+ `;
18
+ export const SHOPIFYIGNORE_CONTENT = `src/
19
+ scripts/
20
+ src
21
+ scripts
22
+ config.yml
23
+ `;
24
+ /**
25
+ * Prompt that drives CLAUDE.md generation. Ships with this package in
26
+ * prompts/ (resolved relative to this module: dist/lib -> <pkg root>/prompts).
27
+ * Set FND_INIT_PROMPT_PATH to test a local prompt without republishing.
28
+ */
29
+ export const initPromptPath = () => {
30
+ const override = process.env.FND_INIT_PROMPT_PATH;
31
+ if (override && existsSync(override))
32
+ return override;
33
+ return fileURLToPath(new URL('../../prompts/init-project-auto.md', import.meta.url));
34
+ };
35
+ /**
36
+ * Runs a command with stdio inherited so the user sees live output and can
37
+ * answer interactive prompts (shopify auth, claude, gh, git/ssh).
38
+ */
39
+ export const run = (command, args, env) => new Promise((resolve, reject) => {
40
+ const child = spawn(command, args, { env: env ?? process.env, stdio: 'inherit' });
41
+ child.on('error', reject);
42
+ child.on('close', (code) => resolve(code ?? 1));
43
+ });
44
+ /** Resolves a binary on PATH, or null if not found. */
45
+ export const which = (bin) => {
46
+ const result = spawnSync('which', [bin], { encoding: 'utf8' });
47
+ return result.status === 0 ? result.stdout.trim() : null;
48
+ };
49
+ const parseNodeVersion = (v) => v.replace(/^v/, '').split('.').map(Number);
50
+ /** `sort -V` equivalent for nvm dir names like `v24.13.1`. */
51
+ const compareNodeVersions = (a, b) => {
52
+ const [aParts, bParts] = [parseNodeVersion(a), parseNodeVersion(b)];
53
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
54
+ const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0);
55
+ if (diff !== 0)
56
+ return diff;
57
+ }
58
+ return 0;
59
+ };
60
+ /**
61
+ * Finds the shopify CLI: PATH first, then nvm node versions (newest wins) —
62
+ * shopify is often installed per nvm node version that isn't currently active.
63
+ */
64
+ export const findShopifyBin = () => {
65
+ const onPath = which('shopify');
66
+ if (onPath)
67
+ return onPath;
68
+ const nodeVersionsDir = join(homedir(), '.nvm', 'versions', 'node');
69
+ if (!existsSync(nodeVersionsDir))
70
+ return null;
71
+ const versions = readdirSync(nodeVersionsDir)
72
+ .filter((v) => existsSync(join(nodeVersionsDir, v, 'bin', 'shopify')))
73
+ .sort(compareNodeVersions);
74
+ const newest = versions.at(-1);
75
+ return newest ? join(nodeVersionsDir, newest, 'bin', 'shopify') : null;
76
+ };
77
+ /**
78
+ * Builds the CLAUDE.md generation prompt and runs `claude -p` with it.
79
+ * Returns 'claude-not-installed' when the claude CLI isn't on PATH,
80
+ * 'missing-prompt' when the bundled prompt file is gone (broken install),
81
+ * otherwise 'written'/'not-written' depending on whether CLAUDE.md appeared.
82
+ */
83
+ export const generateClaudeMd = async (shop, extraHints) => {
84
+ if (!which('claude'))
85
+ return 'claude-not-installed';
86
+ const promptPath = initPromptPath();
87
+ if (!existsSync(promptPath))
88
+ return 'missing-prompt';
89
+ let prompt = readFileSync(promptPath, 'utf8');
90
+ let hints = extraHints;
91
+ if (shop)
92
+ hints = `Shopify store: ${shop}. ${hints}`;
93
+ if (hints.trim())
94
+ prompt += `\n\n## User hints (override inference)\n${hints}`;
95
+ await run('claude', [
96
+ '-p', prompt,
97
+ '--permission-mode', 'acceptEdits',
98
+ '--allowedTools', 'Read Glob Grep Bash Write Edit',
99
+ ]);
100
+ return existsSync(join(process.cwd(), 'CLAUDE.md')) ? 'written' : 'not-written';
101
+ };
@@ -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
+ };