sshya 0.1.0 → 0.1.2

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
@@ -1,17 +1,21 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env bun
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 { connectInteractive, printConnectionsPrompt } from './src/helpers/connection';
8
9
  import { enableEscapeExit } from './src/helpers/escExit';
10
+ import { printFzfInstructions } from './src/helpers/fzf';
9
11
  import { handleSshCommand } from './src/sshCommandHandler';
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();
@@ -25,6 +29,11 @@ program
25
29
  .description('Connect to an SSH connection by alias')
26
30
  .action(connectInteractive);
27
31
 
32
+ program
33
+ .command('print [alias]')
34
+ .description('Print an SSH connection by alias')
35
+ .action(printConnectionsPrompt);
36
+
28
37
  program
29
38
  .command('add')
30
39
  .aliases(['create', 'new'])
@@ -35,7 +44,9 @@ program
35
44
  .command('list')
36
45
  .aliases(['ps', 'ls'])
37
46
  .description('List all SSH connections')
38
- .action(listConnectionsPrompt);
47
+ .option('--oneline', 'Output each connection as ssh args on one line')
48
+ .option('--names', 'Include alias as first column (tab-separated) with --oneline')
49
+ .action((options: { oneline?: boolean; names?: boolean }) => listConnectionsPrompt({ oneline: !!options?.oneline, names: !!options?.names }));
39
50
 
40
51
  program
41
52
  .command('remove [alias]')
@@ -64,6 +75,14 @@ program
64
75
  .description('Export connections to a JSON file (default: sshm-export.json)')
65
76
  .action(exportConnectionsPrompt);
66
77
 
78
+ program
79
+ .command('fzf')
80
+ .aliases(['instructions', 'install'])
81
+ .description('Show instructions to enable fzf-based connection launcher')
82
+ .action(() => {
83
+ printFzfInstructions();
84
+ });
85
+
67
86
  program
68
87
  .command('path')
69
88
  .description('Provides instructions to make the CLI globally accessible')
@@ -92,20 +111,39 @@ program
92
111
  process.exit(0);
93
112
  });
94
113
 
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;
114
+ // Helpers exported for testing primary dispatch logic
115
+ export function normalizeUserArgs(rawArgv: string[]): string[] {
116
+ const looksLikeBunShim = rawArgv[0] === 'bun' && rawArgv[1] === 'run';
117
+ return looksLikeBunShim ? rawArgv.slice(3) : rawArgv;
118
+ }
99
119
 
100
- const isSshCommand = userArgs.length > 0 && (userArgs[0] === 'ssh' || userArgs.some(arg => arg.includes('@')));
120
+ export function determineEntryAction(userArgs: string[]): 'ssh' | 'interactive' | 'cli' {
121
+ const isSsh = userArgs.length > 0 && (userArgs[0] === 'ssh' || userArgs.some(arg => arg.includes('@')));
122
+ if (isSsh) return 'ssh';
123
+ if (userArgs.length === 0) return 'interactive';
124
+ return 'cli';
125
+ }
101
126
 
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 {
127
+ async function runFromProcess() {
128
+ // Clean Bun compile shim arguments (e.g., "bun run index.ts") when running the compiled binary
129
+ const rawArgs = process.argv.slice(2);
130
+ const userArgs = normalizeUserArgs(rawArgs);
131
+ const action = determineEntryAction(userArgs);
132
+
133
+ if (action === 'ssh') {
134
+ await handleSshCommand(userArgs);
135
+ return;
136
+ }
137
+ if (action === 'interactive') {
138
+ await connectInteractive();
139
+ return;
140
+ }
108
141
  // Reconstruct argv array expected by commander: [node, script, ...userArgs]
109
142
  const argvForCommander: string[] = [process.argv[0] ?? '', process.argv[1] ?? '', ...userArgs];
110
143
  program.parse(argvForCommander);
111
144
  }
145
+
146
+ // Only execute when this module is the entrypoint (prevents side effects during tests)
147
+ if (import.meta.main) {
148
+ await runFromProcess();
149
+ }
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.0",
5
+ "version": "0.1.2",
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",
@@ -1,8 +1,32 @@
1
1
  import chalk from "chalk";
2
2
  import { getConnections } from "../database";
3
3
 
4
- export async function listConnectionsPrompt() {
4
+ export async function listConnectionsPrompt(options?: { oneline?: boolean; names?: boolean }) {
5
5
  const connections = getConnections();
6
+ if (options?.oneline) {
7
+ // Print each connection as ssh-ready args on a single line
8
+ for (const c of connections) {
9
+ const parts: string[] = [];
10
+ if (c.key_path) {
11
+ // Safely single-quote the key path for shells
12
+ const quotedKey = `'${String(c.key_path).replace(/'/g, "'\\''")}'`;
13
+ parts.push('-i', quotedKey);
14
+ }
15
+ if (c.port) {
16
+ parts.push('-p', String(c.port));
17
+ }
18
+ parts.push(`${c.user}@${c.host}`);
19
+ const args = parts.join(' ');
20
+ if (options?.names) {
21
+ // Tab-separated: alias<TAB>user@host<TAB>args for easy fzf display and parsing
22
+ console.log(`${c.alias}\t${c.user}@${c.host}\t${args}`);
23
+ } else {
24
+ console.log(args);
25
+ }
26
+ }
27
+ process.exit(0);
28
+ }
29
+
6
30
  if (connections.length === 0) {
7
31
  console.log(chalk.yellow('No connections found. Add one with "sshya add"'));
8
32
  return;
package/src/database.ts CHANGED
@@ -6,7 +6,7 @@ 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;
@@ -1,6 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { getConnectionByAlias } from "../database";
3
- import { runSSH } from "../ssh";
3
+ import { buildSSHArgs, runSSH } from "../ssh";
4
4
  import { selectAlias } from "./selectAlias";
5
5
 
6
6
  /**
@@ -12,8 +12,20 @@ export async function connectInteractive(alias?: string) {
12
12
  }
13
13
  const connection = getConnectionByAlias(alias);
14
14
  if (connection) {
15
- const { user, host, key_path, port } = connection;
16
- runSSH(alias, user, host, key_path, port);
15
+ runSSH(alias, connection);
16
+ } else {
17
+ console.error(chalk.red('Alias not found'));
18
+ }
19
+ }
20
+
21
+ export async function printConnectionsPrompt(alias?: string) {
22
+ if (!alias) {
23
+ alias = await selectAlias('Select a connection');
24
+ }
25
+ const connection = getConnectionByAlias(alias);
26
+ if (connection) {
27
+ let args = buildSSHArgs(connection);
28
+ console.log(args.join(' '));
17
29
  } else {
18
30
  console.error(chalk.red('Alias not found'));
19
31
  }
@@ -0,0 +1,47 @@
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
+ " echo \"Connecting: $alias ($userhost)\"",
23
+ " # Ensure ZLE releases the terminal before starting interactive ssh",
24
+ " if [ -n \"$ZSH_VERSION\" ]; then zle -I; fi",
25
+ " command=$(run_sshya print \"$alias\")",
26
+ " if [ -n \"$ZSH_VERSION\" ]; then",
27
+ " ssh ${(z)command}",
28
+ " zle -R -c",
29
+ " else",
30
+ " read -r -a __args <<< \"$command\"",
31
+ " ssh \"${__args[@]}\"",
32
+ " fi",
33
+ " stty \"$oldstty\" 2>/dev/null || true",
34
+ '}',
35
+ '# zsh widget binding (recommended):',
36
+ 'zle -N fzf_sshya',
37
+ "bindkey '^S' fzf_sshya",
38
+ ].join('\n');
39
+ console.log(chalk.cyan(snippet));
40
+ console.log('\n' + chalk.green('Notes:'));
41
+ console.log('- This expects ' + chalk.bold('fzf') + ' to be installed.');
42
+ console.log('- On bash, you can bind with: ' + chalk.cyan("bind -x '" + '\\"\\u0013\\":fzf_sshya' + "'"));
43
+ console.log('- If Ctrl-S is flow control, disable with: ' + chalk.cyan('stty -ixon'));
44
+ process.exit(0);
45
+ }
46
+
47
+
package/src/ssh.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ChildProcess, spawn } from 'child_process';
2
+ import type { Connection } from './database';
2
3
  import { recordConnectionUsage } from './database';
3
4
 
4
5
  // Track the currently running SSH child-process so other parts of the program
@@ -9,23 +10,17 @@ export function isSshSessionActive(): boolean {
9
10
  return currentSshProcess !== null;
10
11
  }
11
12
 
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}`);
13
+ export function buildSSHArgs(connection: Connection) {
14
+ const args = [];
15
+ if (connection.key_path) args.push('-i', connection.key_path);
16
+ if (connection.port) args.push('-p', connection.port);
17
+ args.push(`${connection.user}@${connection.host}`);
23
18
  return args;
24
19
  }
25
20
 
26
- export function runSSH(alias: string, user: string, host: string, key_path?: string, port?: string) {
21
+ export function runSSH(alias: string, connection: Connection) {
27
22
  recordConnectionUsage(alias);
28
- const args = buildSSHArgs(user, host, key_path, port);
23
+ const args = buildSSHArgs(connection);
29
24
 
30
25
  const sshProcess = spawn('ssh', args, {
31
26
  stdio: 'pipe', // Full control over stdio