From ce563186cb03e5eaa14838a47cc45e3baa117478 Mon Sep 17 00:00:00 2001 From: MatMoul Date: Thu, 4 Jun 2026 01:45:53 +0200 Subject: [PATCH] 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. --- .gitignore | 1 + .memory/project.md | 11 +- README.md | 81 ++++++++--- bun.lock | 26 ++++ mtm-ssh-menu | 356 +++++++++++++++++++++++---------------------- package.json | 13 ++ tsconfig.json | 30 ++++ 7 files changed, 315 insertions(+), 203 deletions(-) create mode 100644 bun.lock create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index f733c4b..33f91ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ +node_modules/ config/ diff --git a/.memory/project.md b/.memory/project.md index 9e9efa1..dacee51 100644 --- a/.memory/project.md +++ b/.memory/project.md @@ -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. diff --git a/README.md b/README.md index 84f11d1..79e8915 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..857fee5 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/mtm-ssh-menu b/mtm-ssh-menu index ad3b348..f5392f7 100755 --- a/mtm-ssh-menu +++ b/mtm-ssh-menu @@ -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}`; diff --git a/package.json b/package.json new file mode 100644 index 0000000..0787b39 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "mtm-ssh-menu", + "private": true, + "scripts": { + "run": "mtm-ssh-menu" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/tsconfig.json @@ -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 + } +}