sshya 0.1.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 +31 -0
- package/bun.lock +126 -0
- package/index.ts +111 -0
- package/package.json +32 -0
- package/src/connection/add.ts +62 -0
- package/src/connection/export.ts +14 -0
- package/src/connection/import.ts +70 -0
- package/src/connection/index.ts +8 -0
- package/src/connection/list.ts +67 -0
- package/src/connection/remove.ts +11 -0
- package/src/connection/test.ts +46 -0
- package/src/connection/update.ts +59 -0
- package/src/database.ts +106 -0
- package/src/helpers/connectInteractive.ts +20 -0
- package/src/helpers/escExit.ts +52 -0
- package/src/helpers/selectAlias.ts +47 -0
- package/src/ssh.ts +76 -0
- package/src/sshCommandHandler.ts +93 -0
- package/tsconfig.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Project: sshya
|
|
2
|
+
|
|
3
|
+
This project is a command-line interface (CLI) tool named `sshya` for managing SSH connections.
|
|
4
|
+
|
|
5
|
+
## Core Functionality
|
|
6
|
+
- Add, remove, update, and list SSH connection configurations (alias, user, host, port, key path).
|
|
7
|
+
- Connect to saved SSH connections.
|
|
8
|
+
- Import and export connections from/to a JSON file.
|
|
9
|
+
- A key feature is the interactive `ssh` connection, which has been optimized to provide a smooth, responsive typing experience and correct terminal resizing behavior.
|
|
10
|
+
|
|
11
|
+
## Technology Stack
|
|
12
|
+
- **Runtime:** Bun
|
|
13
|
+
- **Language:** TypeScript
|
|
14
|
+
- **Dependencies:**
|
|
15
|
+
- `commander`: For command-line argument parsing.
|
|
16
|
+
- `inquirer`: For interactive prompts.
|
|
17
|
+
- `chalk`: for terminal string styling.
|
|
18
|
+
- `fuse.js`: for fuzzy searching connections.
|
|
19
|
+
- `zod`: for validating user input and imported data.
|
|
20
|
+
|
|
21
|
+
## Implementation Details
|
|
22
|
+
The main application logic is in `index.ts`. It uses `child_process.spawn` to run the `ssh` command. Significant effort was made to fix issues with the interactive SSH session, including:
|
|
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.
|
package/bun.lock
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"workspaces": {
|
|
4
|
+
"": {
|
|
5
|
+
"name": "sshya",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@inquirer/search": "^3.0.17",
|
|
8
|
+
"chalk": "^5.4.1",
|
|
9
|
+
"commander": "^14.0.0",
|
|
10
|
+
"fuse.js": "^7.0.0",
|
|
11
|
+
"inquirer": "^12.0.0",
|
|
12
|
+
"zod": "^3.22.2",
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/bun": "latest",
|
|
16
|
+
"@types/chalk": "^2.2.4",
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"typescript": "^5.0.0",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
"packages": {
|
|
24
|
+
"@inquirer/checkbox": ["@inquirer/checkbox@4.2.0", "", { "dependencies": { "@inquirer/core": "^10.1.15", "@inquirer/figures": "^1.0.13", "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-fdSw07FLJEU5vbpOPzXo5c6xmMGDzbZE2+niuDHX5N6mc6V0Ebso/q3xiHra4D73+PMsC8MJmcaZKuAAoaQsSA=="],
|
|
25
|
+
|
|
26
|
+
"@inquirer/confirm": ["@inquirer/confirm@5.1.14", "", { "dependencies": { "@inquirer/core": "^10.1.15", "@inquirer/type": "^3.0.8" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q=="],
|
|
27
|
+
|
|
28
|
+
"@inquirer/core": ["@inquirer/core@10.1.15", "", { "dependencies": { "@inquirer/figures": "^1.0.13", "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-8xrp836RZvKkpNbVvgWUlxjT4CraKk2q+I3Ksy+seI2zkcE+y6wNs1BVhgcv8VyImFecUhdQrYLdW32pAjwBdA=="],
|
|
29
|
+
|
|
30
|
+
"@inquirer/editor": ["@inquirer/editor@4.2.15", "", { "dependencies": { "@inquirer/core": "^10.1.15", "@inquirer/type": "^3.0.8", "external-editor": "^3.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-wst31XT8DnGOSS4nNJDIklGKnf+8shuauVrWzgKegWUe28zfCftcWZ2vktGdzJgcylWSS2SrDnYUb6alZcwnCQ=="],
|
|
31
|
+
|
|
32
|
+
"@inquirer/expand": ["@inquirer/expand@4.0.17", "", { "dependencies": { "@inquirer/core": "^10.1.15", "@inquirer/type": "^3.0.8", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PSqy9VmJx/VbE3CT453yOfNa+PykpKg/0SYP7odez1/NWBGuDXgPhp4AeGYYKjhLn5lUUavVS/JbeYMPdH50Mw=="],
|
|
33
|
+
|
|
34
|
+
"@inquirer/figures": ["@inquirer/figures@1.0.13", "", {}, "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw=="],
|
|
35
|
+
|
|
36
|
+
"@inquirer/input": ["@inquirer/input@4.2.1", "", { "dependencies": { "@inquirer/core": "^10.1.15", "@inquirer/type": "^3.0.8" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-tVC+O1rBl0lJpoUZv4xY+WGWY8V5b0zxU1XDsMsIHYregdh7bN5X5QnIONNBAl0K765FYlAfNHS2Bhn7SSOVow=="],
|
|
37
|
+
|
|
38
|
+
"@inquirer/number": ["@inquirer/number@3.0.17", "", { "dependencies": { "@inquirer/core": "^10.1.15", "@inquirer/type": "^3.0.8" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-GcvGHkyIgfZgVnnimURdOueMk0CztycfC8NZTiIY9arIAkeOgt6zG57G+7vC59Jns3UX27LMkPKnKWAOF5xEYg=="],
|
|
39
|
+
|
|
40
|
+
"@inquirer/password": ["@inquirer/password@4.0.17", "", { "dependencies": { "@inquirer/core": "^10.1.15", "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-DJolTnNeZ00E1+1TW+8614F7rOJJCM4y4BAGQ3Gq6kQIG+OJ4zr3GLjIjVVJCbKsk2jmkmv6v2kQuN/vriHdZA=="],
|
|
41
|
+
|
|
42
|
+
"@inquirer/prompts": ["@inquirer/prompts@7.7.1", "", { "dependencies": { "@inquirer/checkbox": "^4.2.0", "@inquirer/confirm": "^5.1.14", "@inquirer/editor": "^4.2.15", "@inquirer/expand": "^4.0.17", "@inquirer/input": "^4.2.1", "@inquirer/number": "^3.0.17", "@inquirer/password": "^4.0.17", "@inquirer/rawlist": "^4.1.5", "@inquirer/search": "^3.0.17", "@inquirer/select": "^4.3.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XDxPrEWeWUBy8scAXzXuFY45r/q49R0g72bUzgQXZ1DY/xEFX+ESDMkTQolcb5jRBzaNJX2W8XQl6krMNDTjaA=="],
|
|
43
|
+
|
|
44
|
+
"@inquirer/rawlist": ["@inquirer/rawlist@4.1.5", "", { "dependencies": { "@inquirer/core": "^10.1.15", "@inquirer/type": "^3.0.8", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-R5qMyGJqtDdi4Ht521iAkNqyB6p2UPuZUbMifakg1sWtu24gc2Z8CJuw8rP081OckNDMgtDCuLe42Q2Kr3BolA=="],
|
|
45
|
+
|
|
46
|
+
"@inquirer/search": ["@inquirer/search@3.0.17", "", { "dependencies": { "@inquirer/core": "^10.1.15", "@inquirer/figures": "^1.0.13", "@inquirer/type": "^3.0.8", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-CuBU4BAGFqRYors4TNCYzy9X3DpKtgIW4Boi0WNkm4Ei1hvY9acxKdBdyqzqBCEe4YxSdaQQsasJlFlUJNgojw=="],
|
|
47
|
+
|
|
48
|
+
"@inquirer/select": ["@inquirer/select@4.3.1", "", { "dependencies": { "@inquirer/core": "^10.1.15", "@inquirer/figures": "^1.0.13", "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Gfl/5sqOF5vS/LIrSndFgOh7jgoe0UXEizDqahFRkq5aJBLegZ6WjuMh/hVEJwlFQjyLq1z9fRtvUMkb7jM1LA=="],
|
|
49
|
+
|
|
50
|
+
"@inquirer/type": ["@inquirer/type@3.0.8", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw=="],
|
|
51
|
+
|
|
52
|
+
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
|
|
53
|
+
|
|
54
|
+
"@types/chalk": ["@types/chalk@2.2.4", "", { "dependencies": { "chalk": "*" } }, "sha512-pb/QoGqtCpH2famSp72qEsXkNzcErlVmiXlQ/ww+5AddD8TmmYS7EWg5T20YiNCAiTgs8pMf2G8SJG5h/ER1ZQ=="],
|
|
55
|
+
|
|
56
|
+
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
|
|
57
|
+
|
|
58
|
+
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
|
59
|
+
|
|
60
|
+
"ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
|
|
61
|
+
|
|
62
|
+
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
|
63
|
+
|
|
64
|
+
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
|
65
|
+
|
|
66
|
+
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
|
|
67
|
+
|
|
68
|
+
"chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
|
|
69
|
+
|
|
70
|
+
"chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="],
|
|
71
|
+
|
|
72
|
+
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
|
|
73
|
+
|
|
74
|
+
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
|
75
|
+
|
|
76
|
+
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
|
77
|
+
|
|
78
|
+
"commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
|
|
79
|
+
|
|
80
|
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
|
81
|
+
|
|
82
|
+
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
|
83
|
+
|
|
84
|
+
"external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="],
|
|
85
|
+
|
|
86
|
+
"fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
|
|
87
|
+
|
|
88
|
+
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
|
89
|
+
|
|
90
|
+
"inquirer": ["inquirer@12.8.2", "", { "dependencies": { "@inquirer/core": "^10.1.15", "@inquirer/prompts": "^7.7.1", "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", "mute-stream": "^2.0.0", "run-async": "^4.0.5", "rxjs": "^7.8.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-oBDL9f4+cDambZVJdfJu2M5JQfvaug9lbo6fKDlFV40i8t3FGA1Db67ov5Hp5DInG4zmXhHWTSnlXBntnJ7GMA=="],
|
|
91
|
+
|
|
92
|
+
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
|
93
|
+
|
|
94
|
+
"mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="],
|
|
95
|
+
|
|
96
|
+
"os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="],
|
|
97
|
+
|
|
98
|
+
"run-async": ["run-async@4.0.5", "", {}, "sha512-oN9GTgxUNDBumHTTDmQ8dep6VIJbgj9S3dPP+9XylVLIK4xB9XTXtKWROd5pnhdXR9k0EgO1JRcNh0T+Ny2FsA=="],
|
|
99
|
+
|
|
100
|
+
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
|
101
|
+
|
|
102
|
+
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
|
103
|
+
|
|
104
|
+
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
|
105
|
+
|
|
106
|
+
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
|
107
|
+
|
|
108
|
+
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
|
109
|
+
|
|
110
|
+
"tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="],
|
|
111
|
+
|
|
112
|
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
113
|
+
|
|
114
|
+
"type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
|
115
|
+
|
|
116
|
+
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
|
117
|
+
|
|
118
|
+
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
|
119
|
+
|
|
120
|
+
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
|
121
|
+
|
|
122
|
+
"yoctocolors-cjs": ["yoctocolors-cjs@2.1.2", "", {}, "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA=="],
|
|
123
|
+
|
|
124
|
+
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
|
125
|
+
}
|
|
126
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { addConnectionPrompt, exportConnectionsPrompt, importConnectionsPrompt, listConnectionsPrompt, removeConnectionPrompt, testConnectionPrompt, updateConnectionPrompt } from './src/connection';
|
|
6
|
+
import { initDB } from './src/database';
|
|
7
|
+
import { connectInteractive } from './src/helpers/connectInteractive';
|
|
8
|
+
import { enableEscapeExit } from './src/helpers/escExit';
|
|
9
|
+
import { handleSshCommand } from './src/sshCommandHandler';
|
|
10
|
+
|
|
11
|
+
import pkg from './package.json' assert { type: "json" };
|
|
12
|
+
const version = pkg?.version ?? '1.0.0';
|
|
13
|
+
|
|
14
|
+
enableEscapeExit();
|
|
15
|
+
initDB();
|
|
16
|
+
|
|
17
|
+
const program = new Command();
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.version(version)
|
|
21
|
+
.description('A simple CLI to manage your SSH connections');
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command('connect [alias]')
|
|
25
|
+
.description('Connect to an SSH connection by alias')
|
|
26
|
+
.action(connectInteractive);
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.command('add')
|
|
30
|
+
.aliases(['create', 'new'])
|
|
31
|
+
.description('Add a new SSH connection')
|
|
32
|
+
.action(addConnectionPrompt);
|
|
33
|
+
|
|
34
|
+
program
|
|
35
|
+
.command('list')
|
|
36
|
+
.aliases(['ps', 'ls'])
|
|
37
|
+
.description('List all SSH connections')
|
|
38
|
+
.action(listConnectionsPrompt);
|
|
39
|
+
|
|
40
|
+
program
|
|
41
|
+
.command('remove [alias]')
|
|
42
|
+
.aliases(['delete', 'rm'])
|
|
43
|
+
.description('Remove an SSH connection by alias')
|
|
44
|
+
.action(removeConnectionPrompt);
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.command('update [alias]')
|
|
48
|
+
.aliases(['edit'])
|
|
49
|
+
.description('Update an SSH connection by alias')
|
|
50
|
+
.action(updateConnectionPrompt);
|
|
51
|
+
|
|
52
|
+
program
|
|
53
|
+
.command('test [alias]')
|
|
54
|
+
.description('Test an SSH connection by alias')
|
|
55
|
+
.action(testConnectionPrompt);
|
|
56
|
+
|
|
57
|
+
program
|
|
58
|
+
.command('import [file]')
|
|
59
|
+
.description('Import connections from a JSON file')
|
|
60
|
+
.action(importConnectionsPrompt);
|
|
61
|
+
|
|
62
|
+
program
|
|
63
|
+
.command('export [file]')
|
|
64
|
+
.description('Export connections to a JSON file (default: sshm-export.json)')
|
|
65
|
+
.action(exportConnectionsPrompt);
|
|
66
|
+
|
|
67
|
+
program
|
|
68
|
+
.command('path')
|
|
69
|
+
.description('Provides instructions to make the CLI globally accessible')
|
|
70
|
+
.action(() => {
|
|
71
|
+
console.log(chalk.yellow('To make the ' + chalk.bold('s') + ' command globally accessible:'));
|
|
72
|
+
console.log('\n' + chalk.bold('Option 1: Permanent Installation (Recommended)') + '\n');
|
|
73
|
+
console.log('1. Build the executable: ' + chalk.cyan('bun run build'));
|
|
74
|
+
console.log('2. Move the executable to a PATH directory (e.g., /usr/local/bin):');
|
|
75
|
+
console.log(chalk.cyan(' sudo mv bin/s /usr/local/bin/s'));
|
|
76
|
+
console.log('\n' + chalk.green('After these steps, you should be able to run ' + chalk.bold('s') + ' from any directory.'));
|
|
77
|
+
|
|
78
|
+
console.log('\n' + chalk.bold('Option 2: Temporary for Current Session') + '\n');
|
|
79
|
+
console.log('Add the current directory to your PATH for the current terminal session:');
|
|
80
|
+
console.log(chalk.cyan(` export PATH="$PATH:${process.cwd()}/bin"`));
|
|
81
|
+
console.log('\n' + chalk.green('This will allow you to run ' + chalk.bold('s') + ' from this directory until you close the terminal.'));
|
|
82
|
+
process.exit(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
program
|
|
86
|
+
.command('about')
|
|
87
|
+
.description('About this CLI')
|
|
88
|
+
.action(() => {
|
|
89
|
+
console.log(chalk.bold('SSHya CLI'));
|
|
90
|
+
console.log(chalk.gray(`Version ${version}`));
|
|
91
|
+
console.log(chalk.gray('https://github.com/ceneka/sshya'));
|
|
92
|
+
process.exit(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Clean Bun compile shim arguments (e.g., "bun run index.ts") when running the compiled binary
|
|
96
|
+
const rawArgs = process.argv.slice(2);
|
|
97
|
+
const looksLikeBunShim = rawArgs[0] === 'bun' && rawArgs[1] === 'run';
|
|
98
|
+
const userArgs = looksLikeBunShim ? rawArgs.slice(3) : rawArgs;
|
|
99
|
+
|
|
100
|
+
const isSshCommand = userArgs.length > 0 && (userArgs[0] === 'ssh' || userArgs.some(arg => arg.includes('@')));
|
|
101
|
+
|
|
102
|
+
if (isSshCommand) {
|
|
103
|
+
await handleSshCommand(userArgs);
|
|
104
|
+
} else if (userArgs.length === 0) {
|
|
105
|
+
// No user-specified subcommand -> interactive connect
|
|
106
|
+
await connectInteractive();
|
|
107
|
+
} else {
|
|
108
|
+
// Reconstruct argv array expected by commander: [node, script, ...userArgs]
|
|
109
|
+
const argvForCommander: string[] = [process.argv[0] ?? '', process.argv[1] ?? '', ...userArgs];
|
|
110
|
+
program.parse(argvForCommander);
|
|
111
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sshya",
|
|
3
|
+
"module": "index.ts",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"devDependencies": {
|
|
7
|
+
"@types/bun": "latest",
|
|
8
|
+
"@types/chalk": "^2.2.4"
|
|
9
|
+
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"typescript": "^5.0.0"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "bun run index.ts",
|
|
15
|
+
"build": "bun build index.ts --compile --outfile bin/s"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@inquirer/search": "^3.0.17",
|
|
19
|
+
"chalk": "^5.4.1",
|
|
20
|
+
"commander": "^14.0.0",
|
|
21
|
+
"fuse.js": "^7.0.0",
|
|
22
|
+
"inquirer": "^12.0.0",
|
|
23
|
+
"zod": "^3.22.2"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.2.0"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"sshya": "index.ts",
|
|
30
|
+
"s": "index.ts"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import { addConnection, connectionSchema } from "../database";
|
|
4
|
+
import { testConnectionPrompt } from "./test";
|
|
5
|
+
|
|
6
|
+
export async function addConnectionPrompt() {
|
|
7
|
+
const answers = await inquirer.prompt([
|
|
8
|
+
{
|
|
9
|
+
type: 'input',
|
|
10
|
+
name: 'alias',
|
|
11
|
+
message: 'Alias:',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
type: 'input',
|
|
15
|
+
name: 'user',
|
|
16
|
+
message: 'User:',
|
|
17
|
+
default: 'root',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
type: 'input',
|
|
21
|
+
name: 'host',
|
|
22
|
+
message: 'Host/IP:',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
type: 'input',
|
|
26
|
+
name: 'port',
|
|
27
|
+
message: 'Port (optional):',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
type: 'input',
|
|
31
|
+
name: 'key_path',
|
|
32
|
+
message: 'Path to key (optional):',
|
|
33
|
+
},
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const parsed = connectionSchema.safeParse(answers);
|
|
37
|
+
if (!parsed.success) {
|
|
38
|
+
console.error(chalk.red('Invalid input:'), parsed.error.errors.map(e => e.message).join(', '));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const { alias, user, host, key_path, port } = parsed.data;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
addConnection(alias, user, host, key_path, port ? String(port) : undefined);
|
|
45
|
+
console.log(chalk.green('Connection added successfully'));
|
|
46
|
+
|
|
47
|
+
const { test } = await inquirer.prompt([
|
|
48
|
+
{
|
|
49
|
+
type: 'confirm',
|
|
50
|
+
name: 'test',
|
|
51
|
+
message: 'Do you want to test the new connection now?',
|
|
52
|
+
default: true,
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
if (test) {
|
|
57
|
+
await testConnectionPrompt(alias);
|
|
58
|
+
}
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
console.error(chalk.red(err.message));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { getConnections } from '../database';
|
|
4
|
+
|
|
5
|
+
export const exportConnectionsPrompt = (file: string = 'sshm-export.json') => {
|
|
6
|
+
const connections = getConnections();
|
|
7
|
+
if (connections.length === 0) {
|
|
8
|
+
console.log(chalk.yellow('No connections to export.'));
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
fs.writeFileSync(file, JSON.stringify(connections, null, 2));
|
|
12
|
+
console.log(chalk.green(`Successfully exported ${connections.length} connections to ${file}`));
|
|
13
|
+
process.exit(0);
|
|
14
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import { addConnection, connectionSchema, getConnectionByAlias, updateConnection } from '../database';
|
|
5
|
+
|
|
6
|
+
export const importConnectionsPrompt = async (file: string) => {
|
|
7
|
+
if (!fs.existsSync(file)) {
|
|
8
|
+
console.error(chalk.red(`File not found: ${file}`));
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const fileContent = fs.readFileSync(file, 'utf-8');
|
|
14
|
+
const connectionsToImport = JSON.parse(fileContent);
|
|
15
|
+
|
|
16
|
+
if (!Array.isArray(connectionsToImport)) {
|
|
17
|
+
console.error(chalk.red('Invalid file format. Expected a JSON array of connections.'));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let importedCount = 0;
|
|
22
|
+
let skippedCount = 0;
|
|
23
|
+
let updatedCount = 0;
|
|
24
|
+
|
|
25
|
+
console.log(`Found ${connectionsToImport.length} connections to import.`);
|
|
26
|
+
|
|
27
|
+
for (const conn of connectionsToImport) {
|
|
28
|
+
const validation = connectionSchema.safeParse(conn);
|
|
29
|
+
if (!validation.success) {
|
|
30
|
+
console.log(chalk.yellow(`Skipping invalid connection object: ${JSON.stringify(conn)}`));
|
|
31
|
+
skippedCount++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const parsedConn = validation.data;
|
|
35
|
+
|
|
36
|
+
const existingConn = getConnectionByAlias(parsedConn.alias);
|
|
37
|
+
if (existingConn) {
|
|
38
|
+
const { action } = await inquirer.prompt([
|
|
39
|
+
{
|
|
40
|
+
type: 'list',
|
|
41
|
+
name: 'action',
|
|
42
|
+
message: `Connection with alias "${parsedConn.alias}" already exists. What do you want to do?`,
|
|
43
|
+
choices: ['Skip', 'Overwrite'],
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
if (action === 'Skip') {
|
|
48
|
+
skippedCount++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (action === 'Overwrite') {
|
|
53
|
+
updateConnection(parsedConn.alias, parsedConn.user, parsedConn.host, parsedConn.key_path ?? '', parsedConn.port ? String(parsedConn.port) : undefined);
|
|
54
|
+
updatedCount++;
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
addConnection(parsedConn.alias, parsedConn.user, parsedConn.host, parsedConn.key_path, parsedConn.port ? String(parsedConn.port) : undefined);
|
|
58
|
+
importedCount++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(chalk.green('\nImport complete!'));
|
|
63
|
+
console.log(`- ${importedCount} new connections added.`);
|
|
64
|
+
console.log(`- ${updatedCount} connections overwritten.`);
|
|
65
|
+
console.log(`- ${skippedCount} connections skipped.`);
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
console.error(chalk.red(`Error reading or parsing file: ${err instanceof Error ? err.message : String(err)}`));
|
|
68
|
+
}
|
|
69
|
+
process.exit(0);
|
|
70
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getConnections } from "../database";
|
|
3
|
+
|
|
4
|
+
export async function listConnectionsPrompt() {
|
|
5
|
+
const connections = getConnections();
|
|
6
|
+
if (connections.length === 0) {
|
|
7
|
+
console.log(chalk.yellow('No connections found. Add one with "sshya add"'));
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const maxLengths = {
|
|
12
|
+
alias: 'Alias'.length,
|
|
13
|
+
user: 'User'.length,
|
|
14
|
+
host: 'Host'.length,
|
|
15
|
+
port: 'Port'.length,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
for (const c of connections) {
|
|
19
|
+
if (c.alias.length > maxLengths.alias) {
|
|
20
|
+
maxLengths.alias = c.alias.length;
|
|
21
|
+
}
|
|
22
|
+
if (c.user.length > maxLengths.user) {
|
|
23
|
+
maxLengths.user = c.user.length;
|
|
24
|
+
}
|
|
25
|
+
if (c.host.length > maxLengths.host) {
|
|
26
|
+
maxLengths.host = c.host.length;
|
|
27
|
+
}
|
|
28
|
+
if (c.port && String(c.port).length > maxLengths.port) {
|
|
29
|
+
maxLengths.port = String(c.port).length;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const header =
|
|
34
|
+
chalk.bold('Alias'.padEnd(maxLengths.alias)) +
|
|
35
|
+
' ' +
|
|
36
|
+
chalk.bold('User'.padEnd(maxLengths.user)) +
|
|
37
|
+
' ' +
|
|
38
|
+
chalk.bold('Host'.padEnd(maxLengths.host)) +
|
|
39
|
+
' ' +
|
|
40
|
+
chalk.bold('Port'.padEnd(maxLengths.port));
|
|
41
|
+
console.log(header);
|
|
42
|
+
|
|
43
|
+
const separator =
|
|
44
|
+
'-'.repeat(maxLengths.alias) +
|
|
45
|
+
' ' +
|
|
46
|
+
'-'.repeat(maxLengths.user) +
|
|
47
|
+
' ' +
|
|
48
|
+
'-'.repeat(maxLengths.host) +
|
|
49
|
+
' ' +
|
|
50
|
+
'-'.repeat(maxLengths.port);
|
|
51
|
+
console.log(separator);
|
|
52
|
+
|
|
53
|
+
for (const c of connections) {
|
|
54
|
+
const port = c.port ? String(c.port) : '';
|
|
55
|
+
const row =
|
|
56
|
+
c.alias.padEnd(maxLengths.alias) +
|
|
57
|
+
' ' +
|
|
58
|
+
c.user.padEnd(maxLengths.user) +
|
|
59
|
+
' ' +
|
|
60
|
+
c.host.padEnd(maxLengths.host) +
|
|
61
|
+
' ' +
|
|
62
|
+
port.padEnd(maxLengths.port);
|
|
63
|
+
console.log(row);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { removeConnection } from "../database";
|
|
3
|
+
import { selectAlias } from "../helpers/selectAlias";
|
|
4
|
+
|
|
5
|
+
export async function removeConnectionPrompt(alias?: string) {
|
|
6
|
+
if (!alias) {
|
|
7
|
+
alias = await selectAlias('Select a connection to remove');
|
|
8
|
+
}
|
|
9
|
+
removeConnection(alias);
|
|
10
|
+
console.log(chalk.green('Connection removed successfully'));
|
|
11
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import { getConnectionByAlias } from '../database';
|
|
4
|
+
import { selectAlias } from '../helpers/selectAlias';
|
|
5
|
+
|
|
6
|
+
export async function testConnectionPrompt(alias?: string): Promise<void> {
|
|
7
|
+
|
|
8
|
+
if (!alias) {
|
|
9
|
+
alias = await selectAlias('Select a connection to test');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const connection = getConnectionByAlias(alias);
|
|
13
|
+
if (!connection) {
|
|
14
|
+
console.error(chalk.red('Alias not found'));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { user, host, key_path, port } = connection;
|
|
19
|
+
console.log(`\nTesting connection to ${chalk.blue(alias)}...`);
|
|
20
|
+
|
|
21
|
+
const testPromise = new Promise<{ code: number | null; stderr: string }>((resolve) => {
|
|
22
|
+
const args = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5'];
|
|
23
|
+
if (key_path) args.push('-i', key_path);
|
|
24
|
+
if (port) args.push('-p', String(port));
|
|
25
|
+
args.push(`${user}@${host}`, 'exit');
|
|
26
|
+
|
|
27
|
+
const child = spawn('ssh', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
28
|
+
let stderr = '';
|
|
29
|
+
child.stderr.on('data', (data) => (stderr += data.toString()));
|
|
30
|
+
child.on('close', (code) => resolve({ code, stderr }));
|
|
31
|
+
child.on('error', (err) => resolve({ code: -1, stderr: err.message }));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const { code, stderr } = await testPromise;
|
|
35
|
+
|
|
36
|
+
if (code === 0) {
|
|
37
|
+
console.log(chalk.green('Connection successful!'));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.error(chalk.red('Connection failed.'));
|
|
42
|
+
if (stderr) {
|
|
43
|
+
console.error(chalk.grey(stderr.trim()));
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import { getConnectionByAlias, updateConnection } from "../database";
|
|
4
|
+
import { selectAlias } from "../helpers/selectAlias";
|
|
5
|
+
import { testConnectionPrompt } from "./test";
|
|
6
|
+
|
|
7
|
+
export async function updateConnectionPrompt(alias?: string) {
|
|
8
|
+
// If alias is not provided, prompt the user to pick one interactively
|
|
9
|
+
if (!alias) {
|
|
10
|
+
alias = await selectAlias('Select a connection to edit');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const connection = getConnectionByAlias(alias);
|
|
14
|
+
if (connection) {
|
|
15
|
+
const answers = await inquirer.prompt([
|
|
16
|
+
{
|
|
17
|
+
type: 'input',
|
|
18
|
+
name: 'user',
|
|
19
|
+
message: 'User:',
|
|
20
|
+
default: connection.user,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
type: 'input',
|
|
24
|
+
name: 'host',
|
|
25
|
+
message: 'Host/IP:',
|
|
26
|
+
default: connection.host,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
type: 'input',
|
|
30
|
+
name: 'port',
|
|
31
|
+
message: 'Port (optional):',
|
|
32
|
+
default: connection.port,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
type: 'input',
|
|
36
|
+
name: 'key_path',
|
|
37
|
+
message: 'Path to key (optional):',
|
|
38
|
+
default: connection.key_path,
|
|
39
|
+
},
|
|
40
|
+
]);
|
|
41
|
+
updateConnection(alias, answers.user, answers.host, answers.key_path, answers.port);
|
|
42
|
+
console.log(chalk.green('Connection updated successfully'));
|
|
43
|
+
|
|
44
|
+
const { test } = await inquirer.prompt([
|
|
45
|
+
{
|
|
46
|
+
type: 'confirm',
|
|
47
|
+
name: 'test',
|
|
48
|
+
message: 'Do you want to test the updated connection now?',
|
|
49
|
+
default: true,
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
if (test) {
|
|
54
|
+
await testConnectionPrompt(alias);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
console.error(chalk.red('Alias not found'));
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/database.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
const dbDir = path.join(os.homedir(), '.sshya');
|
|
7
|
+
const dbFilePath = path.join(dbDir, 'sshm.json');
|
|
8
|
+
|
|
9
|
+
interface Connection {
|
|
10
|
+
id: number;
|
|
11
|
+
alias: string;
|
|
12
|
+
user: string;
|
|
13
|
+
host: string;
|
|
14
|
+
key_path?: string;
|
|
15
|
+
port?: string;
|
|
16
|
+
lastUsed?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const connectionSchema = z.object({
|
|
20
|
+
alias: z.string().min(1),
|
|
21
|
+
user: z.string().min(1),
|
|
22
|
+
host: z.string().min(1),
|
|
23
|
+
key_path: z.string().optional(),
|
|
24
|
+
port: z.union([z.string(), z.number()]).optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const readDB = (): Connection[] => {
|
|
28
|
+
if (!fs.existsSync(dbFilePath)) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const data = fs.readFileSync(dbFilePath, 'utf-8');
|
|
32
|
+
return JSON.parse(data);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const writeDB = (data: Connection[]) => {
|
|
36
|
+
// Ensure the directory exists before writing
|
|
37
|
+
if (!fs.existsSync(dbDir)) {
|
|
38
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
fs.writeFileSync(dbFilePath, JSON.stringify(data, null, 2));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const initDB = () => {
|
|
44
|
+
// Create base directory if it doesn't exist
|
|
45
|
+
if (!fs.existsSync(dbDir)) {
|
|
46
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Initialize empty database file if it does not exist
|
|
50
|
+
if (!fs.existsSync(dbFilePath)) {
|
|
51
|
+
writeDB([]);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const addConnection = (alias: string, user: string, host: string, key_path?: string, port?: string) => {
|
|
56
|
+
const connections = readDB();
|
|
57
|
+
if (connections.some(c => c.alias === alias)) {
|
|
58
|
+
throw new Error(`Connection with alias "${alias}" already exists.`);
|
|
59
|
+
}
|
|
60
|
+
const newConnection: Connection = {
|
|
61
|
+
id: connections.length > 0 ? Math.max(...connections.map(c => c.id)) + 1 : 1,
|
|
62
|
+
alias,
|
|
63
|
+
user,
|
|
64
|
+
host,
|
|
65
|
+
key_path,
|
|
66
|
+
port,
|
|
67
|
+
};
|
|
68
|
+
connections.push(newConnection);
|
|
69
|
+
writeDB(connections);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const getConnections = () => {
|
|
73
|
+
return readDB();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const getConnectionByAlias = (alias: string) => {
|
|
77
|
+
const connections = readDB();
|
|
78
|
+
return connections.find(c => c.alias === alias);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const removeConnection = (alias: string) => {
|
|
82
|
+
const connections = readDB();
|
|
83
|
+
const newConnections = connections.filter(c => c.alias !== alias);
|
|
84
|
+
writeDB(newConnections);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const updateConnection = (alias: string, newUser: string, newHost: string, newKeyPath: string, newPort?: string) => {
|
|
88
|
+
const connections = readDB();
|
|
89
|
+
const connection = connections.find(c => c.alias === alias);
|
|
90
|
+
if (connection) {
|
|
91
|
+
connection.user = newUser;
|
|
92
|
+
connection.host = newHost;
|
|
93
|
+
connection.key_path = newKeyPath;
|
|
94
|
+
connection.port = newPort;
|
|
95
|
+
writeDB(connections);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const recordConnectionUsage = (alias: string) => {
|
|
100
|
+
const connections = readDB();
|
|
101
|
+
const connection = connections.find(c => c.alias === alias);
|
|
102
|
+
if (connection) {
|
|
103
|
+
connection.lastUsed = Date.now();
|
|
104
|
+
writeDB(connections);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
import { isSshSessionActive } from '../ssh';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Enable global Escape key handler that terminates the CLI gracefully.
|
|
6
|
+
* Call the returned cleanup function after finishing the prompt to restore terminal state.
|
|
7
|
+
*/
|
|
8
|
+
export function enableEscapeExit(): () => void {
|
|
9
|
+
// Ensure keypress events are emitted
|
|
10
|
+
readline.emitKeypressEvents(process.stdin);
|
|
11
|
+
|
|
12
|
+
// If the terminal is interactive, switch to raw mode so we can capture individual keys
|
|
13
|
+
const shouldRestoreRawMode = process.stdin.isTTY && !process.stdin.isRaw;
|
|
14
|
+
if (shouldRestoreRawMode) {
|
|
15
|
+
process.stdin.setRawMode(true);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
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
|
+
if (key.name === 'escape') {
|
|
22
|
+
if (isSshSessionActive()) {
|
|
23
|
+
return; // let the remote shell receive the key
|
|
24
|
+
}
|
|
25
|
+
console.log('\nCancelled by user.');
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Handle Ctrl+C. When stdin is in raw mode (which we enable above), the
|
|
30
|
+
// usual SIGINT event is **not** emitted. However, we still respect a
|
|
31
|
+
// running SSH session: users can terminate the remote session with
|
|
32
|
+
// their usual shortcut inside the SSH client, so we don’t force-exit
|
|
33
|
+
// the parent app in that case.
|
|
34
|
+
if (key.ctrl && key.name === 'c') {
|
|
35
|
+
if (isSshSessionActive()) {
|
|
36
|
+
return; // let ssh handle the interrupt
|
|
37
|
+
}
|
|
38
|
+
console.log('\nCancelled by user.');
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
process.stdin.on('keypress', onKeypress);
|
|
44
|
+
|
|
45
|
+
// Return a cleanup fn to detach listener & restore terminal
|
|
46
|
+
return () => {
|
|
47
|
+
process.stdin.off('keypress', onKeypress);
|
|
48
|
+
if (shouldRestoreRawMode && process.stdin.isTTY) {
|
|
49
|
+
process.stdin.setRawMode(false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import Fuse from "fuse.js";
|
|
3
|
+
import inquirer from "inquirer";
|
|
4
|
+
import { getConnections } from "../database";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prompt user to choose a connection alias using fuzzy search.
|
|
8
|
+
* Aborts the process if user presses ESC or if no connections are available.
|
|
9
|
+
* @param message Prompt message to display
|
|
10
|
+
* @returns selected alias as string
|
|
11
|
+
*/
|
|
12
|
+
export async function selectAlias(message: string = "Select a connection"): Promise<string> {
|
|
13
|
+
const connections = getConnections().sort((a, b) => (b.lastUsed ?? 0) - (a.lastUsed ?? 0));
|
|
14
|
+
if (connections.length === 0) {
|
|
15
|
+
console.log(chalk.yellow('No connections found. Add one with "sshya add"'));
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
const { alias } = await inquirer.prompt([
|
|
21
|
+
{
|
|
22
|
+
type: 'search',
|
|
23
|
+
name: 'alias',
|
|
24
|
+
message,
|
|
25
|
+
// Fuzzy-search the aliases using Fuse.js as the user types.
|
|
26
|
+
// The signature matches the @inquirer/search documentation:
|
|
27
|
+
// (term: string | void, { signal }: { signal: AbortSignal }) => Promise<Choice[]>
|
|
28
|
+
source: async (term: unknown) => {
|
|
29
|
+
const allChoices = connections.map(c => ({ name: c.alias, value: c.alias }));
|
|
30
|
+
|
|
31
|
+
// When term is undefined or empty, return the full, recently-used-sorted list.
|
|
32
|
+
if (typeof term !== 'string' || term.trim().length === 0) {
|
|
33
|
+
return allChoices;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Use Fuse.js for fuzzy matching on the `name` field of choice objects.
|
|
37
|
+
const fuse = new Fuse(allChoices, {
|
|
38
|
+
keys: ['name'],
|
|
39
|
+
threshold: 0.4,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return fuse.search(term).map(r => r.item);
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
return alias as string;
|
|
47
|
+
}
|
package/src/ssh.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ChildProcess, spawn } from 'child_process';
|
|
2
|
+
import { recordConnectionUsage } from './database';
|
|
3
|
+
|
|
4
|
+
// Track the currently running SSH child-process so other parts of the program
|
|
5
|
+
// (e.g. the global key-handler) can know whether we are inside an SSH session.
|
|
6
|
+
let currentSshProcess: ChildProcess | null = null;
|
|
7
|
+
|
|
8
|
+
export function isSshSessionActive(): boolean {
|
|
9
|
+
return currentSshProcess !== null;
|
|
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);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
process.stdin.pipe(sshProcess.stdin);
|
|
43
|
+
sshProcess.stdout.pipe(process.stdout);
|
|
44
|
+
sshProcess.stderr.pipe(process.stderr);
|
|
45
|
+
|
|
46
|
+
const onResize = () => {
|
|
47
|
+
if (sshProcess.pid) {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(sshProcess.pid, 'SIGWINCH');
|
|
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);
|
|
59
|
+
}
|
|
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
|
+
}
|
|
73
|
+
|
|
74
|
+
sshProcess.on('exit', cleanup);
|
|
75
|
+
sshProcess.on('error', () => cleanup(1));
|
|
76
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|