sshya 0.2.4 → 0.3.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/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # SSHya CLI - SSH Connection Manager
2
2
 
3
- **Purpose**: CLI tool for managing SSH connections with aliases, interactive prompts, and fzf integration.
3
+ **Purpose**: CLI tool for managing SSH connections with aliases, interactive prompts, and optional fzf selection.
4
4
 
5
5
  ## Core Features
6
6
  - **Connection Management**: Add, remove, update, list SSH connections with custom aliases
@@ -15,19 +15,21 @@
15
15
  ### Database (`src/database.ts`)
16
16
  - `Connection` interface with id, alias, user, host, key_path, port, remote_path
17
17
 
18
- ### Commands (`index.ts`)
18
+ ### Commands (`index.ts` / `s.ts`)
19
19
  - `add`/`remove`/`update`/`list`: Manage connections
20
- - `print [alias]`: Output SSH command for scripting/fzf
20
+ - `print [alias]`: Output SSH command for scripting
21
21
  - `import`/`export`: JSON file operations
22
- - `fzf`/`instructions`: Shell integration setup
22
+ - `fzf`: In-process fzf picker that connects to the selected alias
23
+ - `s`: Shortcut entrypoint that always runs `connect` (interactive picker or alias)
23
24
 
24
25
  ## Usage Patterns
25
26
  1. **Direct**: `sshya add` → Interactive prompt flow
26
- 2. **fzf Integration**: Shell function with `sshya print` for fast selection
27
- 3. **Scripting**: `sshya print my-server` → Outputs `ssh -p 2222 -t -i ~/.ssh/key user@host`
27
+ 2. **Fast connect**: `s` or `sshya connect` built-in fuzzy picker (no shell rc changes)
28
+ 3. **fzf**: `sshya fzf` → fzf-based selection, then the same connect flow
29
+ 4. **Scripting**: `sshya print my-server` → Outputs `ssh -p 2222 -t -i ~/.ssh/key user@host`
28
30
 
29
31
  ## Notable Implementation Details
30
- - Shell-specific argument parsing (zsh vs bash) for fzf integration
32
+ - `package.json` bins: `sshya` `index.ts`, `s` `s.ts`
31
33
  - Usage tracking via `lastUsed` timestamps
32
34
  - Always includes `-t` flag for proper pseudo-terminal allocation
33
35
  - **High-performance optimizations**: ~0.2ms processing time for 8 connections with caching and pre-computation
package/README.md CHANGED
@@ -4,10 +4,11 @@ This project is a command-line interface (CLI) tool named `sshya` for managing S
4
4
 
5
5
  ## Core Functionality
6
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`).
7
+ - Connect to saved connections with `s` or `sshya connect` (aliases: `ssh`, `go`).
8
+ - Copy files through saved connections with `sshya copy` (aliases: `sc`, `scp`).
8
9
  - Generate SSH command strings for scripting with `sshya print`.
9
10
  - Import and export connections from/to a JSON file.
10
- - fzf-based launcher for fast, keyboard-driven connection selection.
11
+ - Optional fzf picker with `sshya fzf`.
11
12
 
12
13
  ## Technology Stack
13
14
  - **Runtime:** Bun
@@ -22,48 +23,44 @@ This project is a command-line interface (CLI) tool named `sshya` for managing S
22
23
  ## Implementation Details
23
24
  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.
24
25
 
25
- ## fzf-based launcher
26
+ ## Connecting
26
27
 
27
- You can enable a fast, keyboard-driven launcher for your saved connections using `fzf`.
28
+ Install exposes two commands: `sshya` for management, and `s` as a shortcut to connect. No shell rc changes are required.
28
29
 
29
- ### Quick setup
30
-
31
- 1. Ensure `fzf` is installed.
32
- 2. Print the shell snippet:
33
-
34
- ```bash
35
- sshya fzf
36
- # or the aliases
37
- sshya instructions
38
- sshya install
39
- ```
30
+ ```bash
31
+ s # interactive picker (type to filter)
32
+ s my-server # connect by alias
33
+ sshya connect # same picker
34
+ sshya connect my-server
35
+ ```
40
36
 
41
- 3. Copy the printed snippet into your `~/.zshrc` or `~/.bashrc` and reload your shell.
37
+ `s` and `sshya connect` use the built-in fuzzy picker. If you prefer fzf:
42
38
 
43
- ### How it works
39
+ ```bash
40
+ sshya fzf
41
+ ```
44
42
 
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.
43
+ That requires `fzf` on your PATH. After you pick an entry, it runs the same in-process `connect` flow and spawns your system `ssh` with inherited stdio.
48
44
 
49
- ### Usage
45
+ ### Copy files
50
46
 
51
- After adding the snippet to your shell rc and reloading, simply run:
47
+ Use stored connections for `scp` uploads and downloads:
52
48
 
53
49
  ```bash
54
- s
50
+ sshya copy my-server ./local.txt /tmp/remote.txt
51
+ sshya copy ./local.txt /tmp/remote.txt # interactive connection picker
52
+ sshya copy --download my-server /tmp/remote.txt ./
53
+ sshya copy --recursive my-server ./dist /var/www
55
54
  ```
56
55
 
57
- This will launch the fzf interface for selecting and connecting to your saved SSH connections.
58
-
59
- You can also connect directly without fzf:
56
+ The `copy` command also works as `sshya sc` or `sshya scp`.
60
57
 
61
- ```bash
62
- sshya connect my-server
63
- sshya connect # interactive picker
64
- ```
58
+ Relative remote paths are resolved from the connection's saved remote working directory when one is configured.
59
+ If an exact alias is not found, `connect` and `copy` use the first fuzzy match from the same search used by the interactive picker.
65
60
 
66
61
  ### Troubleshooting
67
62
 
68
- - If fzf filtering looks wrong, reload your shell after updating the snippet. The launcher should call `sshya connect`, not parse `sshya print` output.
63
+ - If `s` is not found, reinstall or relink the package so both bins (`sshya` and `s`) are on your PATH.
64
+ - If `s` still opens an old fzf shell function, remove the `s()` snippet from `~/.zshrc` or `~/.bashrc` so the installed binary is used.
65
+ - If `sshya fzf` fails, install `fzf` or use `s` / `sshya connect` instead.
69
66
  - If the port or key path is ignored, verify the connection with `sshya test <alias>`.
package/index.ts CHANGED
@@ -3,11 +3,11 @@
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, connectConnectionPrompt, exportConnectionsPrompt, importConnectionsPrompt, listConnectionsPrompt, removeConnectionPrompt, testConnectionPrompt, updateConnectionPrompt } from './src/connection';
6
+ import { addConnectionPrompt, connectConnectionPrompt, copyConnectionPrompt, 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';
10
- import { printFzfInstructions } from './src/helpers/fzf';
10
+ import { runFzfLauncher } from './src/helpers/fzf';
11
11
  // built-in SSH handler removed
12
12
 
13
13
  const version = pkg?.version ?? '1.0.0';
@@ -30,6 +30,20 @@ program
30
30
  .description('Connect to an SSH connection by alias')
31
31
  .action(connectConnectionPrompt);
32
32
 
33
+ program
34
+ .command('copy [alias] [source] [destination]')
35
+ .aliases(['sc', 'scp'])
36
+ .description('Copy files using a stored SSH connection')
37
+ .option('-d, --download', 'Copy from the remote host to a local destination')
38
+ .option('-r, --recursive', 'Copy directories recursively')
39
+ .option('-p, --preserve', 'Preserve modification times, access times, and modes')
40
+ .action((alias?: string, source?: string, destination?: string, options?: { download?: boolean; recursive?: boolean; preserve?: boolean }) => {
41
+ return copyConnectionPrompt(alias, source, destination, {
42
+ download: !!options?.download,
43
+ recursive: !!options?.recursive,
44
+ preserve: !!options?.preserve,
45
+ });
46
+ });
33
47
 
34
48
  program
35
49
  .command('print [alias]')
@@ -79,11 +93,8 @@ program
79
93
 
80
94
  program
81
95
  .command('fzf')
82
- .aliases(['instructions', 'install'])
83
- .description('Show instructions to enable fzf-based connection launcher')
84
- .action(() => {
85
- printFzfInstructions();
86
- });
96
+ .description('Select a connection with fzf and connect')
97
+ .action(runFzfLauncher);
87
98
 
88
99
  program
89
100
  .command('path')
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "sshya",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.2.4",
5
+ "version": "0.3.0",
6
6
  "description": "CLI for managing SSH connections with aliases, interactive prompts, and fzf integration",
7
7
  "devDependencies": {
8
8
  "@types/bun": "latest",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "scripts": {
15
15
  "start": "bun run index.ts",
16
- "build": "bun build index.ts --compile --outfile bin/sshya"
16
+ "build": "bun build index.ts --compile --outfile bin/sshya && bun build s.ts --compile --outfile bin/s"
17
17
  },
18
18
  "dependencies": {
19
19
  "@inquirer/search": "^3.0.17",
@@ -28,6 +28,6 @@
28
28
  },
29
29
  "bin": {
30
30
  "sshya": "index.ts",
31
- "s": "index.ts"
31
+ "s": "s.ts"
32
32
  }
33
33
  }
package/s.ts ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { connectConnectionPrompt } from './src/connection';
4
+ import { initDB } from './src/database';
5
+ import { enableEscapeExit } from './src/helpers/escExit';
6
+
7
+ if (process.stdin.isTTY && process.stdout.isTTY) {
8
+ enableEscapeExit();
9
+ }
10
+
11
+ initDB();
12
+
13
+ const rawArgs = process.argv.slice(2);
14
+ const looksLikeBunShim = rawArgs[0] === 'bun' && rawArgs[1] === 'run';
15
+ const userArgs = looksLikeBunShim ? rawArgs.slice(3) : rawArgs;
16
+ const arg = userArgs[0];
17
+
18
+ if (arg === '-h' || arg === '--help') {
19
+ console.log('Usage: s [alias]');
20
+ console.log('Connect to a saved SSH connection. Omit alias to pick interactively.');
21
+ process.exit(0);
22
+ }
23
+
24
+ await connectConnectionPrompt(arg);
@@ -2,14 +2,22 @@ import chalk from 'chalk';
2
2
  import { spawn } from 'child_process';
3
3
  import { expandHomePath, getConnectionByAlias, recordConnectionUsage } from '../database';
4
4
  import { buildRemoteCommand } from '../helpers/shell';
5
- import { selectAlias } from '../helpers/selectAlias';
5
+ import { findAliasBySearchTerm, selectAlias } from '../helpers/selectAlias';
6
6
 
7
7
  export async function connectConnectionPrompt(alias?: string): Promise<void> {
8
8
  if (!alias) {
9
9
  alias = await selectAlias('Select a connection to connect');
10
10
  }
11
11
 
12
- const connection = getConnectionByAlias(alias);
12
+ let connection = getConnectionByAlias(alias);
13
+ if (!connection) {
14
+ const matchedAlias = findAliasBySearchTerm(alias);
15
+ if (matchedAlias) {
16
+ alias = matchedAlias;
17
+ connection = getConnectionByAlias(alias);
18
+ }
19
+ }
20
+
13
21
  if (!connection) {
14
22
  console.error(chalk.red('Alias not found'));
15
23
  process.exit(1);
@@ -0,0 +1,119 @@
1
+ import chalk from 'chalk';
2
+ import path from 'path';
3
+ import { spawn } from 'child_process';
4
+ import { expandHomePath, getConnectionByAlias, recordConnectionUsage, type Connection } from '../database';
5
+ import { findAliasBySearchTerm, selectAlias } from '../helpers/selectAlias';
6
+
7
+ interface CopyOptions {
8
+ download?: boolean;
9
+ recursive?: boolean;
10
+ preserve?: boolean;
11
+ }
12
+
13
+ interface NormalizedCopyArgs {
14
+ alias?: string;
15
+ source?: string;
16
+ destination?: string;
17
+ }
18
+
19
+ const isAbsoluteRemotePath = (value: string): boolean => {
20
+ return value.startsWith('/') || value.startsWith('~/') || value === '~';
21
+ };
22
+
23
+ const resolveRemotePath = (connection: Connection, value: string): string => {
24
+ const remotePath = value.trim();
25
+ const remoteBase = connection.remote_path?.trim();
26
+
27
+ if (!remoteBase || isAbsoluteRemotePath(remotePath)) {
28
+ return remotePath;
29
+ }
30
+
31
+ return path.posix.join(remoteBase, remotePath);
32
+ };
33
+
34
+ const buildRemoteSpec = (connection: Connection, remotePath: string): string => {
35
+ return `${connection.user}@${connection.host}:${resolveRemotePath(connection, remotePath)}`;
36
+ };
37
+
38
+ const normalizeCopyArgs = (first?: string, second?: string, third?: string): NormalizedCopyArgs => {
39
+ if (third) {
40
+ return { alias: first, source: second, destination: third };
41
+ }
42
+
43
+ if (first && second) {
44
+ const maybeConnection = getConnectionByAlias(first);
45
+ if (maybeConnection) {
46
+ return { alias: first, source: second };
47
+ }
48
+
49
+ return { source: first, destination: second };
50
+ }
51
+
52
+ return { alias: first, source: second, destination: third };
53
+ };
54
+
55
+ export async function copyConnectionPrompt(
56
+ first?: string,
57
+ second?: string,
58
+ third?: string,
59
+ options: CopyOptions = {},
60
+ ): Promise<void> {
61
+ const { download = false, recursive = false, preserve = false } = options;
62
+ let { alias, source, destination } = normalizeCopyArgs(first, second, third);
63
+
64
+ if (!source || !destination) {
65
+ console.error(chalk.red('Source and destination are required'));
66
+ console.error(chalk.grey('Usage: sshya copy [alias] <source> <destination>'));
67
+ console.error(chalk.grey(' sshya copy --download [alias] <remote-source> <local-destination>'));
68
+ process.exit(1);
69
+ }
70
+
71
+ if (!alias) {
72
+ alias = await selectAlias(download ? 'Select a connection to copy from' : 'Select a connection to copy to');
73
+ }
74
+
75
+ let connection = getConnectionByAlias(alias);
76
+ if (!connection) {
77
+ const matchedAlias = findAliasBySearchTerm(alias);
78
+ if (matchedAlias) {
79
+ alias = matchedAlias;
80
+ connection = getConnectionByAlias(alias);
81
+ }
82
+ }
83
+
84
+ if (!connection) {
85
+ console.error(chalk.red('Alias not found'));
86
+ process.exit(1);
87
+ }
88
+
89
+ const args: string[] = [];
90
+ if (connection.key_path) {
91
+ args.push('-i', expandHomePath(connection.key_path) ?? connection.key_path);
92
+ }
93
+ if (connection.port) {
94
+ args.push('-P', String(connection.port));
95
+ }
96
+ if (recursive) {
97
+ args.push('-r');
98
+ }
99
+ if (preserve) {
100
+ args.push('-p');
101
+ }
102
+
103
+ if (download) {
104
+ args.push(buildRemoteSpec(connection, source), destination);
105
+ } else {
106
+ args.push(source, buildRemoteSpec(connection, destination));
107
+ }
108
+
109
+ recordConnectionUsage(alias);
110
+
111
+ const child = spawn('scp', args, { stdio: 'inherit' });
112
+ child.on('error', (error) => {
113
+ console.error(chalk.red(`Failed to start scp: ${error.message}`));
114
+ process.exit(1);
115
+ });
116
+ child.on('close', (code) => {
117
+ process.exit(code ?? 1);
118
+ });
119
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './add';
2
2
  export * from './connect';
3
+ export * from './copy';
3
4
  export * from './export';
4
5
  export * from './import';
5
6
  export * from './list';
@@ -1,27 +1,67 @@
1
1
  import chalk from 'chalk';
2
+ import { spawn } from 'child_process';
3
+ import { connectConnectionPrompt } from '../connection/connect';
4
+ import { getConnections } from '../database';
2
5
 
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
- 's() {',
8
- ' local line alias userhost',
9
- ' run_sshya() {',
10
- ' stdbuf -oL sshya "$@"',
11
- ' }',
12
- " line=$(run_sshya list --oneline --names | fzf --with-nth=1,2 --nth=1,2 --delimiter=$'\\t') || return",
13
- " alias=${line%%$'\\t'*}",
14
- " userhost=${line#*$'\\t'}; userhost=${userhost%%$'\\t'*}",
15
- " echo \"Connecting: $alias ($userhost)\"",
16
- " run_sshya connect \"$alias\"",
17
- '}',
18
- ].join('\n');
19
- console.log(chalk.cyan(snippet));
20
- console.log('\n' + chalk.green('Notes:'));
21
- console.log('- This expects ' + chalk.bold('fzf') + ' to be installed.');
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');
24
- process.exit(0);
6
+ const FZF_ARGS = [
7
+ '--with-nth=1,2',
8
+ '--nth=1,2',
9
+ '--delimiter=\t',
10
+ ];
11
+
12
+ export async function runFzfLauncher(): Promise<void> {
13
+ const connections = getConnections()
14
+ .sort((a, b) => (b.lastUsed ?? 0) - (a.lastUsed ?? 0));
15
+
16
+ if (connections.length === 0) {
17
+ console.log(chalk.yellow('No connections found. Add one with "sshya add"'));
18
+ process.exit(0);
19
+ }
20
+
21
+ const input = connections
22
+ .map((c) => `${c.alias}\t${c.user}@${c.host}`)
23
+ .join('\n');
24
+
25
+ const alias = await pickAliasWithFzf(input);
26
+ if (!alias) {
27
+ process.exit(0);
28
+ }
29
+
30
+ await connectConnectionPrompt(alias);
25
31
  }
26
32
 
33
+ function pickAliasWithFzf(input: string): Promise<string | undefined> {
34
+ return new Promise((resolve) => {
35
+ const child = spawn('fzf', FZF_ARGS, { stdio: ['pipe', 'pipe', 'inherit'] });
27
36
 
37
+ child.on('error', (error: NodeJS.ErrnoException) => {
38
+ if (error.code === 'ENOENT') {
39
+ console.error(chalk.red('fzf is not installed. Use "s" or "sshya connect" instead.'));
40
+ process.exit(1);
41
+ }
42
+ console.error(chalk.red(`Failed to start fzf: ${error.message}`));
43
+ process.exit(1);
44
+ });
45
+
46
+ let stdout = '';
47
+ child.stdout.on('data', (chunk: Buffer | string) => {
48
+ stdout += chunk.toString();
49
+ });
50
+
51
+ child.stdin.on('error', () => {
52
+ // fzf may close stdin early when the user cancels.
53
+ });
54
+ child.stdin.write(input);
55
+ child.stdin.end();
56
+
57
+ child.on('close', (code) => {
58
+ if (code !== 0) {
59
+ resolve(undefined);
60
+ return;
61
+ }
62
+
63
+ const alias = stdout.trim().split('\t')[0];
64
+ resolve(alias || undefined);
65
+ });
66
+ });
67
+ }
@@ -4,6 +4,30 @@ import inquirer from "inquirer";
4
4
  import { getConnections } from "../database";
5
5
  import { enableEscapeExit } from "./escExit";
6
6
 
7
+ const getAliasChoices = () => {
8
+ return getConnections()
9
+ .sort((a, b) => (b.lastUsed ?? 0) - (a.lastUsed ?? 0))
10
+ .map(c => ({ name: c.alias, value: c.alias }));
11
+ };
12
+
13
+ const searchAliasChoices = (term: string) => {
14
+ const choices = getAliasChoices();
15
+ if (term.trim().length === 0) {
16
+ return choices;
17
+ }
18
+
19
+ const fuse = new Fuse(choices, {
20
+ keys: ['name'],
21
+ threshold: 0.4,
22
+ });
23
+
24
+ return fuse.search(term).map(r => r.item);
25
+ };
26
+
27
+ export const findAliasBySearchTerm = (term: string): string | undefined => {
28
+ return searchAliasChoices(term)[0]?.value;
29
+ };
30
+
7
31
  /**
8
32
  * Prompt user to choose a connection alias using fuzzy search.
9
33
  * Aborts the process if user presses ESC or if no connections are available.
@@ -11,8 +35,7 @@ import { enableEscapeExit } from "./escExit";
11
35
  * @returns selected alias as string
12
36
  */
13
37
  export async function selectAlias(message: string = "Select a connection"): Promise<string> {
14
- const connections = getConnections().sort((a, b) => (b.lastUsed ?? 0) - (a.lastUsed ?? 0));
15
- if (connections.length === 0) {
38
+ if (getConnections().length === 0) {
16
39
  console.log(chalk.yellow('No connections found. Add one with "sshya add"'));
17
40
  process.exit(0);
18
41
  }
@@ -29,20 +52,12 @@ export async function selectAlias(message: string = "Select a connection"): Prom
29
52
  // The signature matches the @inquirer/search documentation:
30
53
  // (term: string | void, { signal }: { signal: AbortSignal }) => Promise<Choice[]>
31
54
  source: async (term: unknown) => {
32
- const allChoices = connections.map(c => ({ name: c.alias, value: c.alias }));
33
-
34
55
  // When term is undefined or empty, return the full, recently-used-sorted list.
35
56
  if (typeof term !== 'string' || term.trim().length === 0) {
36
- return allChoices;
57
+ return searchAliasChoices('');
37
58
  }
38
59
 
39
- // Use Fuse.js for fuzzy matching on the `name` field of choice objects.
40
- const fuse = new Fuse(allChoices, {
41
- keys: ['name'],
42
- threshold: 0.4,
43
- });
44
-
45
- return fuse.search(term).map(r => r.item);
60
+ return searchAliasChoices(term);
46
61
  },
47
62
  },
48
63
  ]);