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:
2026-06-04 01:45:53 +02:00
parent ba79cedcb1
commit ce563186cb
7 changed files with 315 additions and 203 deletions
+1
View File
@@ -1 +1,2 @@
node_modules/
config/
+7 -4
View File
@@ -1,13 +1,16 @@
# Project Memory
- 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`
- 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 `global.yaml` includes `office`, `dev`, and `backup` jump hosts.
- Documentation language: English.
- 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`.
- Known follow-up improvements:
- Each group should have default values.
- `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`.
- 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.
+57 -24
View File
@@ -1,6 +1,6 @@
# 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
@@ -14,10 +14,9 @@ A Bash SSH target picker built around `fzf` and YAML configuration.
## Requirements
- `bun`
- `ssh`
- `fzf`
- `jq`
- `yq`
## Usage
@@ -25,11 +24,14 @@ A Bash SSH target picker built around `fzf` and YAML configuration.
./mtm-ssh-menu [--config-dir DIR] [-k [user]]
```
The usage shown by the script is `sshm [--config-dir DIR] [-k [user]]`.
Options:
- `-h`, `--help`: show help
- `--config-dir DIR`: use a custom configuration directory
- `-k`, `--known-hosts [user]`: include hosts from `~/.ssh/known_hosts`
- `-c`, `--config-dir DIR`: use a custom configuration directory
- `-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:
@@ -47,14 +49,56 @@ ssh:
default_options: ""
jump_hosts:
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`
```yaml
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:
- name: api-1
aliases:
@@ -73,22 +117,9 @@ servers:
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 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:
@@ -108,11 +139,13 @@ A complete sample configuration is available in `sample-config/`.
- `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.
- 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.
- The sample config includes `ssh_options` in one file, but the current script does not consume it yet.
- `default_options` is consumed for hosts that do not define their own `options`, and is appended as raw SSH options.
- `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`.
- 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.
- Known-host entries always 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`.
+26
View File
@@ -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
View File
@@ -1,183 +1,189 @@
#!/usr/bin/env bash
set -euo pipefail
#!/usr/bin/env bun
CONFIG_DIR="$HOME/.config/mtm-ssh-menu"
USE_KNOWN_HOSTS=0
USE_KNOWN_HOSTS_USER=$(whoami)
import { YAML, $ } from "bun";
import { readdirSync } from "node:fs";
import { extname } from "node:path";
usage() {
cat <<'EOF'
Usage: sshm [--config-dir DIR] [-k [user]]
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
type Args = {
help: boolean
configDir: string
knownHosts?: string | true
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--config-dir)
if [[ $# -lt 2 ]]; then
printf 'Error: --config-dir requires a value.\n' >&2
exit 1
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
const usage = () => {
console.log("Usage: sshm [--config-dir DIR] [-k [user]]");
console.log("");
console.log("Options:");
console.log(" -h | --help Show this help message");
console.log(" -c | --config-dir DIR Use a custom config directory");
console.log(" -k | --known-hosts [user] Include known_hosts (root if no user provided)");
}
main() {
if [ "$USE_KNOWN_HOSTS" == 1 ]; then
load_known_hosts
fi
load_config
popup_menu
const parseArgs = (argv: string[]): Args => {
const args: Args = {
help: false,
configDir: `${Bun.env.HOME ?? ""}/.config/mtm-ssh-menu`,
}
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;
}
main
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}`;
+13
View File
@@ -0,0 +1,13 @@
{
"name": "mtm-ssh-menu",
"private": true,
"scripts": {
"run": "mtm-ssh-menu"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}
+30
View File
@@ -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
}
}