feat: migrate ssh menu to Bun/TypeScript
Replace the Bash implementation and jq/yq dependency chain with a Bun-based TypeScript script, add Bun/TypeScript project files, and update docs and sample config notes to match the new CLI and behavior.
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
config/
|
config/
|
||||||
|
|||||||
+7
-4
@@ -1,13 +1,16 @@
|
|||||||
# Project Memory
|
# Project Memory
|
||||||
|
|
||||||
- Project: mtm-ssh-menu
|
- Project: mtm-ssh-menu
|
||||||
- Type: Bash SSH target picker using `fzf` and YAML config.
|
- Type: TypeScript/Bun SSH target picker with YAML config.
|
||||||
- Main entry point: `mtm-ssh-menu`
|
- Main entry point: `mtm-ssh-menu`
|
||||||
- Configuration: `~/.config/mtm-ssh-menu/global.yaml` and `hosts/*.yaml` by default.
|
- Configuration: `~/.config/mtm-ssh-menu/global.yaml` and `hosts/*.yaml` by default.
|
||||||
- Dependencies: `bash`, `ssh`, `fzf`, `jq`, `yq`.
|
- Dependencies: `bun`, `ssh`, `fzf`.
|
||||||
- Sample config is stored in `sample-config/` and mirrors current script behavior.
|
- Sample config is stored in `sample-config/` and mirrors current script behavior.
|
||||||
|
- Sample `global.yaml` includes `office`, `dev`, and `backup` jump hosts.
|
||||||
- Documentation language: English.
|
- Documentation language: English.
|
||||||
- Current task focus: keep README and memory aligned with the working script and sample configuration.
|
- Current task focus: keep README and memory aligned with the working script and sample configuration.
|
||||||
- Sample host files currently use numbered names: `01_prod.yaml`, `02_staging.yaml`, `03_dev.yaml`.
|
- Sample host files currently use numbered names: `01_prod.yaml`, `02_staging.yaml`, `03_dev.yaml`.
|
||||||
- Known follow-up improvements:
|
- `sample-config/hosts/01_prod.yaml` contains `web-1` and `db-1`; `02_staging.yaml` contains `app-1` and `worker-1`; `03_dev.yaml` contains `api-1` and `web-1`.
|
||||||
- Each group should have default values.
|
- CLI usage currently exposes `-c/--config-dir` and `-k/--known-hosts` (optional user) as documented in the script.
|
||||||
|
- `-k` without a user defaults known-host entries to `root`.
|
||||||
|
- Host-specific `options` override global `default_options`; `ssh_options` in sample config is currently unused.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# mtm-ssh-menu
|
# mtm-ssh-menu
|
||||||
|
|
||||||
A Bash SSH target picker built around `fzf` and YAML configuration.
|
A Bun/TypeScript SSH target picker built around `fzf` and YAML configuration.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
@@ -14,10 +14,9 @@ A Bash SSH target picker built around `fzf` and YAML configuration.
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
- `bun`
|
||||||
- `ssh`
|
- `ssh`
|
||||||
- `fzf`
|
- `fzf`
|
||||||
- `jq`
|
|
||||||
- `yq`
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -25,11 +24,14 @@ A Bash SSH target picker built around `fzf` and YAML configuration.
|
|||||||
./mtm-ssh-menu [--config-dir DIR] [-k [user]]
|
./mtm-ssh-menu [--config-dir DIR] [-k [user]]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The usage shown by the script is `sshm [--config-dir DIR] [-k [user]]`.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
|
||||||
- `-h`, `--help`: show help
|
- `-h`, `--help`: show help
|
||||||
- `--config-dir DIR`: use a custom configuration directory
|
- `-c`, `--config-dir DIR`: use a custom configuration directory
|
||||||
- `-k`, `--known-hosts [user]`: include hosts from `~/.ssh/known_hosts`
|
- `-k`, `--known-hosts [user]`: include hosts from `~/.ssh/known_hosts` and optionally set the user
|
||||||
|
- When `-k` is used without a user, the selected entry uses `root`
|
||||||
|
|
||||||
By default, the script reads configuration from:
|
By default, the script reads configuration from:
|
||||||
|
|
||||||
@@ -47,14 +49,56 @@ ssh:
|
|||||||
default_options: ""
|
default_options: ""
|
||||||
jump_hosts:
|
jump_hosts:
|
||||||
office: "user@192.168.10.11"
|
office: "user@192.168.10.11"
|
||||||
office-alt: "user@192.168.10.11:2222"
|
dev: "user@192.168.10.11:2222"
|
||||||
|
backup: "admin@10.0.0.5:2200"
|
||||||
```
|
```
|
||||||
|
|
||||||
### `hosts/01_prod.yaml`
|
### `hosts/01_prod.yaml`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
group: prod
|
group: prod
|
||||||
|
servers:
|
||||||
|
- name: web-1
|
||||||
|
aliases:
|
||||||
|
- frontend
|
||||||
|
- web
|
||||||
|
host: web-1.prod.internal
|
||||||
|
jump_host: office
|
||||||
|
|
||||||
|
- name: db-1
|
||||||
|
aliases:
|
||||||
|
- database
|
||||||
|
- sql
|
||||||
|
host: 10.0.0.20
|
||||||
|
jump_host: office
|
||||||
|
```
|
||||||
|
|
||||||
|
### `hosts/02_staging.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
group: staging
|
||||||
|
servers:
|
||||||
|
- name: app-1
|
||||||
|
aliases:
|
||||||
|
- app
|
||||||
|
- application
|
||||||
|
host: staging-app.example.com
|
||||||
|
user: ubuntu
|
||||||
|
port: 22
|
||||||
|
ssh_options: ""
|
||||||
|
|
||||||
|
- name: worker-1
|
||||||
|
aliases:
|
||||||
|
- worker
|
||||||
|
host: 10.20.0.15
|
||||||
|
user: ubuntu
|
||||||
|
port: 22
|
||||||
|
```
|
||||||
|
|
||||||
|
### `hosts/03_dev.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
group: dev
|
||||||
servers:
|
servers:
|
||||||
- name: api-1
|
- name: api-1
|
||||||
aliases:
|
aliases:
|
||||||
@@ -73,22 +117,9 @@ servers:
|
|||||||
port: 22
|
port: 22
|
||||||
```
|
```
|
||||||
|
|
||||||
### `hosts/03_dev.yaml`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
group: dev
|
|
||||||
servers:
|
|
||||||
- name: web-1
|
|
||||||
aliases:
|
|
||||||
- frontend
|
|
||||||
- web
|
|
||||||
host: web-1.prod.internal
|
|
||||||
jump_host: office
|
|
||||||
```
|
|
||||||
|
|
||||||
The file name does not need to match the `group` value. The script reads the `group` from the YAML content and uses the file path internally to load the selected host.
|
The file name does not need to match the `group` value. The script reads the `group` from the YAML content and uses the file path internally to load the selected host.
|
||||||
|
|
||||||
The sample configuration now uses numbered file names such as `01_prod.yaml`, `02_staging.yaml`, and `03_dev.yaml`.
|
The sample configuration uses numbered file names: `01_prod.yaml`, `02_staging.yaml`, and `03_dev.yaml`.
|
||||||
|
|
||||||
Each host entry can define:
|
Each host entry can define:
|
||||||
|
|
||||||
@@ -108,11 +139,13 @@ A complete sample configuration is available in `sample-config/`.
|
|||||||
- `jump_host` values are resolved by name from `global.yaml`.
|
- `jump_host` values are resolved by name from `global.yaml`.
|
||||||
- Jump hosts support `user@host` and `user@host:port`; if the port is omitted, `22` is used.
|
- Jump hosts support `user@host` and `user@host:port`; if the port is omitted, `22` is used.
|
||||||
- If a host does not define a user or port, global defaults are used.
|
- If a host does not define a user or port, global defaults are used.
|
||||||
- `default_options` is present in the config but not consumed by the current script.
|
- `default_options` is consumed for hosts that do not define their own `options`, and is appended as raw SSH options.
|
||||||
- The sample config includes `ssh_options` in one file, but the current script does not consume it yet.
|
- `ssh_options` appears in the sample config but is not used by the current script; only `options` is read.
|
||||||
- Sample host examples are stored as `sample-config/hosts/01_prod.yaml`, `02_staging.yaml`, and `03_dev.yaml`.
|
- Sample host examples are stored as `sample-config/hosts/01_prod.yaml`, `02_staging.yaml`, and `03_dev.yaml`.
|
||||||
- The `group` key is required for each host config file; the file name is only an internal storage detail.
|
- The `group` key is required for each host config file; the file name is only an internal storage detail.
|
||||||
- When `-k` is used, entries from `known_hosts` are shown with the selected user or `root` by default.
|
- When `-k` is used, entries from `known_hosts` are shown with the selected user or `root` by default.
|
||||||
|
- Known-host entries always use port `22`.
|
||||||
- `known_hosts` entries use port `22`.
|
- `known_hosts` entries use port `22`.
|
||||||
- The script uses strict Bash mode (`set -euo pipefail`).
|
- The script is implemented in Bun and launches `fzf` plus `ssh` from a parsed YAML configuration.
|
||||||
|
- The current implementation builds the final SSH command as a string and executes it through Bun.
|
||||||
- The script name shown in usage is `sshm`, while the repository file is `mtm-ssh-menu`.
|
- The script name shown in usage is `sshm`, while the repository file is `mtm-ssh-menu`.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "mtm-ssh-menu",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
-175
@@ -1,183 +1,189 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bun
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
CONFIG_DIR="$HOME/.config/mtm-ssh-menu"
|
import { YAML, $ } from "bun";
|
||||||
USE_KNOWN_HOSTS=0
|
import { readdirSync } from "node:fs";
|
||||||
USE_KNOWN_HOSTS_USER=$(whoami)
|
import { extname } from "node:path";
|
||||||
|
|
||||||
usage() {
|
type Args = {
|
||||||
cat <<'EOF'
|
help: boolean
|
||||||
Usage: sshm [--config-dir DIR] [-k [user]]
|
configDir: string
|
||||||
|
knownHosts?: string | true
|
||||||
Options:
|
|
||||||
-h | --help Show this help message
|
|
||||||
--config-dir DIR Use a custom config directory
|
|
||||||
-k | --known-hosts [user] Include known_hosts
|
|
||||||
|
|
||||||
The config directory must contain:
|
|
||||||
- global.yaml
|
|
||||||
- hosts/*.yaml
|
|
||||||
EOF
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_args() {
|
const usage = () => {
|
||||||
while [[ $# -gt 0 ]]; do
|
console.log("Usage: sshm [--config-dir DIR] [-k [user]]");
|
||||||
case "$1" in
|
console.log("");
|
||||||
--config-dir)
|
console.log("Options:");
|
||||||
if [[ $# -lt 2 ]]; then
|
console.log(" -h | --help Show this help message");
|
||||||
printf 'Error: --config-dir requires a value.\n' >&2
|
console.log(" -c | --config-dir DIR Use a custom config directory");
|
||||||
exit 1
|
console.log(" -k | --known-hosts [user] Include known_hosts (root if no user provided)");
|
||||||
fi
|
|
||||||
CONFIG_DIR="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--known-hosts|-k)
|
|
||||||
USE_KNOWN_HOSTS=1
|
|
||||||
if [ $# -eq 1 ]; then
|
|
||||||
shift
|
|
||||||
else
|
|
||||||
USE_KNOWN_HOSTS_USER="$2"
|
|
||||||
shift 2
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
--help|-h)
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
printf 'Error: unknown argument: %s\n' "$1" >&2
|
|
||||||
usage >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
}
|
|
||||||
parse_args "$@"
|
|
||||||
|
|
||||||
dependency_check() {
|
|
||||||
if ! command -v yq >/dev/null 2>&1; then
|
|
||||||
printf 'Error: yq is required but not installed.\n' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! command -v jq >/dev/null 2>&1; then
|
|
||||||
printf 'Error: jq is required but not installed.\n' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! command -v ssh >/dev/null 2>&1; then
|
|
||||||
printf 'Error: ssh is required but not installed.\n' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! command -v fzf >/dev/null 2>&1; then
|
|
||||||
printf 'Error: fzf is required but not installed.\n' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
dependency_check
|
|
||||||
|
|
||||||
GLOBAL_CONFIG="$CONFIG_DIR/global.yaml"
|
|
||||||
HOSTS_DIR="$CONFIG_DIR/hosts"
|
|
||||||
DEFAULT_SSH_USER="root"
|
|
||||||
DEFAULT_SSH_PORT="22"
|
|
||||||
DEFAULT_SSH_OPTIONS=""
|
|
||||||
SSH_JUMP_HOSTS={}
|
|
||||||
SERVERS=""
|
|
||||||
|
|
||||||
load_config() {
|
|
||||||
local GLOBAL_CONFIG_CONTENT
|
|
||||||
GLOBAL_CONFIG_CONTENT="$(<"$GLOBAL_CONFIG")"
|
|
||||||
DEFAULT_SSH_USER=$(yq -r '.ssh.default_user // "'$DEFAULT_SSH_USER'"' <<<"$GLOBAL_CONFIG_CONTENT")
|
|
||||||
DEFAULT_SSH_PORT=$(yq -r '.ssh.default_port // "'$DEFAULT_SSH_PORT'"' <<<"$GLOBAL_CONFIG_CONTENT")
|
|
||||||
DEFAULT_SSH_OPTIONS=$(yq -r '.ssh.default_options // "'"$DEFAULT_SSH_OPTIONS"'"' <<<"$GLOBAL_CONFIG_CONTENT")
|
|
||||||
SSH_JUMP_HOSTS=$(yq -r '.ssh.jump_hosts // {}' <<<"$GLOBAL_CONFIG_CONTENT")
|
|
||||||
|
|
||||||
shopt -s nullglob
|
|
||||||
for file in "$HOSTS_DIR"/*.yaml; do
|
|
||||||
local FILE_CONTENT GROUP_NAME GROUP_SERVERS INDEX
|
|
||||||
FILE_CONTENT="$(<"$file")"
|
|
||||||
GROUP_NAME=$(yq -r '.group // empty' <<<"$FILE_CONTENT")
|
|
||||||
if [ "$GROUP_NAME" = "" ]; then
|
|
||||||
printf 'Error: missing group name in config file: %s\n' "$file" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
GROUP_SERVERS=$(yq -r '.servers // []' <<<"$FILE_CONTENT")
|
|
||||||
INDEX=0
|
|
||||||
for ((i=0; i<$(jq -r '. | length' <<<"$GROUP_SERVERS"); i++)); do
|
|
||||||
local SSH_SERVER_NAME SSH_SERVER_ALIASES ALIASES SSH_SERVER_HOST SSH_SERVER_PORT SSH_SERVER_USER
|
|
||||||
SSH_SERVER_NAME=$(jq -r '.'"[$i]"'.name // ""' <<<"$GROUP_SERVERS")
|
|
||||||
SSH_SERVER_ALIASES=$(jq -r '.'"[$i]"'.aliases // ""' <<<"$GROUP_SERVERS")
|
|
||||||
SSH_SERVER_HOST=$(jq -r '.'"[$i]"'.host // ""' <<<"$GROUP_SERVERS")
|
|
||||||
SSH_SERVER_PORT=$(jq -r '.'"[$i]"'.port // "'"$DEFAULT_SSH_PORT"'"' <<<"$GROUP_SERVERS")
|
|
||||||
SSH_SERVER_USER=$(jq -r '.'"[$i]"'.user // "'"$DEFAULT_SSH_USER"'"' <<<"$GROUP_SERVERS")
|
|
||||||
ALIASES=""
|
|
||||||
if [ "$SSH_SERVER_ALIASES" != "" ]; then
|
|
||||||
# shellcheck disable=SC2027
|
|
||||||
ALIASES="("$(echo "$SSH_SERVER_ALIASES" | jq -r 'join(", ")')")"
|
|
||||||
fi
|
|
||||||
if [ "$SERVERS" != "" ]; then
|
|
||||||
SERVERS+="\n"
|
|
||||||
fi
|
|
||||||
SERVERS+="${GROUP_NAME} | ${file} | ${INDEX} | ${SSH_SERVER_HOST} | ${SSH_SERVER_NAME} ${ALIASES} | ${SSH_SERVER_USER} | ${SSH_SERVER_PORT}"
|
|
||||||
INDEX=$((INDEX+1))
|
|
||||||
done
|
|
||||||
done
|
|
||||||
shopt -u nullglob
|
|
||||||
}
|
|
||||||
load_known_hosts() {
|
|
||||||
local FILE_CONTENT GROUP_NAME
|
|
||||||
FILE_CONTENT=$(cat "$HOME"/.ssh/known_hosts | awk -F ' ' '{print $1}' | sort | awk '!seen[$1]++')
|
|
||||||
GROUP_NAME="Local"
|
|
||||||
for host in $FILE_CONTENT; do
|
|
||||||
if [ "$SERVERS" != "" ]; then
|
|
||||||
SERVERS+="\n"
|
|
||||||
fi
|
|
||||||
SERVERS+="${GROUP_NAME} | known_hosts | ${host} | ${host} | ${USE_KNOWN_HOSTS_USER} | 22"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
popup_menu() {
|
|
||||||
local SERVER
|
|
||||||
SERVER=$(echo -e "${SERVERS}" | column -t -s "|" -o "|" | fzf -e --layout=reverse --with-nth=1,4,5,6,7 --delimiter="|")
|
|
||||||
local GROUP_NAME GROUP_SOURCE HOST_INDEX
|
|
||||||
GROUP_NAME=$(echo "$SERVER" | awk -F '|' '{print $1}' | xargs)
|
|
||||||
GROUP_SOURCE=$(echo "$SERVER" | awk -F '|' '{print $2}' | xargs)
|
|
||||||
HOST_INDEX=$(echo "$SERVER" | awk -F '|' '{print $3}' | xargs)
|
|
||||||
ssh_connect "$GROUP_SOURCE" "$HOST_INDEX"
|
|
||||||
}
|
|
||||||
ssh_connect() {
|
|
||||||
if [[ "$HOST_INDEX" != *"."* ]]; then
|
|
||||||
local SERVER SSH_SERVER_USER SSH_SERVER_HOST SSH_SERVER_PORT SSH_SERVER_OPTIONS SSH_JUMP_HOST
|
|
||||||
SERVER=$(yq -r '.servers['"$2"']' "$1")
|
|
||||||
SSH_SERVER_USER=$(jq -r '.user // "'"$DEFAULT_SSH_USER"'"' <<<"$SERVER")
|
|
||||||
SSH_SERVER_HOST=$(jq -r '.host // ""' <<<"$SERVER")
|
|
||||||
SSH_SERVER_PORT=$(jq -r '.port // "'"$DEFAULT_SSH_PORT"'"' <<<"$SERVER")
|
|
||||||
|
|
||||||
SSH_SERVER_OPTIONS="-p $SSH_SERVER_PORT"
|
|
||||||
SSH_JUMP_HOST=$(jq -r '.jump_host // ""' <<<"$SERVER")
|
|
||||||
if [ "$SSH_JUMP_HOST" != "" ]; then
|
|
||||||
SSH_JUMP_HOST=$(jq -r '.'"$SSH_JUMP_HOST"' // ""' <<<"${SSH_JUMP_HOSTS}")
|
|
||||||
SSH_JUMP_HOST_USER=$(cut -d'@' -f1 <<<"$SSH_JUMP_HOST")
|
|
||||||
SSH_JUMP_HOST_TARGET=$(cut -d'@' -f2- <<<"$SSH_JUMP_HOST")
|
|
||||||
SSH_JUMP_HOST_TARGET_HOST=$(cut -d':' -f1 <<<"$SSH_JUMP_HOST_TARGET")
|
|
||||||
SSH_JUMP_HOST_TARGET_PORT=$(cut -s -d':' -f2 <<<"$SSH_JUMP_HOST_TARGET")
|
|
||||||
if [ "$SSH_JUMP_HOST_TARGET_PORT" = "" ]; then
|
|
||||||
SSH_JUMP_HOST_TARGET_PORT="22"
|
|
||||||
fi
|
|
||||||
SSH_JUMP_HOST="-t ${SSH_JUMP_HOST_USER}@${SSH_JUMP_HOST_TARGET_HOST} ssh -p ${SSH_JUMP_HOST_TARGET_PORT}"
|
|
||||||
SSH_SERVER_OPTIONS=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
ssh ${SSH_SERVER_OPTIONS} ${SSH_JUMP_HOST} "${SSH_SERVER_USER}"@"${SSH_SERVER_HOST}"
|
|
||||||
else
|
|
||||||
ssh "${USE_KNOWN_HOSTS_USER}"@"${2}"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
const parseArgs = (argv: string[]): Args => {
|
||||||
if [ "$USE_KNOWN_HOSTS" == 1 ]; then
|
const args: Args = {
|
||||||
load_known_hosts
|
help: false,
|
||||||
fi
|
configDir: `${Bun.env.HOME ?? ""}/.config/mtm-ssh-menu`,
|
||||||
load_config
|
|
||||||
popup_menu
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
|
||||||
|
switch (arg) {
|
||||||
|
case "-h":
|
||||||
|
case "--help":
|
||||||
|
args.help = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "-c":
|
||||||
|
case "--config-dir": {
|
||||||
|
const value = argv[i + 1];
|
||||||
|
if (!value || value.startsWith("-")) {
|
||||||
|
throw new Error(`${arg} requires a directory path`);
|
||||||
|
}
|
||||||
|
args.configDir = value;
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "-k":
|
||||||
|
case "--known-hosts": {
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next && !next.startsWith("-")) {
|
||||||
|
args.knownHosts = next;
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
args.knownHosts = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
let args: Args;
|
||||||
|
|
||||||
|
try {
|
||||||
|
args = parseArgs(Bun.argv.slice(2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
usage();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.help) {
|
||||||
|
usage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
const config: any = {
|
||||||
|
global: YAML.parse(await Bun.file(args.configDir + '/global.yaml').text()),
|
||||||
|
hosts: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadKnownHosts = async () => {
|
||||||
|
const rawFile = await Bun.file(`${Bun.env.HOME ?? ""}/.ssh/known_hosts`).text();
|
||||||
|
rawFile.split('\n').forEach(line => {
|
||||||
|
const parts = line.split(' ');
|
||||||
|
if (parts[0]) {
|
||||||
|
const target = {
|
||||||
|
user: 'root',
|
||||||
|
host: '',
|
||||||
|
port: 22,
|
||||||
|
name: '',
|
||||||
|
}
|
||||||
|
if (args.knownHosts != true) target.user = args.knownHosts as string;
|
||||||
|
if (parts[0].startsWith("[")) {
|
||||||
|
const hostparts: any = parts[0].split(":");
|
||||||
|
target.host = hostparts[0].substring(1, hostparts[0].length - 1);
|
||||||
|
target.port = hostparts[1];
|
||||||
|
target.name = parts[1] as string;
|
||||||
|
} else {
|
||||||
|
target.host = parts[0];
|
||||||
|
target.port = 22;
|
||||||
|
target.name = parts[1] as string;
|
||||||
|
}
|
||||||
|
const record = {
|
||||||
|
index: config.hosts.length,
|
||||||
|
group: ".ssh",
|
||||||
|
host: target.host,
|
||||||
|
user: target.user,
|
||||||
|
port: target.port,
|
||||||
|
name: target.name,
|
||||||
|
aliases: [],
|
||||||
|
};
|
||||||
|
config.hosts.push(record);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const loadHosts = async () => {
|
||||||
|
const configHostsDir = args.configDir + '/hosts/';
|
||||||
|
const configHostsFiles = readdirSync(configHostsDir).sort();
|
||||||
|
for (let i = 0; i < configHostsFiles.length; i++) {
|
||||||
|
if (extname(configHostsFiles[i] as string) === '.yaml') {
|
||||||
|
const file: any = YAML.parse(await Bun.file(configHostsDir + configHostsFiles[i]).text());
|
||||||
|
const groupName = file.group;
|
||||||
|
file.servers.forEach((host: any) => {
|
||||||
|
const record = {
|
||||||
|
index: config.hosts.length,
|
||||||
|
group: groupName,
|
||||||
|
host: host.host,
|
||||||
|
user: host?.user || config.global.ssh.default_user,
|
||||||
|
port: host?.port || config.global.ssh.default_port,
|
||||||
|
jump_host: host?.jump_host,
|
||||||
|
options: host?.options || config.global.ssh.default_options,
|
||||||
|
name: host?.name || host.host,
|
||||||
|
aliases: host?.aliases || [],
|
||||||
|
}
|
||||||
|
config.hosts.push(record);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.knownHosts) await loadKnownHosts();
|
||||||
|
await loadHosts();
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await loadConfig();
|
||||||
|
|
||||||
|
const parseJumpHost = (name: string) => {
|
||||||
|
return config.global.ssh.jump_hosts[name];
|
||||||
|
}
|
||||||
|
const tuiMenu = async () => {
|
||||||
|
let records: string = "";
|
||||||
|
config.hosts.forEach((host: any) => {
|
||||||
|
if (records.length > 0) records += "\n";
|
||||||
|
records += `${host.index} | ${host.group} | ${host.host} | ${host.name}`;
|
||||||
|
if (host.aliases.length > 0) records += ` (${host.aliases.join(", ")})`;
|
||||||
|
records += ` | ${host.user} | ${host.port}`;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await $`echo "${records}" | fzf -e --layout=reverse --with-nth=2,3,4,5,6 --accept-nth=1 --delimiter="|"`.text();
|
||||||
|
} catch (error) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const buildSSHCommnand = (host: any) => {
|
||||||
|
const args = ["ssh"];
|
||||||
|
if (host?.port !== 22) args.push("-p", String(host.port));
|
||||||
|
if (host?.options?.length > 0) args.push(...String(host.options).split(/\s+/).filter(Boolean));
|
||||||
|
const jump_host = parseJumpHost(host.jump_host);
|
||||||
|
if (jump_host) args.push("-t", jump_host, "ssh");
|
||||||
|
args.push(`${host.user}@${host.host}`);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostIndex = Number(await tuiMenu());
|
||||||
|
|
||||||
|
const cmd = buildSSHCommnand(config.hosts[hostIndex]);
|
||||||
|
console.log(cmd.join(" "));
|
||||||
|
|
||||||
|
await $`${cmd}`;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "mtm-ssh-menu",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"run": "mtm-ssh-menu"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
"types": ["bun"],
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user