sshya 0.2.4 → 0.2.5

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
@@ -5,6 +5,7 @@ This project is a command-line interface (CLI) tool named `sshya` for managing S
5
5
  ## Core Functionality
6
6
  - Add, remove, update, and list SSH connection configurations (alias, user, host, port, key path, remote path).
7
7
  - Connect to saved connections with `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
11
  - fzf-based launcher for fast, keyboard-driven connection selection.
@@ -63,6 +64,28 @@ sshya connect my-server
63
64
  sshya connect # interactive picker
64
65
  ```
65
66
 
67
+ ### Copy files
68
+
69
+ Use stored connections for `scp` uploads and downloads:
70
+
71
+ ```bash
72
+ sshya copy my-server ./local.txt /tmp/remote.txt
73
+ sshya copy ./local.txt /tmp/remote.txt # interactive connection picker
74
+ sshya copy --download my-server /tmp/remote.txt ./
75
+ sshya copy --recursive my-server ./dist /var/www
76
+ ```
77
+
78
+ The `copy` command also works as `sshya sc` or `sshya scp`. For a short no-fzf wrapper:
79
+
80
+ ```bash
81
+ sc() {
82
+ sshya copy "$@"
83
+ }
84
+ ```
85
+
86
+ Relative remote paths are resolved from the connection's saved remote working directory when one is configured.
87
+ If an exact alias is not found, `connect` and `copy` use the first fuzzy match from the same search used by the interactive picker.
88
+
66
89
  ### Troubleshooting
67
90
 
68
91
  - If fzf filtering looks wrong, reload your shell after updating the snippet. The launcher should call `sshya connect`, not parse `sshya print` output.
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, 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';
@@ -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]')
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.2.5",
6
6
  "description": "CLI for managing SSH connections with aliases, interactive prompts, and fzf integration",
7
7
  "devDependencies": {
8
8
  "@types/bun": "latest",
@@ -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';
@@ -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
  ]);