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 +58 -0
- package/index.ts +38 -19
- package/package.json +2 -2
- package/src/connection/add.ts +7 -2
- package/src/connection/import.ts +16 -2
- package/src/connection/list.ts +37 -2
- package/src/connection/test.ts +16 -4
- package/src/connection/update.ts +7 -1
- package/src/database.ts +84 -15
- package/src/helpers/connection.ts +35 -0
- package/src/helpers/escExit.ts +0 -9
- package/src/helpers/fzf.ts +57 -0
- package/src/helpers/shell.ts +26 -0
- package/src/ssh.ts +20 -73
- package/src/helpers/connectInteractive.ts +0 -20
- package/src/sshCommandHandler.ts +0 -93
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 {
|
|
8
|
+
import { printConnectionsPrompt } from './src/helpers/connection';
|
|
8
9
|
import { enableEscapeExit } from './src/helpers/escExit';
|
|
9
|
-
import {
|
|
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
|
-
|
|
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('
|
|
25
|
-
.description('
|
|
26
|
-
.action(
|
|
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
|
-
.
|
|
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
|
-
//
|
|
96
|
-
|
|
97
|
-
const looksLikeBunShim =
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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.
|
|
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/
|
|
15
|
+
"build": "bun build index.ts --compile --outfile bin/sshya"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@inquirer/search": "^3.0.17",
|
package/src/connection/add.ts
CHANGED
|
@@ -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([
|
package/src/connection/import.ts
CHANGED
|
@@ -50,11 +50,25 @@ export const importConnectionsPrompt = async (file: string) => {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
if (action === 'Overwrite') {
|
|
53
|
-
updateConnection(
|
|
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(
|
|
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
|
}
|
package/src/connection/list.ts
CHANGED
|
@@ -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;
|
package/src/connection/test.ts
CHANGED
|
@@ -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}
|
|
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 = '';
|
package/src/connection/update.ts
CHANGED
|
@@ -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
|
-
|
|
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 = (
|
|
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
|
-
|
|
58
|
-
|
|
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 = (
|
|
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
|
+
}
|
package/src/helpers/escExit.ts
CHANGED
|
@@ -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 {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/src/sshCommandHandler.ts
DELETED
|
@@ -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
|
-
}
|