sshya 0.2.1 → 0.2.3

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/AGENTS.md CHANGED
@@ -4,23 +4,16 @@
4
4
 
5
5
  ## Core Features
6
6
  - **Connection Management**: Add, remove, update, list SSH connections with custom aliases
7
- - **Interactive Interface**: Uses inquirer.js for user-friendly prompts
8
- - **fzf Integration**: Fast keyboard-driven connection selection via shell snippets
9
7
  - **Import/Export**: JSON-based connection backup and restore
10
8
 
11
9
  ## Architecture
12
10
  - **Runtime**: Bun (JavaScript runtime)
13
- - **Language**: TypeScript with ESM modules
14
- - **Storage**: JSON file at `~/.sshya/sshm.json`
15
- - **Dependencies**: commander, inquirer, chalk, fuse.js, zod
11
+ - **Language**: TypeScript
16
12
 
17
13
  ## Key Components
18
14
 
19
15
  ### Database (`src/database.ts`)
20
16
  - `Connection` interface with id, alias, user, host, key_path, port, remote_path
21
- - Zod schema validation for data integrity
22
- - Home directory expansion for paths (`~` → `/home/user`)
23
- - CRUD operations with automatic normalization
24
17
 
25
18
  ### Commands (`index.ts`)
26
19
  - `add`/`remove`/`update`/`list`: Manage connections
@@ -28,29 +21,15 @@
28
21
  - `import`/`export`: JSON file operations
29
22
  - `fzf`/`instructions`: Shell integration setup
30
23
 
31
- ### Interactive Features
32
- - Fuzzy search for connection selection (fuse.js)
33
- - Real-time validation with Zod schemas
34
- - Colorized output (chalk)
35
- - Graceful error handling with process.exit codes
36
-
37
24
  ## Usage Patterns
38
25
  1. **Direct**: `sshya add` → Interactive prompt flow
39
26
  2. **fzf Integration**: Shell function with `sshya print` for fast selection
40
27
  3. **Scripting**: `sshya print my-server` → Outputs `ssh -p 2222 -t -i ~/.ssh/key user@host`
41
28
 
42
29
  ## Notable Implementation Details
43
- - Interactive prompt handling with raw mode TTY for user input
44
30
  - Shell-specific argument parsing (zsh vs bash) for fzf integration
45
- - Automatic path normalization and validation
46
31
  - Usage tracking via `lastUsed` timestamps
47
32
  - Always includes `-t` flag for proper pseudo-terminal allocation
48
- - Enhanced error handling and debugging for fzf integration issues
49
-
50
- ## Build & Installation
51
- ```bash
52
- bun run build # Creates bin/sshya binary
53
- sudo mv bin/sshya /usr/local/bin/s # Global access
54
- ```
33
+ - **High-performance optimizations**: ~0.2ms processing time for 8 connections with caching and pre-computation
55
34
 
56
35
  This tool prioritizes developer experience with fast, keyboard-driven workflows while maintaining robust error handling and cross-platform compatibility.
package/README.md CHANGED
@@ -3,10 +3,11 @@
3
3
  This project is a command-line interface (CLI) tool named `sshya` for managing SSH connections.
4
4
 
5
5
  ## Core Functionality
6
- - Add, remove, update, and list SSH connection configurations (alias, user, host, port, key path).
7
- - Generate SSH command strings for saved connections.
6
+ - Add, remove, update, and list SSH connection configurations (alias, user, host, port, key path, remote path).
7
+ - Connect to saved connections with `sshya connect` (aliases: `ssh`, `go`).
8
+ - Generate SSH command strings for scripting with `sshya print`.
8
9
  - Import and export connections from/to a JSON file.
9
- - A key feature is the fzf-based launcher that provides fast, keyboard-driven connection selection and execution using your system's SSH client.
10
+ - fzf-based launcher for fast, keyboard-driven connection selection.
10
11
 
11
12
  ## Technology Stack
12
13
  - **Runtime:** Bun
@@ -19,7 +20,7 @@ This project is a command-line interface (CLI) tool named `sshya` for managing S
19
20
  - `zod`: for validating user input and imported data.
20
21
 
21
22
  ## Implementation Details
22
- The main application logic is in `index.ts`. The tool generates SSH command strings that are executed by your system's SSH client, providing a clean separation between connection management and actual SSH execution.
23
+ The main application logic is in `index.ts`. SSH arguments are built in-process and passed directly to your system `ssh` binary, avoiding fragile shell string parsing.
23
24
 
24
25
  ## fzf-based launcher
25
26
 
@@ -41,40 +42,28 @@ You can enable a fast, keyboard-driven launcher for your saved connections using
41
42
 
42
43
  ### How it works
43
44
 
44
- - The function displays your connections via `sshya list --oneline --names`, showing:
45
- - alias, user@host (for display), and a third tab-separated column containing ssh-ready arguments.
46
- - 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.
47
- - Argument splitting is handled per-shell to avoid issues with flags and quoting:
48
- - zsh: `ssh ${(z)command}`
49
- - bash: `read -r -a __args <<< "$command"; ssh "${__args[@]}"`
45
+ - The function displays your connections via `sshya list --oneline --names`, showing alias and user@host.
46
+ - After you pick an entry with `fzf`, it runs `sshya connect <alias>`.
47
+ - `sshya connect` builds SSH arguments in-process and spawns your system `ssh` with inherited stdio, so TTY behavior matches typing the command yourself.
50
48
 
51
- This ensures flags like `-p 2222` and identities like `-i '/path/with spaces/key'` are passed correctly to `ssh`.
49
+ ### Usage
52
50
 
53
- ### Why system ssh?
51
+ After adding the snippet to your shell rc and reloading, simply run:
54
52
 
55
- 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.
56
-
57
- ### ZLE/TTY handling (zsh)
58
-
59
- The snippet detaches the zsh line editor before starting `ssh` and restores it after:
60
-
61
- - Before `ssh`: `zle -I`
62
- - After `ssh`: `zle -R -c`
63
-
64
- It also temporarily disables XON/XOFF flow control during selection (enables Ctrl-S) and restores your previous `stty` settings afterward.
53
+ ```bash
54
+ s
55
+ ```
65
56
 
66
- ### Keybinding
57
+ This will launch the fzf interface for selecting and connecting to your saved SSH connections.
67
58
 
68
- The snippet includes an example zsh binding:
59
+ You can also connect directly without fzf:
69
60
 
70
61
  ```bash
71
- zle -N fzf_sshya
72
- bindkey '^S' fzf_sshya
62
+ sshya connect my-server
63
+ sshya connect # interactive picker
73
64
  ```
74
65
 
75
- For bash, see the printed notes (example: `bind -x` with Ctrl-S), and ensure flow control is disabled (`stty -ixon`).
76
-
77
66
  ### Troubleshooting
78
67
 
79
- - 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`).
80
- - 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.
68
+ - If fzf filtering looks wrong, reload your shell after updating the snippet. The launcher should call `sshya connect`, not parse `sshya print` output.
69
+ - If the port or key path is ignored, verify the connection with `sshya test <alias>`.
package/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import chalk from 'chalk';
4
4
  import { Command } from 'commander';
5
5
  import pkg from './package.json' assert { type: "json" };
6
- import { addConnectionPrompt, exportConnectionsPrompt, importConnectionsPrompt, listConnectionsPrompt, removeConnectionPrompt, testConnectionPrompt, updateConnectionPrompt } from './src/connection';
6
+ import { addConnectionPrompt, connectConnectionPrompt, exportConnectionsPrompt, importConnectionsPrompt, listConnectionsPrompt, removeConnectionPrompt, testConnectionPrompt, updateConnectionPrompt } from './src/connection';
7
7
  import { initDB } from './src/database';
8
8
  import { printConnectionsPrompt } from './src/helpers/connection';
9
9
  import { enableEscapeExit } from './src/helpers/escExit';
@@ -24,7 +24,12 @@ program
24
24
  .version(version)
25
25
  .description('A simple CLI to manage your SSH connections');
26
26
 
27
- // connect command removed – use system ssh via fzf snippet
27
+ program
28
+ .command('connect [alias]')
29
+ .aliases(['ssh', 'go'])
30
+ .description('Connect to an SSH connection by alias')
31
+ .action(connectConnectionPrompt);
32
+
28
33
 
29
34
  program
30
35
  .command('print [alias]')
package/package.json CHANGED
@@ -2,7 +2,8 @@
2
2
  "name": "sshya",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.2.1",
5
+ "version": "0.2.3",
6
+ "description": "CLI for managing SSH connections with aliases, interactive prompts, and fzf integration",
6
7
  "devDependencies": {
7
8
  "@types/bun": "latest",
8
9
  "@types/chalk": "^2.2.4"
@@ -0,0 +1,48 @@
1
+ import chalk from 'chalk';
2
+ import { spawn } from 'child_process';
3
+ import { expandHomePath, getConnectionByAlias, recordConnectionUsage } from '../database';
4
+ import { buildRemoteCommand } from '../helpers/shell';
5
+ import { selectAlias } from '../helpers/selectAlias';
6
+
7
+ export async function connectConnectionPrompt(alias?: string): Promise<void> {
8
+ if (!alias) {
9
+ alias = await selectAlias('Select a connection to connect');
10
+ }
11
+
12
+ const connection = getConnectionByAlias(alias);
13
+ if (!connection) {
14
+ console.error(chalk.red('Alias not found'));
15
+ process.exit(1);
16
+ }
17
+
18
+ const args: string[] = [];
19
+ if (connection.key_path) {
20
+ args.push('-i', expandHomePath(connection.key_path) ?? connection.key_path);
21
+ }
22
+ if (connection.port) {
23
+ args.push('-p', String(connection.port));
24
+ }
25
+
26
+ args.push('-t', `${connection.user}@${connection.host}`);
27
+
28
+ if (connection.remote_path) {
29
+ const remoteCommand = buildRemoteCommand({
30
+ remotePath: connection.remote_path,
31
+ postCommand: 'exec "$SHELL" -l',
32
+ });
33
+ if (remoteCommand) {
34
+ args.push(remoteCommand);
35
+ }
36
+ }
37
+
38
+ recordConnectionUsage(alias);
39
+
40
+ const child = spawn('ssh', args, { stdio: 'inherit' });
41
+ child.on('error', (error) => {
42
+ console.error(chalk.red(`Failed to start ssh: ${error.message}`));
43
+ process.exit(1);
44
+ });
45
+ child.on('close', (code) => {
46
+ process.exit(code ?? 1);
47
+ });
48
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './add';
2
+ export * from './connect';
2
3
  export * from './export';
3
4
  export * from './import';
4
5
  export * from './list';
@@ -1,42 +1,130 @@
1
1
  import chalk from "chalk";
2
+ import os from "os";
2
3
  import { expandHomePath, getConnections } from "../database";
3
4
  import { buildRemoteCommand } from "../helpers/shell";
4
5
 
5
6
  export async function listConnectionsPrompt(options?: { oneline?: boolean; names?: boolean }) {
6
- const connections = getConnections();
7
+ let connections = getConnections();
7
8
  if (options?.oneline) {
8
- // Print each connection as ssh-ready args on a single line
9
+ // Sort by lastUsed (most recent first) for fzf - this helps with selection
10
+ if (options?.names) {
11
+ connections = connections.sort((a, b) => (b.lastUsed ?? 0) - (a.lastUsed ?? 0));
12
+ }
13
+
14
+ // Pre-compute constants outside the loop
15
+ const TAB = '\t';
16
+ const SPACE = ' ';
17
+ const DASH_I = '-i';
18
+ const DASH_P = '-p';
19
+ const DASH_T = '-t';
20
+ const SINGLE_QUOTE = "'";
21
+ const ESCAPED_QUOTE = "'\\''";
22
+
23
+ // Cache home directory for path expansion
24
+ const homeDir = os.homedir();
25
+
26
+ // Pre-compute user@host combinations to avoid string concatenation in loop
27
+ const userHostCache = new Map<string, string>();
9
28
  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
- // Always add -t for interactive terminal allocation (required for fzf integration)
21
- parts.push('-t');
22
- parts.push(`${c.user}@${c.host}`);
23
- if (c.remote_path) {
24
- const remoteCommand = buildRemoteCommand({
25
- remotePath: String(c.remote_path),
26
- postCommand: 'exec "$SHELL" -l',
27
- });
28
- if (remoteCommand) {
29
- parts.push(remoteCommand);
30
- }
31
- }
32
- const args = parts.join(' ');
29
+ userHostCache.set(c.alias, `${c.user}@${c.host}`);
30
+ }
31
+
32
+ for (const c of connections) {
33
+ // Use cached user@host
34
+ const userHost = userHostCache.get(c.alias)!;
35
+
33
36
  if (options?.names) {
34
- // Tab-separated: alias<TAB>user@host<TAB>args for easy fzf display and parsing
35
- console.log(`${c.alias}\t${c.user}@${c.host}\t${args}`);
37
+ // Optimized tab-separated format for fzf
38
+ let output = c.alias + TAB + userHost + TAB;
39
+
40
+ // Build SSH args array more efficiently
41
+ const args: string[] = [];
42
+
43
+ if (c.key_path) {
44
+ // Fast path expansion - only expand if it starts with ~
45
+ let expandedKeyPath = c.key_path;
46
+ if (expandedKeyPath.startsWith('~')) {
47
+ if (expandedKeyPath === '~') {
48
+ expandedKeyPath = homeDir;
49
+ } else if (expandedKeyPath.startsWith('~/')) {
50
+ expandedKeyPath = homeDir + expandedKeyPath.slice(1);
51
+ }
52
+ }
53
+
54
+ // Fast quote escaping - only escape single quotes
55
+ const needsEscaping = expandedKeyPath.includes(SINGLE_QUOTE);
56
+ const quotedKey = needsEscaping
57
+ ? SINGLE_QUOTE + expandedKeyPath.replace(/'/g, ESCAPED_QUOTE) + SINGLE_QUOTE
58
+ : SINGLE_QUOTE + expandedKeyPath + SINGLE_QUOTE;
59
+
60
+ args.push(DASH_I, quotedKey);
61
+ }
62
+
63
+ if (c.port) {
64
+ args.push(DASH_P, String(c.port));
65
+ }
66
+
67
+ // Always add -t for interactive terminal allocation
68
+ args.push(DASH_T);
69
+ args.push(userHost);
70
+
71
+ if (c.remote_path) {
72
+ // Only build remote command if needed
73
+ const remoteCommand = buildRemoteCommand({
74
+ remotePath: String(c.remote_path),
75
+ postCommand: 'exec "$SHELL" -l',
76
+ });
77
+ if (remoteCommand) {
78
+ args.push(remoteCommand);
79
+ }
80
+ }
81
+
82
+ // Join args with spaces (faster than array join for small arrays)
83
+ output += args.join(SPACE);
84
+ console.log(output);
36
85
  } else {
37
- console.log(args);
86
+ // Simple args-only format (even faster)
87
+ const args: string[] = [];
88
+
89
+ if (c.key_path) {
90
+ let expandedKeyPath = c.key_path;
91
+ if (expandedKeyPath.startsWith('~')) {
92
+ if (expandedKeyPath === '~') {
93
+ expandedKeyPath = homeDir;
94
+ } else if (expandedKeyPath.startsWith('~/')) {
95
+ expandedKeyPath = homeDir + expandedKeyPath.slice(1);
96
+ }
97
+ }
98
+
99
+ const needsEscaping = expandedKeyPath.includes(SINGLE_QUOTE);
100
+ const quotedKey = needsEscaping
101
+ ? SINGLE_QUOTE + expandedKeyPath.replace(/'/g, ESCAPED_QUOTE) + SINGLE_QUOTE
102
+ : SINGLE_QUOTE + expandedKeyPath + SINGLE_QUOTE;
103
+
104
+ args.push(DASH_I, quotedKey);
105
+ }
106
+
107
+ if (c.port) {
108
+ args.push(DASH_P, String(c.port));
109
+ }
110
+
111
+ args.push(DASH_T);
112
+ args.push(userHost);
113
+
114
+ if (c.remote_path) {
115
+ const remoteCommand = buildRemoteCommand({
116
+ remotePath: String(c.remote_path),
117
+ postCommand: 'exec "$SHELL" -l',
118
+ });
119
+ if (remoteCommand) {
120
+ args.push(remoteCommand);
121
+ }
122
+ }
123
+
124
+ console.log(args.join(SPACE));
38
125
  }
39
126
  }
127
+
40
128
  process.exit(0);
41
129
  }
42
130
 
package/src/database.ts CHANGED
@@ -34,15 +34,24 @@ const normalizeOptionalString = (value?: string | number | null): string | undef
34
34
  return trimmed.length > 0 ? trimmed : undefined;
35
35
  };
36
36
 
37
+ // Cache home directory for performance
38
+ let homeDirCache: string | null = null;
39
+
37
40
  export const expandHomePath = (input?: string): string | undefined => {
38
41
  if (!input) {
39
42
  return undefined;
40
43
  }
44
+
45
+ // Cache home directory
46
+ if (!homeDirCache) {
47
+ homeDirCache = os.homedir();
48
+ }
49
+
41
50
  if (input === '~') {
42
- return os.homedir();
51
+ return homeDirCache;
43
52
  }
44
53
  if (input.startsWith('~/')) {
45
- return path.join(os.homedir(), input.slice(2));
54
+ return path.join(homeDirCache, input.slice(2));
46
55
  }
47
56
  return input;
48
57
  };
@@ -63,13 +72,31 @@ const normalizeConnection = (connection: Connection): Connection => {
63
72
  };
64
73
  };
65
74
 
75
+ // Cache for parsed connections to avoid re-reading/parsing
76
+ let connectionCache: Connection[] | null = null;
77
+ let cacheTimestamp: number = 0;
78
+ const CACHE_DURATION = 1000; // 1 second cache
79
+
66
80
  const readDB = (): Connection[] => {
81
+ const now = Date.now();
82
+
83
+ // Return cached data if it's still fresh
84
+ if (connectionCache && (now - cacheTimestamp) < CACHE_DURATION) {
85
+ return connectionCache;
86
+ }
87
+
67
88
  if (!fs.existsSync(dbFilePath)) {
68
- return [];
89
+ connectionCache = [];
90
+ cacheTimestamp = now;
91
+ return connectionCache;
69
92
  }
93
+
70
94
  const data = fs.readFileSync(dbFilePath, 'utf-8');
71
95
  const parsed: Connection[] = JSON.parse(data);
72
- return parsed.map(normalizeConnection);
96
+ connectionCache = parsed.map(normalizeConnection);
97
+ cacheTimestamp = now;
98
+
99
+ return connectionCache;
73
100
  };
74
101
 
75
102
  const writeDB = (data: Connection[]) => {
@@ -78,6 +105,10 @@ const writeDB = (data: Connection[]) => {
78
105
  fs.mkdirSync(dbDir, { recursive: true });
79
106
  }
80
107
  fs.writeFileSync(dbFilePath, JSON.stringify(data, null, 2));
108
+
109
+ // Invalidate cache when data is written
110
+ connectionCache = null;
111
+ cacheTimestamp = 0;
81
112
  };
82
113
 
83
114
  export const initDB = () => {
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import { expandHomePath, getConnectionByAlias } from "../database";
2
+ import { expandHomePath, getConnectionByAlias, recordConnectionUsage } from "../database";
3
3
  import { buildRemoteCommand } from "./shell";
4
4
 
5
5
  export async function printConnectionsPrompt(alias?: string) {
@@ -12,6 +12,8 @@ export async function printConnectionsPrompt(alias?: string) {
12
12
  console.error(chalk.red('Alias not found'));
13
13
  process.exit(1);
14
14
  }
15
+ // Record usage when connection is accessed via print (used by fzf)
16
+ recordConnectionUsage(alias);
15
17
  const parts: string[] = [];
16
18
  if (connection.key_path) {
17
19
  const expandedKeyPath = expandHomePath(String(connection.key_path));
@@ -4,42 +4,23 @@ export function printFzfInstructions() {
4
4
  console.log(chalk.yellow('To enable a quick fzf-powered SSH launcher, add this to your shell rc:'));
5
5
  console.log('\n' + chalk.bold('~/.bashrc or ~/.zshrc') + '\n');
6
6
  const snippet = [
7
- '_fzf_sshya() {',
8
- ' local line alias userhost command oldstty',
9
- ' # Disable XON/XOFF for Ctrl-S binding',
10
- ' oldstty=$(stty -g 2>/dev/null)',
11
- ' stty -ixon 2>/dev/null || true',
7
+ 's() {',
8
+ ' local line alias userhost',
12
9
  ' 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',
10
+ ' stdbuf -oL sshya "$@"',
18
11
  ' }',
19
- " line=$(run_sshya list --oneline --names | fzf --tac --with-nth=1,2 --delimiter=$'\\t') || { stty \"$oldstty\" 2>/dev/null || true; return; }",
12
+ " line=$(run_sshya list --oneline --names | fzf --with-nth=1,2 --nth=1,2 --delimiter=$'\\t') || return",
20
13
  " alias=${line%%$'\\t'*}",
21
14
  " userhost=${line#*$'\\t'}; userhost=${userhost%%$'\\t'*}",
22
- " command=$(run_sshya print \"$alias\")",
23
15
  " echo \"Connecting: $alias ($userhost)\"",
24
- " if [ -n \"$ZSH_VERSION\" ]; then",
25
- " local -a _args; _args=(${(zQ)command})",
26
- " ssh \"${_args[@]}\"",
27
- " zle -R -c",
28
- " else",
29
- " read -r -a __args <<< \"$command\"",
30
- " ssh \"${__args[@]}\"",
31
- " fi",
32
- " stty \"$oldstty\" 2>/dev/null || true",
16
+ " run_sshya connect \"$alias\"",
33
17
  '}',
34
- '# Zsh widget binding',
35
- 'zle -N _fzf_sshya',
36
- "bindkey '^S' _fzf_sshya",
37
18
  ].join('\n');
38
19
  console.log(chalk.cyan(snippet));
39
20
  console.log('\n' + chalk.green('Notes:'));
40
21
  console.log('- This expects ' + chalk.bold('fzf') + ' to be installed.');
41
- console.log('- On bash, you can bind with: ' + chalk.cyan("bind -x '" + '\\"\\u0013\\":_fzf_sshya' + "'"));
42
- console.log('- If Ctrl-S is flow control, disable with: ' + chalk.cyan('stty -ixon'));
22
+ console.log('- After adding this to your shell rc, reload with: ' + chalk.cyan('source ~/.zshrc') + ' or ' + chalk.cyan('source ~/.bashrc'));
23
+ console.log('- Then simply run ' + chalk.bold('s') + ' to launch the fzf interface');
43
24
  process.exit(0);
44
25
  }
45
26