sshya 0.1.1 → 0.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
@@ -29,3 +29,61 @@ The final solution involves:
29
29
  3. Piping `stdin`, `stdout`, and `stderr` between the main process and the child `ssh` process.
30
30
  4. Manually listening for `resize` events on `process.stdout` and forwarding them to the `ssh` child process by sending a `SIGWINCH` signal.
31
31
  5. Using specific `ssh` command-line options (`-tt`, `ServerAliveInterval`, etc.) to ensure a stable and responsive interactive session.
32
+
33
+ ## fzf-based launcher
34
+
35
+ You can enable a fast, keyboard-driven launcher for your saved connections using `fzf`.
36
+
37
+ ### Quick setup
38
+
39
+ 1. Ensure `fzf` is installed.
40
+ 2. Print the shell snippet:
41
+
42
+ ```bash
43
+ sshya fzf
44
+ # or the aliases
45
+ sshya instructions
46
+ sshya install
47
+ ```
48
+
49
+ 3. Copy the printed snippet into your `~/.zshrc` or `~/.bashrc` and reload your shell.
50
+
51
+ ### How it works
52
+
53
+ - The function displays your connections via `sshya list --oneline --names`, showing:
54
+ - alias, user@host (for display), and a third tab-separated column containing ssh-ready arguments.
55
+ - After you pick an entry with `fzf`, it runs `sshya print <alias>` to obtain a single-line command string and then invokes your system `ssh` client.
56
+ - Argument splitting is handled per-shell to avoid issues with flags and quoting:
57
+ - zsh: `ssh ${(z)command}`
58
+ - bash: `read -r -a __args <<< "$command"; ssh "${__args[@]}"`
59
+
60
+ This ensures flags like `-p 2222` and identities like `-i '/path/with spaces/key'` are passed correctly to `ssh`.
61
+
62
+ ### Why system ssh?
63
+
64
+ When launching from `fzf`, we intentionally use your host `ssh` binary instead of the built-in connect helper to avoid TTY and ZLE interaction problems.
65
+
66
+ ### ZLE/TTY handling (zsh)
67
+
68
+ The snippet detaches the zsh line editor before starting `ssh` and restores it after:
69
+
70
+ - Before `ssh`: `zle -I`
71
+ - After `ssh`: `zle -R -c`
72
+
73
+ It also temporarily disables XON/XOFF flow control during selection (enables Ctrl-S) and restores your previous `stty` settings afterward.
74
+
75
+ ### Keybinding
76
+
77
+ The snippet includes an example zsh binding:
78
+
79
+ ```bash
80
+ zle -N fzf_sshya
81
+ bindkey '^S' fzf_sshya
82
+ ```
83
+
84
+ For bash, see the printed notes (example: `bind -x` with Ctrl-S), and ensure flow control is disabled (`stty -ixon`).
85
+
86
+ ### Troubleshooting
87
+
88
+ - If typing doesn't work in the remote session, make sure you've reloaded your shell after updating the snippet (it uses `zle -I`, `zle -R -c`, and restores `stty`).
89
+ - If the port or key path is ignored, ensure you're on the updated snippet which splits args correctly for zsh/bash and make sure `sshya print <alias>` outputs the expected `-p`/`-i` flags.
package/index.ts CHANGED
@@ -2,16 +2,20 @@
2
2
 
3
3
  import chalk from 'chalk';
4
4
  import { Command } from 'commander';
5
+ import pkg from './package.json' assert { type: "json" };
5
6
  import { addConnectionPrompt, exportConnectionsPrompt, importConnectionsPrompt, listConnectionsPrompt, removeConnectionPrompt, testConnectionPrompt, updateConnectionPrompt } from './src/connection';
6
7
  import { initDB } from './src/database';
7
- import { connectInteractive } from './src/helpers/connectInteractive';
8
+ import { printConnectionsPrompt } from './src/helpers/connection';
8
9
  import { enableEscapeExit } from './src/helpers/escExit';
9
- import { handleSshCommand } from './src/sshCommandHandler';
10
+ import { printFzfInstructions } from './src/helpers/fzf';
11
+ // built-in SSH handler removed
10
12
 
11
- import pkg from './package.json' assert { type: "json" };
12
13
  const version = pkg?.version ?? '1.0.0';
13
14
 
14
- enableEscapeExit();
15
+ if (process.stdin.isTTY && process.stdout.isTTY) {
16
+ enableEscapeExit();
17
+ }
18
+
15
19
  initDB();
16
20
 
17
21
  const program = new Command();
@@ -20,10 +24,12 @@ program
20
24
  .version(version)
21
25
  .description('A simple CLI to manage your SSH connections');
22
26
 
27
+ // connect command removed – use system ssh via fzf snippet
28
+
23
29
  program
24
- .command('connect [alias]')
25
- .description('Connect to an SSH connection by alias')
26
- .action(connectInteractive);
30
+ .command('print [alias]')
31
+ .description('Print an SSH connection by alias')
32
+ .action(printConnectionsPrompt);
27
33
 
28
34
  program
29
35
  .command('add')
@@ -35,7 +41,9 @@ program
35
41
  .command('list')
36
42
  .aliases(['ps', 'ls'])
37
43
  .description('List all SSH connections')
38
- .action(listConnectionsPrompt);
44
+ .option('--oneline', 'Output each connection as ssh args on one line')
45
+ .option('--names', 'Include alias as first column (tab-separated) with --oneline')
46
+ .action((options: { oneline?: boolean; names?: boolean }) => listConnectionsPrompt({ oneline: !!options?.oneline, names: !!options?.names }));
39
47
 
40
48
  program
41
49
  .command('remove [alias]')
@@ -64,6 +72,14 @@ program
64
72
  .description('Export connections to a JSON file (default: sshm-export.json)')
65
73
  .action(exportConnectionsPrompt);
66
74
 
75
+ program
76
+ .command('fzf')
77
+ .aliases(['instructions', 'install'])
78
+ .description('Show instructions to enable fzf-based connection launcher')
79
+ .action(() => {
80
+ printFzfInstructions();
81
+ });
82
+
67
83
  program
68
84
  .command('path')
69
85
  .description('Provides instructions to make the CLI globally accessible')
@@ -92,20 +108,23 @@ program
92
108
  process.exit(0);
93
109
  });
94
110
 
95
- // Clean Bun compile shim arguments (e.g., "bun run index.ts") when running the compiled binary
96
- const rawArgs = process.argv.slice(2);
97
- const looksLikeBunShim = rawArgs[0] === 'bun' && rawArgs[1] === 'run';
98
- const userArgs = looksLikeBunShim ? rawArgs.slice(3) : rawArgs;
111
+ // Helpers exported for testing primary dispatch logic
112
+ export function normalizeUserArgs(rawArgv: string[]): string[] {
113
+ const looksLikeBunShim = rawArgv[0] === 'bun' && rawArgv[1] === 'run';
114
+ return looksLikeBunShim ? rawArgv.slice(3) : rawArgv;
115
+ }
99
116
 
100
- const isSshCommand = userArgs.length > 0 && (userArgs[0] === 'ssh' || userArgs.some(arg => arg.includes('@')));
101
117
 
102
- if (isSshCommand) {
103
- await handleSshCommand(userArgs);
104
- } else if (userArgs.length === 0) {
105
- // No user-specified subcommand -> interactive connect
106
- await connectInteractive();
107
- } else {
118
+ async function runFromProcess() {
119
+ // Clean Bun compile shim arguments (e.g., "bun run index.ts") when running the compiled binary
120
+ const rawArgs = process.argv.slice(2);
121
+ const userArgs = normalizeUserArgs(rawArgs);
108
122
  // Reconstruct argv array expected by commander: [node, script, ...userArgs]
109
123
  const argvForCommander: string[] = [process.argv[0] ?? '', process.argv[1] ?? '', ...userArgs];
110
124
  program.parse(argvForCommander);
111
125
  }
126
+
127
+ // Only execute when this module is the entrypoint (prevents side effects during tests)
128
+ if (import.meta.main) {
129
+ await runFromProcess();
130
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "sshya",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.1.1",
5
+ "version": "0.2.0",
6
6
  "devDependencies": {
7
7
  "@types/bun": "latest",
8
8
  "@types/chalk": "^2.2.4"
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "scripts": {
14
14
  "start": "bun run index.ts",
15
- "build": "bun build index.ts --compile --outfile bin/s"
15
+ "build": "bun build index.ts --compile --outfile bin/sshya"
16
16
  },
17
17
  "dependencies": {
18
18
  "@inquirer/search": "^3.0.17",
@@ -31,6 +31,11 @@ export async function addConnectionPrompt() {
31
31
  name: 'key_path',
32
32
  message: 'Path to key (optional):',
33
33
  },
34
+ {
35
+ type: 'input',
36
+ name: 'remote_path',
37
+ message: 'Remote working directory (optional):',
38
+ },
34
39
  ]);
35
40
 
36
41
  const parsed = connectionSchema.safeParse(answers);
@@ -38,10 +43,10 @@ export async function addConnectionPrompt() {
38
43
  console.error(chalk.red('Invalid input:'), parsed.error.errors.map(e => e.message).join(', '));
39
44
  return;
40
45
  }
41
- const { alias, user, host, key_path, port } = parsed.data;
46
+ const { alias, user, host, key_path, port, remote_path } = parsed.data;
42
47
 
43
48
  try {
44
- addConnection(alias, user, host, key_path, port ? String(port) : undefined);
49
+ addConnection(alias, user, host, key_path, port ? String(port) : undefined, remote_path);
45
50
  console.log(chalk.green('Connection added successfully'));
46
51
 
47
52
  const { test } = await inquirer.prompt([
@@ -50,11 +50,25 @@ export const importConnectionsPrompt = async (file: string) => {
50
50
  }
51
51
 
52
52
  if (action === 'Overwrite') {
53
- updateConnection(parsedConn.alias, parsedConn.user, parsedConn.host, parsedConn.key_path ?? '', parsedConn.port ? String(parsedConn.port) : undefined);
53
+ updateConnection(
54
+ parsedConn.alias,
55
+ parsedConn.user,
56
+ parsedConn.host,
57
+ parsedConn.key_path ?? '',
58
+ parsedConn.port ? String(parsedConn.port) : undefined,
59
+ parsedConn.remote_path,
60
+ );
54
61
  updatedCount++;
55
62
  }
56
63
  } else {
57
- addConnection(parsedConn.alias, parsedConn.user, parsedConn.host, parsedConn.key_path, parsedConn.port ? String(parsedConn.port) : undefined);
64
+ addConnection(
65
+ parsedConn.alias,
66
+ parsedConn.user,
67
+ parsedConn.host,
68
+ parsedConn.key_path,
69
+ parsedConn.port ? String(parsedConn.port) : undefined,
70
+ parsedConn.remote_path,
71
+ );
58
72
  importedCount++;
59
73
  }
60
74
  }
@@ -1,8 +1,43 @@
1
1
  import chalk from "chalk";
2
- import { getConnections } from "../database";
2
+ import { expandHomePath, getConnections } from "../database";
3
+ import { buildRemoteCommand } from "../helpers/shell";
3
4
 
4
- export async function listConnectionsPrompt() {
5
+ export async function listConnectionsPrompt(options?: { oneline?: boolean; names?: boolean }) {
5
6
  const connections = getConnections();
7
+ if (options?.oneline) {
8
+ // Print each connection as ssh-ready args on a single line
9
+ for (const c of connections) {
10
+ const parts: string[] = [];
11
+ if (c.key_path) {
12
+ const expandedKeyPath = expandHomePath(String(c.key_path));
13
+ // Safely single-quote the key path for shells
14
+ const quotedKey = `'${String(expandedKeyPath).replace(/'/g, "'\\''")}'`;
15
+ parts.push('-i', quotedKey);
16
+ }
17
+ if (c.port) {
18
+ parts.push('-p', String(c.port));
19
+ }
20
+ parts.push(`${c.user}@${c.host}`);
21
+ if (c.remote_path) {
22
+ const remoteCommand = buildRemoteCommand({
23
+ remotePath: String(c.remote_path),
24
+ postCommand: 'exec "$SHELL" -l',
25
+ });
26
+ if (remoteCommand) {
27
+ parts.push('-t', remoteCommand);
28
+ }
29
+ }
30
+ const args = parts.join(' ');
31
+ if (options?.names) {
32
+ // Tab-separated: alias<TAB>user@host<TAB>args for easy fzf display and parsing
33
+ console.log(`${c.alias}\t${c.user}@${c.host}\t${args}`);
34
+ } else {
35
+ console.log(args);
36
+ }
37
+ }
38
+ process.exit(0);
39
+ }
40
+
6
41
  if (connections.length === 0) {
7
42
  console.log(chalk.yellow('No connections found. Add one with "sshya add"'));
8
43
  return;
@@ -1,7 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import { spawn } from 'child_process';
3
- import { getConnectionByAlias } from '../database';
3
+ import { expandHomePath, getConnectionByAlias } from '../database';
4
4
  import { selectAlias } from '../helpers/selectAlias';
5
+ import { buildRemoteCommand } from '../helpers/shell';
5
6
 
6
7
  export async function testConnectionPrompt(alias?: string): Promise<void> {
7
8
 
@@ -15,14 +16,25 @@ export async function testConnectionPrompt(alias?: string): Promise<void> {
15
16
  return;
16
17
  }
17
18
 
18
- const { user, host, key_path, port } = connection;
19
+ const { user, host, key_path, port, remote_path } = connection;
19
20
  console.log(`\nTesting connection to ${chalk.blue(alias)}...`);
20
21
 
21
22
  const testPromise = new Promise<{ code: number | null; stderr: string }>((resolve) => {
22
23
  const args = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5'];
23
- if (key_path) args.push('-i', key_path);
24
+ if (key_path) args.push('-i', expandHomePath(key_path) ?? key_path);
24
25
  if (port) args.push('-p', String(port));
25
- args.push(`${user}@${host}`, 'exit');
26
+ args.push(`${user}@${host}`);
27
+ if (remote_path) {
28
+ const remoteCommand = buildRemoteCommand({
29
+ remotePath: remote_path,
30
+ postCommand: 'exit',
31
+ });
32
+ if (remoteCommand) {
33
+ args.push('-t', remoteCommand);
34
+ }
35
+ } else {
36
+ args.push('exit');
37
+ }
26
38
 
27
39
  const child = spawn('ssh', args, { stdio: ['ignore', 'ignore', 'pipe'] });
28
40
  let stderr = '';
@@ -37,8 +37,14 @@ export async function updateConnectionPrompt(alias?: string) {
37
37
  message: 'Path to key (optional):',
38
38
  default: connection.key_path,
39
39
  },
40
+ {
41
+ type: 'input',
42
+ name: 'remote_path',
43
+ message: 'Remote working directory (optional):',
44
+ default: connection.remote_path,
45
+ },
40
46
  ]);
41
- updateConnection(alias, answers.user, answers.host, answers.key_path, answers.port);
47
+ updateConnection(alias, answers.user, answers.host, answers.key_path, answers.port, answers.remote_path);
42
48
  console.log(chalk.green('Connection updated successfully'));
43
49
 
44
50
  const { test } = await inquirer.prompt([
package/src/database.ts CHANGED
@@ -6,13 +6,14 @@ import { z } from 'zod';
6
6
  const dbDir = path.join(os.homedir(), '.sshya');
7
7
  const dbFilePath = path.join(dbDir, 'sshm.json');
8
8
 
9
- interface Connection {
9
+ export interface Connection {
10
10
  id: number;
11
11
  alias: string;
12
12
  user: string;
13
13
  host: string;
14
14
  key_path?: string;
15
15
  port?: string;
16
+ remote_path?: string;
16
17
  lastUsed?: number;
17
18
  }
18
19
 
@@ -22,14 +23,53 @@ export const connectionSchema = z.object({
22
23
  host: z.string().min(1),
23
24
  key_path: z.string().optional(),
24
25
  port: z.union([z.string(), z.number()]).optional(),
26
+ remote_path: z.string().optional(),
25
27
  });
26
28
 
29
+ const normalizeOptionalString = (value?: string | number | null): string | undefined => {
30
+ if (value === undefined || value === null) {
31
+ return undefined;
32
+ }
33
+ const trimmed = String(value).trim();
34
+ return trimmed.length > 0 ? trimmed : undefined;
35
+ };
36
+
37
+ export const expandHomePath = (input?: string): string | undefined => {
38
+ if (!input) {
39
+ return undefined;
40
+ }
41
+ if (input === '~') {
42
+ return os.homedir();
43
+ }
44
+ if (input.startsWith('~/')) {
45
+ return path.join(os.homedir(), input.slice(2));
46
+ }
47
+ return input;
48
+ };
49
+
50
+ const normalizeConnection = (connection: Connection): Connection => {
51
+ const normalizedKeyPath = expandHomePath(normalizeOptionalString(connection.key_path));
52
+ const normalizedPort = normalizeOptionalString(connection.port);
53
+ const normalizedRemotePath = normalizeOptionalString(connection.remote_path);
54
+
55
+ return {
56
+ ...connection,
57
+ alias: connection.alias.trim(),
58
+ user: connection.user.trim(),
59
+ host: connection.host.trim(),
60
+ key_path: normalizedKeyPath,
61
+ port: normalizedPort,
62
+ remote_path: normalizedRemotePath,
63
+ };
64
+ };
65
+
27
66
  const readDB = (): Connection[] => {
28
67
  if (!fs.existsSync(dbFilePath)) {
29
68
  return [];
30
69
  }
31
70
  const data = fs.readFileSync(dbFilePath, 'utf-8');
32
- return JSON.parse(data);
71
+ const parsed: Connection[] = JSON.parse(data);
72
+ return parsed.map(normalizeConnection);
33
73
  };
34
74
 
35
75
  const writeDB = (data: Connection[]) => {
@@ -49,21 +89,42 @@ export const initDB = () => {
49
89
  // Initialize empty database file if it does not exist
50
90
  if (!fs.existsSync(dbFilePath)) {
51
91
  writeDB([]);
92
+ return;
52
93
  }
94
+
95
+ // Normalize any existing data on disk once during init
96
+ const existingData = readDB();
97
+ writeDB(existingData);
53
98
  };
54
99
 
55
- export const addConnection = (alias: string, user: string, host: string, key_path?: string, port?: string) => {
100
+ export const addConnection = (
101
+ alias: string,
102
+ user: string,
103
+ host: string,
104
+ key_path?: string,
105
+ port?: string,
106
+ remote_path?: string,
107
+ ) => {
56
108
  const connections = readDB();
57
- if (connections.some(c => c.alias === alias)) {
58
- throw new Error(`Connection with alias "${alias}" already exists.`);
109
+ const normalizedAlias = alias.trim();
110
+ if (connections.some(c => c.alias === normalizedAlias)) {
111
+ throw new Error(`Connection with alias "${normalizedAlias}" already exists.`);
59
112
  }
113
+
114
+ const normalizedUser = user.trim();
115
+ const normalizedHost = host.trim();
116
+ const normalizedKeyPath = expandHomePath(normalizeOptionalString(key_path));
117
+ const normalizedPort = normalizeOptionalString(port);
118
+ const normalizedRemotePath = normalizeOptionalString(remote_path);
119
+
60
120
  const newConnection: Connection = {
61
121
  id: connections.length > 0 ? Math.max(...connections.map(c => c.id)) + 1 : 1,
62
- alias,
63
- user,
64
- host,
65
- key_path,
66
- port,
122
+ alias: normalizedAlias,
123
+ user: normalizedUser,
124
+ host: normalizedHost,
125
+ key_path: normalizedKeyPath,
126
+ port: normalizedPort,
127
+ remote_path: normalizedRemotePath,
67
128
  };
68
129
  connections.push(newConnection);
69
130
  writeDB(connections);
@@ -84,14 +145,22 @@ export const removeConnection = (alias: string) => {
84
145
  writeDB(newConnections);
85
146
  };
86
147
 
87
- export const updateConnection = (alias: string, newUser: string, newHost: string, newKeyPath: string, newPort?: string) => {
148
+ export const updateConnection = (
149
+ alias: string,
150
+ newUser: string,
151
+ newHost: string,
152
+ newKeyPath?: string,
153
+ newPort?: string,
154
+ newRemotePath?: string,
155
+ ) => {
88
156
  const connections = readDB();
89
157
  const connection = connections.find(c => c.alias === alias);
90
158
  if (connection) {
91
- connection.user = newUser;
92
- connection.host = newHost;
93
- connection.key_path = newKeyPath;
94
- connection.port = newPort;
159
+ connection.user = newUser.trim();
160
+ connection.host = newHost.trim();
161
+ connection.key_path = expandHomePath(normalizeOptionalString(newKeyPath));
162
+ connection.port = normalizeOptionalString(newPort);
163
+ connection.remote_path = normalizeOptionalString(newRemotePath);
95
164
  writeDB(connections);
96
165
  }
97
166
  };
@@ -0,0 +1,35 @@
1
+ import chalk from "chalk";
2
+ import { expandHomePath, getConnectionByAlias } from "../database";
3
+ import { buildRemoteCommand } from "./shell";
4
+
5
+ export async function printConnectionsPrompt(alias?: string) {
6
+ if (!alias) {
7
+ console.error(chalk.red('Alias is required'));
8
+ process.exit(1);
9
+ }
10
+ const connection = getConnectionByAlias(alias);
11
+ if (!connection) {
12
+ console.error(chalk.red('Alias not found'));
13
+ process.exit(1);
14
+ }
15
+ const parts: string[] = [];
16
+ if (connection.key_path) {
17
+ const expandedKeyPath = expandHomePath(String(connection.key_path));
18
+ const quotedKey = `'${String(expandedKeyPath).replace(/'/g, "'\\''")}'`;
19
+ parts.push('-i', quotedKey);
20
+ }
21
+ if (connection.port) {
22
+ parts.push('-p', String(connection.port));
23
+ }
24
+ parts.push(`${connection.user}@${connection.host}`);
25
+ if (connection.remote_path) {
26
+ const remoteCommand = buildRemoteCommand({
27
+ remotePath: String(connection.remote_path),
28
+ postCommand: 'exec "$SHELL" -l',
29
+ });
30
+ if (remoteCommand) {
31
+ parts.push('-t', remoteCommand);
32
+ }
33
+ }
34
+ console.log(parts.join(' '));
35
+ }
@@ -1,5 +1,4 @@
1
1
  import readline from 'readline';
2
- import { isSshSessionActive } from '../ssh';
3
2
 
4
3
  /**
5
4
  * Enable global Escape key handler that terminates the CLI gracefully.
@@ -16,12 +15,7 @@ export function enableEscapeExit(): () => void {
16
15
  }
17
16
 
18
17
  const onKeypress = (_: string, key: readline.Key) => {
19
- // Handle ESC key – ignored while an SSH session is active so that
20
- // pressing Escape inside the remote shell works as expected.
21
18
  if (key.name === 'escape') {
22
- if (isSshSessionActive()) {
23
- return; // let the remote shell receive the key
24
- }
25
19
  console.log('\nCancelled by user.');
26
20
  process.exit(0);
27
21
  }
@@ -32,9 +26,6 @@ export function enableEscapeExit(): () => void {
32
26
  // their usual shortcut inside the SSH client, so we don’t force-exit
33
27
  // the parent app in that case.
34
28
  if (key.ctrl && key.name === 'c') {
35
- if (isSshSessionActive()) {
36
- return; // let ssh handle the interrupt
37
- }
38
29
  console.log('\nCancelled by user.');
39
30
  process.exit(0);
40
31
  }
@@ -0,0 +1,57 @@
1
+ import chalk from 'chalk';
2
+
3
+ export function printFzfInstructions() {
4
+ console.log(chalk.yellow('To enable a quick fzf-powered SSH launcher, add this to your shell rc:'));
5
+ console.log('\n' + chalk.bold('~/.bashrc or ~/.zshrc') + '\n');
6
+ const snippet = [
7
+ '_fzf_sshya() {',
8
+ ' local line alias userhost command oldstty',
9
+ ' # Temporarily disable XON/XOFF so Ctrl-S works, then restore',
10
+ ' oldstty=$(stty -g 2>/dev/null)',
11
+ ' stty -ixon 2>/dev/null || true',
12
+ ' run_sshya() {',
13
+ ' if command -v sshya >/dev/null 2>&1; then',
14
+ ' stdbuf -oL sshya "$@"',
15
+ ' else',
16
+ ' stdbuf -oL bun --silent run start "$@"',
17
+ ' fi',
18
+ ' }',
19
+ " line=$(run_sshya list --oneline --names | fzf --tac --with-nth=1,2 --delimiter=$'\\t') || { stty \"$oldstty\" 2>/dev/null || true; return; }",
20
+ " alias=${line%%$'\\t'*}",
21
+ " userhost=${line#*$'\\t'}; userhost=${userhost%%$'\\t'*}",
22
+ " command=$(run_sshya print \"$alias\")",
23
+ " echo \"Connecting: $alias ($userhost)\"",
24
+ " # Ensure ZLE releases the terminal before starting interactive ssh",
25
+ " if [ -n \"$ZSH_VERSION\" ]; then",
26
+ " local -a _args",
27
+ " local remote_cmd=\"\"",
28
+ " # Check if command contains -t for remote command",
29
+ " if [[ \"$command\" == *' -t '* ]]; then",
30
+ " # Split at -t, parse only SSH args, keep remote command as-is",
31
+ " _args=(${(zQ)command%% -t *})",
32
+ " remote_cmd=\"${command#* -t }\"",
33
+ " ssh \"${_args[@]}\" -t \"$remote_cmd\"",
34
+ " else",
35
+ " _args=(${(zQ)command})",
36
+ " ssh \"${_args[@]}\"",
37
+ " fi",
38
+ " zle -R -c",
39
+ " else",
40
+ " read -r -a __args <<< \"$command\"",
41
+ " ssh \"${__args[@]}\"",
42
+ " fi",
43
+ " stty \"$oldstty\" 2>/dev/null || true",
44
+ '}',
45
+ '# zsh widget binding (recommended):',
46
+ 'zle -N _fzf_sshya',
47
+ "bindkey '^S' _fzf_sshya",
48
+ ].join('\n');
49
+ console.log(chalk.cyan(snippet));
50
+ console.log('\n' + chalk.green('Notes:'));
51
+ console.log('- This expects ' + chalk.bold('fzf') + ' to be installed.');
52
+ console.log('- On bash, you can bind with: ' + chalk.cyan("bind -x '" + '\\"\\u0013\\":_fzf_sshya' + "'"));
53
+ console.log('- If Ctrl-S is flow control, disable with: ' + chalk.cyan('stty -ixon'));
54
+ process.exit(0);
55
+ }
56
+
57
+
@@ -0,0 +1,26 @@
1
+ export const escapeSingleQuotes = (value: string): string => value.replace(/'/g, "'\\''");
2
+
3
+ export const wrapInSingleQuotes = (value: string): string => `'${escapeSingleQuotes(value)}'`;
4
+
5
+ interface RemoteCommandOptions {
6
+ remotePath?: string;
7
+ postCommand?: string;
8
+ }
9
+
10
+ export const buildRemoteCommand = ({
11
+ remotePath,
12
+ postCommand,
13
+ }: RemoteCommandOptions): string | undefined => {
14
+ const commands: string[] = [];
15
+ if (typeof remotePath === 'string' && remotePath.trim().length > 0) {
16
+ commands.push(`cd ${wrapInSingleQuotes(remotePath.trim())}`);
17
+ }
18
+ if (postCommand && postCommand.trim().length > 0) {
19
+ commands.push(postCommand.trim());
20
+ }
21
+ if (commands.length === 0) {
22
+ return undefined;
23
+ }
24
+ return commands.join(' && ');
25
+ };
26
+
package/src/ssh.ts CHANGED
@@ -1,76 +1,23 @@
1
- import { ChildProcess, spawn } from 'child_process';
2
- import { recordConnectionUsage } from './database';
3
-
4
- // Track the currently running SSH child-process so other parts of the program
5
- // (e.g. the global key-handler) can know whether we are inside an SSH session.
6
- let currentSshProcess: ChildProcess | null = null;
7
-
8
- export function isSshSessionActive(): boolean {
9
- return currentSshProcess !== null;
10
- }
11
-
12
- export function buildSSHArgs(user: string, host: string, key_path?: string, port?: string) {
13
- const args = [
14
- '-tt',
15
- '-o', 'NumberOfPasswordPrompts=1',
16
- '-o', 'ConnectTimeout=10',
17
- '-o', 'ServerAliveInterval=15',
18
- '-o', 'TCPKeepAlive=yes',
19
- ];
20
- if (key_path) args.push('-i', key_path);
21
- if (port) args.push('-p', port);
22
- args.push(`${user}@${host}`);
23
- return args;
24
- }
25
-
26
- export function runSSH(alias: string, user: string, host: string, key_path?: string, port?: string) {
27
- recordConnectionUsage(alias);
28
- const args = buildSSHArgs(user, host, key_path, port);
29
-
30
- const sshProcess = spawn('ssh', args, {
31
- stdio: 'pipe', // Full control over stdio
32
- });
33
-
34
- // Mark session active so that other modules can detect we are now inside an
35
- // SSH session.
36
- currentSshProcess = sshProcess;
37
-
38
- if (process.stdin.isTTY) {
39
- process.stdin.setRawMode(true);
1
+ import type { Connection } from './database';
2
+ import { expandHomePath } from './database';
3
+ import { buildRemoteCommand } from './helpers/shell';
4
+
5
+ export function buildSSHArgs(connection: Connection) {
6
+ const args: (string | number)[] = [];
7
+ if (connection.key_path) {
8
+ const expandedKeyPath = expandHomePath(connection.key_path);
9
+ args.push('-i', expandedKeyPath ?? connection.key_path);
40
10
  }
41
-
42
- process.stdin.pipe(sshProcess.stdin);
43
- sshProcess.stdout.pipe(process.stdout);
44
- sshProcess.stderr.pipe(process.stderr);
45
-
46
- const onResize = () => {
47
- if (sshProcess.pid) {
48
- try {
49
- process.kill(sshProcess.pid, 'SIGWINCH');
50
- } catch {
51
- /* Process already exited – ignore */
52
- }
53
- }
54
- };
55
-
56
- const cleanup = (code?: number | null) => {
57
- if (process.stdout.isTTY) {
58
- process.stdout.removeListener('resize', onResize);
11
+ if (connection.port) args.push('-p', connection.port);
12
+ args.push(`${connection.user}@${connection.host}`);
13
+ if (connection.remote_path) {
14
+ const remoteCommand = buildRemoteCommand({
15
+ remotePath: connection.remote_path,
16
+ postCommand: 'exec "$SHELL" -l',
17
+ });
18
+ if (remoteCommand) {
19
+ args.push('-t', remoteCommand);
59
20
  }
60
- if (process.stdin.isTTY) {
61
- process.stdin.setRawMode(false);
62
- }
63
- process.stdin.unpipe(sshProcess.stdin);
64
- // Mark session ended
65
- currentSshProcess = null;
66
- process.exit(code ?? 0);
67
- };
68
-
69
- if (process.stdout.isTTY) {
70
- process.stdout.on('resize', onResize);
71
- onResize();
72
21
  }
73
-
74
- sshProcess.on('exit', cleanup);
75
- sshProcess.on('error', () => cleanup(1));
76
- }
22
+ return args.map(String);
23
+ }
@@ -1,20 +0,0 @@
1
- import chalk from "chalk";
2
- import { getConnectionByAlias } from "../database";
3
- import { runSSH } from "../ssh";
4
- import { selectAlias } from "./selectAlias";
5
-
6
- /**
7
- * Helper to prompt user to select a connection and then connect
8
- */
9
- export async function connectInteractive(alias?: string) {
10
- if (!alias) {
11
- alias = await selectAlias('Select a connection');
12
- }
13
- const connection = getConnectionByAlias(alias);
14
- if (connection) {
15
- const { user, host, key_path, port } = connection;
16
- runSSH(alias, user, host, key_path, port);
17
- } else {
18
- console.error(chalk.red('Alias not found'));
19
- }
20
- }
@@ -1,93 +0,0 @@
1
- import chalk from 'chalk';
2
- import inquirer from 'inquirer';
3
- import { addConnection, getConnections } from './database';
4
- import { runSSH } from './ssh';
5
-
6
- export interface ParsedSSH {
7
- user: string;
8
- host: string;
9
- key_path?: string;
10
- port?: string;
11
- }
12
-
13
- export function parseSshCommand(args: string[]): ParsedSSH | null {
14
- if (args[0] === 'ssh') {
15
- args = args.slice(1);
16
- }
17
-
18
- let user = '';
19
- let host = '';
20
- let key_path: string | undefined;
21
- let port: string | undefined;
22
-
23
- const userHostArg = args.find(arg => arg.includes('@') && !arg.startsWith('-'));
24
- if (!userHostArg) return null;
25
-
26
- const parts = userHostArg.split('@');
27
- if (parts.length < 2) return null;
28
- user = parts[0] ?? '';
29
- host = parts[1] ?? '';
30
-
31
- for (let i = 0; i < args.length; i++) {
32
- const arg = args[i];
33
- if (!arg) continue;
34
-
35
- if (arg === '-i' && i + 1 < args.length) {
36
- key_path = args[i + 1]!;
37
- i++;
38
- // Support combined flag and value, e.g. -i/home/key.pem
39
- } else if (arg.startsWith('-i') && arg.length > 2) {
40
- key_path = arg.slice(2);
41
- } else if (arg === '-p' && i + 1 < args.length) {
42
- port = args[i + 1]!;
43
- i++;
44
- // Support combined flag and value, e.g. -p5572
45
- } else if (arg.startsWith('-p') && arg.length > 2) {
46
- port = arg.slice(2);
47
- }
48
- }
49
-
50
- return { user, host, key_path, port };
51
- }
52
-
53
- export async function handleSshCommand(args: string[]) {
54
- const connectionDetails = parseSshCommand(args);
55
- if (!connectionDetails) {
56
- console.error(chalk.red('Could not parse SSH command.'));
57
- console.log('Expected format: ssh -i <key> user@host -p <port>');
58
- return;
59
- }
60
-
61
- const { user, host, key_path, port } = connectionDetails;
62
-
63
- const connections = getConnections();
64
- const existingConnection = connections.find(c => {
65
- const dbPort = c.port ? String(c.port) : undefined;
66
- const dbKeyPath = c.key_path || undefined;
67
- return c.user === user && c.host === host && dbKeyPath === key_path && dbPort === port;
68
- });
69
-
70
- if (existingConnection) {
71
- console.log(chalk.green(`Found existing connection with alias "${existingConnection.alias}". Connecting...`));
72
- runSSH(existingConnection.alias, user, host, key_path, port);
73
- return;
74
- }
75
-
76
- console.log('This looks like a new connection.');
77
- const { alias } = await inquirer.prompt([
78
- {
79
- type: 'input',
80
- name: 'alias',
81
- message: 'Enter an alias to save this connection for future use:',
82
- validate: input => (input ? true : 'Alias cannot be empty.'),
83
- },
84
- ]);
85
-
86
- try {
87
- addConnection(alias, user, host, key_path, port ? String(port) : undefined);
88
- console.log(chalk.green(`Connection saved as "${alias}". Connecting...`));
89
- runSSH(alias, user, host, key_path, port);
90
- } catch (err: any) {
91
- console.error(chalk.red(err.message));
92
- }
93
- }