sshya 0.1.2 → 0.2.1
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 +56 -0
- package/README.md +3 -12
- package/index.ts +3 -22
- package/package.json +1 -1
- package/src/connection/add.ts +7 -2
- package/src/connection/import.ts +16 -2
- package/src/connection/list.ts +15 -2
- package/src/connection/test.ts +16 -4
- package/src/connection/update.ts +7 -1
- package/src/database.ts +83 -14
- package/src/helpers/connection.ts +27 -22
- package/src/helpers/escExit.ts +0 -9
- package/src/helpers/fzf.ts +9 -10
- package/src/helpers/shell.ts +26 -0
- package/src/ssh.ts +0 -71
- package/src/sshCommandHandler.ts +0 -93
package/AGENTS.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SSHya CLI - SSH Connection Manager
|
|
2
|
+
|
|
3
|
+
**Purpose**: CLI tool for managing SSH connections with aliases, interactive prompts, and fzf integration.
|
|
4
|
+
|
|
5
|
+
## Core Features
|
|
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
|
+
- **Import/Export**: JSON-based connection backup and restore
|
|
10
|
+
|
|
11
|
+
## Architecture
|
|
12
|
+
- **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
|
|
16
|
+
|
|
17
|
+
## Key Components
|
|
18
|
+
|
|
19
|
+
### Database (`src/database.ts`)
|
|
20
|
+
- `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
|
+
|
|
25
|
+
### Commands (`index.ts`)
|
|
26
|
+
- `add`/`remove`/`update`/`list`: Manage connections
|
|
27
|
+
- `print [alias]`: Output SSH command for scripting/fzf
|
|
28
|
+
- `import`/`export`: JSON file operations
|
|
29
|
+
- `fzf`/`instructions`: Shell integration setup
|
|
30
|
+
|
|
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
|
+
## Usage Patterns
|
|
38
|
+
1. **Direct**: `sshya add` → Interactive prompt flow
|
|
39
|
+
2. **fzf Integration**: Shell function with `sshya print` for fast selection
|
|
40
|
+
3. **Scripting**: `sshya print my-server` → Outputs `ssh -p 2222 -t -i ~/.ssh/key user@host`
|
|
41
|
+
|
|
42
|
+
## Notable Implementation Details
|
|
43
|
+
- Interactive prompt handling with raw mode TTY for user input
|
|
44
|
+
- Shell-specific argument parsing (zsh vs bash) for fzf integration
|
|
45
|
+
- Automatic path normalization and validation
|
|
46
|
+
- Usage tracking via `lastUsed` timestamps
|
|
47
|
+
- 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
|
+
```
|
|
55
|
+
|
|
56
|
+
This tool prioritizes developer experience with fast, keyboard-driven workflows while maintaining robust error handling and cross-platform compatibility.
|
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@ 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).
|
|
7
|
-
-
|
|
7
|
+
- Generate SSH command strings for saved connections.
|
|
8
8
|
- Import and export connections from/to a JSON file.
|
|
9
|
-
- A key feature is the
|
|
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
10
|
|
|
11
11
|
## Technology Stack
|
|
12
12
|
- **Runtime:** Bun
|
|
@@ -19,16 +19,7 @@ This project is a command-line interface (CLI) tool named `sshya` for managing S
|
|
|
19
19
|
- `zod`: for validating user input and imported data.
|
|
20
20
|
|
|
21
21
|
## Implementation Details
|
|
22
|
-
The main application logic is in `index.ts`.
|
|
23
|
-
- Laggy typing and missing keystrokes.
|
|
24
|
-
- Incorrect terminal behavior on window resizing.
|
|
25
|
-
|
|
26
|
-
The final solution involves:
|
|
27
|
-
1. Spawning the `ssh` process with `stdio: 'pipe'`.
|
|
28
|
-
2. Setting the local terminal to `rawMode` (`process.stdin.setRawMode(true)`).
|
|
29
|
-
3. Piping `stdin`, `stdout`, and `stderr` between the main process and the child `ssh` process.
|
|
30
|
-
4. Manually listening for `resize` events on `process.stdout` and forwarding them to the `ssh` child process by sending a `SIGWINCH` signal.
|
|
31
|
-
5. Using specific `ssh` command-line options (`-tt`, `ServerAliveInterval`, etc.) to ensure a stable and responsive interactive session.
|
|
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.
|
|
32
23
|
|
|
33
24
|
## fzf-based launcher
|
|
34
25
|
|
package/index.ts
CHANGED
|
@@ -5,10 +5,10 @@ import { Command } from 'commander';
|
|
|
5
5
|
import pkg from './package.json' assert { type: "json" };
|
|
6
6
|
import { addConnectionPrompt, exportConnectionsPrompt, importConnectionsPrompt, listConnectionsPrompt, removeConnectionPrompt, testConnectionPrompt, updateConnectionPrompt } from './src/connection';
|
|
7
7
|
import { initDB } from './src/database';
|
|
8
|
-
import {
|
|
8
|
+
import { printConnectionsPrompt } from './src/helpers/connection';
|
|
9
9
|
import { enableEscapeExit } from './src/helpers/escExit';
|
|
10
10
|
import { printFzfInstructions } from './src/helpers/fzf';
|
|
11
|
-
|
|
11
|
+
// built-in SSH handler removed
|
|
12
12
|
|
|
13
13
|
const version = pkg?.version ?? '1.0.0';
|
|
14
14
|
|
|
@@ -24,10 +24,7 @@ program
|
|
|
24
24
|
.version(version)
|
|
25
25
|
.description('A simple CLI to manage your SSH connections');
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
.command('connect [alias]')
|
|
29
|
-
.description('Connect to an SSH connection by alias')
|
|
30
|
-
.action(connectInteractive);
|
|
27
|
+
// connect command removed – use system ssh via fzf snippet
|
|
31
28
|
|
|
32
29
|
program
|
|
33
30
|
.command('print [alias]')
|
|
@@ -117,27 +114,11 @@ export function normalizeUserArgs(rawArgv: string[]): string[] {
|
|
|
117
114
|
return looksLikeBunShim ? rawArgv.slice(3) : rawArgv;
|
|
118
115
|
}
|
|
119
116
|
|
|
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
|
-
}
|
|
126
117
|
|
|
127
118
|
async function runFromProcess() {
|
|
128
119
|
// Clean Bun compile shim arguments (e.g., "bun run index.ts") when running the compiled binary
|
|
129
120
|
const rawArgs = process.argv.slice(2);
|
|
130
121
|
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
|
-
}
|
|
141
122
|
// Reconstruct argv array expected by commander: [node, script, ...userArgs]
|
|
142
123
|
const argvForCommander: string[] = [process.argv[0] ?? '', process.argv[1] ?? '', ...userArgs];
|
|
143
124
|
program.parse(argvForCommander);
|
package/package.json
CHANGED
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,5 +1,6 @@
|
|
|
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
5
|
export async function listConnectionsPrompt(options?: { oneline?: boolean; names?: boolean }) {
|
|
5
6
|
const connections = getConnections();
|
|
@@ -8,14 +9,26 @@ export async function listConnectionsPrompt(options?: { oneline?: boolean; names
|
|
|
8
9
|
for (const c of connections) {
|
|
9
10
|
const parts: string[] = [];
|
|
10
11
|
if (c.key_path) {
|
|
12
|
+
const expandedKeyPath = expandHomePath(String(c.key_path));
|
|
11
13
|
// Safely single-quote the key path for shells
|
|
12
|
-
const quotedKey = `'${String(
|
|
14
|
+
const quotedKey = `'${String(expandedKeyPath).replace(/'/g, "'\\''")}'`;
|
|
13
15
|
parts.push('-i', quotedKey);
|
|
14
16
|
}
|
|
15
17
|
if (c.port) {
|
|
16
18
|
parts.push('-p', String(c.port));
|
|
17
19
|
}
|
|
20
|
+
// Always add -t for interactive terminal allocation (required for fzf integration)
|
|
21
|
+
parts.push('-t');
|
|
18
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
|
+
}
|
|
19
32
|
const args = parts.join(' ');
|
|
20
33
|
if (options?.names) {
|
|
21
34
|
// Tab-separated: alias<TAB>user@host<TAB>args for easy fzf display and parsing
|
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
|
@@ -13,6 +13,7 @@ export interface Connection {
|
|
|
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
|
};
|
|
@@ -1,32 +1,37 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import { getConnectionByAlias } from "../database";
|
|
3
|
-
import {
|
|
4
|
-
import { selectAlias } from "./selectAlias";
|
|
2
|
+
import { expandHomePath, getConnectionByAlias } from "../database";
|
|
3
|
+
import { buildRemoteCommand } from "./shell";
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
* Helper to prompt user to select a connection and then connect
|
|
8
|
-
*/
|
|
9
|
-
export async function connectInteractive(alias?: string) {
|
|
5
|
+
export async function printConnectionsPrompt(alias?: string) {
|
|
10
6
|
if (!alias) {
|
|
11
|
-
|
|
7
|
+
console.error(chalk.red('Alias is required'));
|
|
8
|
+
process.exit(1);
|
|
12
9
|
}
|
|
13
10
|
const connection = getConnectionByAlias(alias);
|
|
14
|
-
if (connection) {
|
|
15
|
-
runSSH(alias, connection);
|
|
16
|
-
} else {
|
|
11
|
+
if (!connection) {
|
|
17
12
|
console.error(chalk.red('Alias not found'));
|
|
13
|
+
process.exit(1);
|
|
18
14
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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);
|
|
24
20
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
if (connection.port) {
|
|
22
|
+
parts.push('-p', String(connection.port));
|
|
23
|
+
}
|
|
24
|
+
// Always add -t for interactive terminal allocation (required for fzf integration)
|
|
25
|
+
parts.push('-t');
|
|
26
|
+
parts.push(`${connection.user}@${connection.host}`);
|
|
27
|
+
if (connection.remote_path) {
|
|
28
|
+
const remoteCommand = buildRemoteCommand({
|
|
29
|
+
remotePath: String(connection.remote_path),
|
|
30
|
+
postCommand: 'exec "$SHELL" -l',
|
|
31
|
+
});
|
|
32
|
+
if (remoteCommand) {
|
|
33
|
+
parts.push(remoteCommand);
|
|
34
|
+
}
|
|
31
35
|
}
|
|
36
|
+
console.log(parts.join(' '));
|
|
32
37
|
}
|
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
|
}
|
package/src/helpers/fzf.ts
CHANGED
|
@@ -4,9 +4,9 @@ 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
|
-
'
|
|
7
|
+
'_fzf_sshya() {',
|
|
8
8
|
' local line alias userhost command oldstty',
|
|
9
|
-
' #
|
|
9
|
+
' # Disable XON/XOFF for Ctrl-S binding',
|
|
10
10
|
' oldstty=$(stty -g 2>/dev/null)',
|
|
11
11
|
' stty -ixon 2>/dev/null || true',
|
|
12
12
|
' run_sshya() {',
|
|
@@ -19,12 +19,11 @@ export function printFzfInstructions() {
|
|
|
19
19
|
" line=$(run_sshya list --oneline --names | fzf --tac --with-nth=1,2 --delimiter=$'\\t') || { stty \"$oldstty\" 2>/dev/null || true; return; }",
|
|
20
20
|
" alias=${line%%$'\\t'*}",
|
|
21
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
22
|
" command=$(run_sshya print \"$alias\")",
|
|
23
|
+
" echo \"Connecting: $alias ($userhost)\"",
|
|
26
24
|
" if [ -n \"$ZSH_VERSION\" ]; then",
|
|
27
|
-
"
|
|
25
|
+
" local -a _args; _args=(${(zQ)command})",
|
|
26
|
+
" ssh \"${_args[@]}\"",
|
|
28
27
|
" zle -R -c",
|
|
29
28
|
" else",
|
|
30
29
|
" read -r -a __args <<< \"$command\"",
|
|
@@ -32,14 +31,14 @@ export function printFzfInstructions() {
|
|
|
32
31
|
" fi",
|
|
33
32
|
" stty \"$oldstty\" 2>/dev/null || true",
|
|
34
33
|
'}',
|
|
35
|
-
'#
|
|
36
|
-
'zle -N
|
|
37
|
-
"bindkey '^S'
|
|
34
|
+
'# Zsh widget binding',
|
|
35
|
+
'zle -N _fzf_sshya',
|
|
36
|
+
"bindkey '^S' _fzf_sshya",
|
|
38
37
|
].join('\n');
|
|
39
38
|
console.log(chalk.cyan(snippet));
|
|
40
39
|
console.log('\n' + chalk.green('Notes:'));
|
|
41
40
|
console.log('- This expects ' + chalk.bold('fzf') + ' to be installed.');
|
|
42
|
-
console.log('- On bash, you can bind with: ' + chalk.cyan("bind -x '" + '\\"\\u0013\\":
|
|
41
|
+
console.log('- On bash, you can bind with: ' + chalk.cyan("bind -x '" + '\\"\\u0013\\":_fzf_sshya' + "'"));
|
|
43
42
|
console.log('- If Ctrl-S is flow control, disable with: ' + chalk.cyan('stty -ixon'));
|
|
44
43
|
process.exit(0);
|
|
45
44
|
}
|
|
@@ -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
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { ChildProcess, spawn } from 'child_process';
|
|
2
|
-
import type { Connection } from './database';
|
|
3
|
-
import { recordConnectionUsage } from './database';
|
|
4
|
-
|
|
5
|
-
// Track the currently running SSH child-process so other parts of the program
|
|
6
|
-
// (e.g. the global key-handler) can know whether we are inside an SSH session.
|
|
7
|
-
let currentSshProcess: ChildProcess | null = null;
|
|
8
|
-
|
|
9
|
-
export function isSshSessionActive(): boolean {
|
|
10
|
-
return currentSshProcess !== null;
|
|
11
|
-
}
|
|
12
|
-
|
|
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}`);
|
|
18
|
-
return args;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function runSSH(alias: string, connection: Connection) {
|
|
22
|
-
recordConnectionUsage(alias);
|
|
23
|
-
const args = buildSSHArgs(connection);
|
|
24
|
-
|
|
25
|
-
const sshProcess = spawn('ssh', args, {
|
|
26
|
-
stdio: 'pipe', // Full control over stdio
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
// Mark session active so that other modules can detect we are now inside an
|
|
30
|
-
// SSH session.
|
|
31
|
-
currentSshProcess = sshProcess;
|
|
32
|
-
|
|
33
|
-
if (process.stdin.isTTY) {
|
|
34
|
-
process.stdin.setRawMode(true);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
process.stdin.pipe(sshProcess.stdin);
|
|
38
|
-
sshProcess.stdout.pipe(process.stdout);
|
|
39
|
-
sshProcess.stderr.pipe(process.stderr);
|
|
40
|
-
|
|
41
|
-
const onResize = () => {
|
|
42
|
-
if (sshProcess.pid) {
|
|
43
|
-
try {
|
|
44
|
-
process.kill(sshProcess.pid, 'SIGWINCH');
|
|
45
|
-
} catch {
|
|
46
|
-
/* Process already exited – ignore */
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const cleanup = (code?: number | null) => {
|
|
52
|
-
if (process.stdout.isTTY) {
|
|
53
|
-
process.stdout.removeListener('resize', onResize);
|
|
54
|
-
}
|
|
55
|
-
if (process.stdin.isTTY) {
|
|
56
|
-
process.stdin.setRawMode(false);
|
|
57
|
-
}
|
|
58
|
-
process.stdin.unpipe(sshProcess.stdin);
|
|
59
|
-
// Mark session ended
|
|
60
|
-
currentSshProcess = null;
|
|
61
|
-
process.exit(code ?? 0);
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
if (process.stdout.isTTY) {
|
|
65
|
-
process.stdout.on('resize', onResize);
|
|
66
|
-
onResize();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
sshProcess.on('exit', cleanup);
|
|
70
|
-
sshProcess.on('error', () => cleanup(1));
|
|
71
|
-
}
|
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
|
-
}
|