Gitfed
bastien-mrq/sssh/ Commits/ 1220acd

feat: sssh — gestionnaire de connexions SSH (CLI + TUI)

- sssh <nom> : connexion directe avec résolution par préfixe unique - sssh : TUI Bubble Tea (filtre, connexion, add/edit/delete via huh) - add/rm/edit/list/export/import en CLI, stockage TOML atomique - fusion lecture seule des hosts de ~/.ssh/config - complétion fish, démos VHS, README Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

bastien-mrq 2026-08-13 09:03 commit 1220acdf3e97e2baad17ce44d57851aae84c0762
19 files changed +1256 −0
A .gitignore +3 −0
A README.md +91 −0
A completions/sssh.fish +17 −0
A go.mod +41 −0
A go.sum +86 −0
A internal/.DS_Store Binary file — not shown.
A internal/connect/connect.go +23 −0
A internal/host/host.go +113 −0
A internal/sshconf/sshconf.go +63 −0
A internal/store/store.go +137 −0
A internal/tui/form.go +100 −0
A internal/tui/list.go +124 −0
A main.go +353 −0
A vhs/add.gif Binary file — not shown.
A vhs/add.tape +31 −0
A vhs/demo-hosts.toml +27 −0
A vhs/demo.gif Binary file — not shown.
A vhs/demo.tape +36 −0
A vhs/fakebin/ssh +11 −0
.gitignore
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4e1065 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/sssh +/dist/ +vhs/fakebin/sssh
README.md
diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7386b0 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# sssh — simple-ssh + +Un gestionnaire de connexions SSH minimaliste : un nom court par serveur, un TUI pour choisir, et c'est tout. + +![Démo du TUI](vhs/demo.gif) + +## Fonctionnalités + +- `sssh <nom>` se connecte directement — un préfixe unique suffit (`sssh ino` → `inoval`) +- `sssh` sans argument ouvre un TUI avec filtre instantané +- Ajout en une ligne ou via formulaire dans le TUI +- Les hosts de `~/.ssh/config` apparaissent aussi (lecture seule) +- Support port, clé privée, ProxyJump (qui peut référencer un autre host sssh) et tags +- Export / import pour synchroniser entre machines +- La connexion délègue au `ssh` système (`exec`) : agent, TTY, config… tout marche comme un ssh normal + +## Installation + +```sh +git clone ssh://git@git.neuromancer.ovh:2222/bastien-mrq/sssh.git +cd sssh +go build -o sssh . +mv sssh ~/.local/bin/ # ou n'importe où dans ton PATH +``` + +Complétion fish : `cp completions/sssh.fish ~/.config/fish/completions/` + +## Usage + +![Démo add et list](vhs/add.gif) + +```sh +sssh # TUI de sélection +sssh serveur-1 # connexion directe +sssh serv # préfixe unique accepté +sssh vps-ovh uptime # exécute une commande distante + +sssh add vps-ovh debian@203.0.113.21 # ajout en une ligne +sssh add api deploy@192.0.2.7:2222 -i ~/.ssh/id_deploy -J bastion -t client,prod +sssh edit vps-ovh # formulaire d'édition +sssh rm vps-ovh +sssh list + +sssh export > hosts.toml # sauvegarde / partage +sssh import hosts.toml # fusionne (--force pour écraser) +``` + +### Raccourcis du TUI + +| Touche | Action | +|---|---| +| `↑↓` / `jk` | naviguer | +| `/` puis texte | filtrer | +| `entrée` | se connecter | +| `a` | ajouter un host | +| `e` | éditer | +| `d` | supprimer (avec confirmation) | +| `q` | quitter | + +## Configuration + +Les hosts vivent dans `~/.config/sssh/hosts.toml` (surchargeable avec `$SSSH_CONFIG`) : + +```toml +[hosts.serveur-1] +user = "root" +host = "192.0.2.11" +port = 22 # optionnel +identity = "~/.ssh/id_prod" # optionnel +jump = "bastion" # optionnel — nom sssh ou [user@]host[:port] +tags = ["client", "prod"] # optionnel +``` + +C'est un simple fichier : pour synchroniser deux machines, copie-le, ou versionne `~/.config/sssh/` dans un repo git. + +Les alias concrets de `~/.ssh/config` sont affichés à côté des hosts sssh, marqués `(ssh_config)`, et la connexion leur passe simplement l'alias (`ssh <alias>`), donc leur configuration s'applique telle quelle. Ils ne sont jamais modifiés par sssh. En cas de nom identique, le host sssh gagne. + +## Développement + +```sh +go build ./... +go vet ./... +``` + +Les GIFs de démo sont générés avec [VHS](https://github.com/charmbracelet/vhs) : + +```sh +go build -o vhs/fakebin/sssh . && vhs vhs/demo.tape && vhs vhs/add.tape +``` + +Construit avec [Bubble Tea](https://github.com/charmbracelet/bubbletea), [huh](https://github.com/charmbracelet/huh) et [go-toml](https://github.com/pelletier/go-toml).
completions/sssh.fish
diff --git a/completions/sssh.fish b/completions/sssh.fish new file mode 100644 index 0000000..c32b5c5 --- /dev/null +++ b/completions/sssh.fish @@ -0,0 +1,17 @@ +# Complétion fish pour sssh — à copier dans ~/.config/fish/completions/ +complete -c sssh -f + +# Noms de hosts en premier argument +complete -c sssh -n '__fish_use_subcommand' -a '(sssh list --names 2>/dev/null)' -d 'host SSH' + +# Sous-commandes +complete -c sssh -n '__fish_use_subcommand' -a add -d 'Ajouter un host' +complete -c sssh -n '__fish_use_subcommand' -a edit -d 'Éditer un host' +complete -c sssh -n '__fish_use_subcommand' -a rm -d 'Supprimer un host' +complete -c sssh -n '__fish_use_subcommand' -a list -d 'Lister les hosts' +complete -c sssh -n '__fish_use_subcommand' -a export -d 'Exporter hosts.toml' +complete -c sssh -n '__fish_use_subcommand' -a import -d 'Importer un hosts.toml' +complete -c sssh -n '__fish_use_subcommand' -a help -d 'Aide' + +# Noms de hosts pour edit/rm +complete -c sssh -n '__fish_seen_subcommand_from edit rm' -a '(sssh list --names 2>/dev/null)'
go.mod
diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d67c462 --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module git.neuromancer.ovh/bastien-mrq/sssh + +go 1.25.4 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/kevinburke/ssh_config v1.6.0 + github.com/pelletier/go-toml/v2 v2.4.3 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.23.0 // indirect +)
go.sum
diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6264d1 --- /dev/null +++ b/go.sum @@ -0,0 +1,86 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
internal/.DS_Store
diff --git a/internal/.DS_Store b/internal/.DS_Store new file mode 100644 index 0000000..2247c85 Binary files /dev/null and b/internal/.DS_Store differ
internal/connect/connect.go
diff --git a/internal/connect/connect.go b/internal/connect/connect.go new file mode 100644 index 0000000..685cbe5 --- /dev/null +++ b/internal/connect/connect.go @@ -0,0 +1,23 @@ +// Package connect lance la connexion en remplaçant le processus par ssh. +package connect + +import ( + "fmt" + "os" + "os/exec" + "syscall" + + "git.neuromancer.ovh/bastien-mrq/sssh/internal/host" +) + +// Exec remplace le processus courant par ssh (TTY natif, agent, etc.). +// Ne revient jamais en cas de succès. +func Exec(h host.Host, extra []string) error { + path, err := exec.LookPath("ssh") + if err != nil { + return fmt.Errorf("binaire ssh introuvable : %w", err) + } + argv := append([]string{"ssh"}, h.SSHArgs(extra...)...) + fmt.Fprintf(os.Stderr, "→ %s\n", h.Describe()) + return syscall.Exec(path, argv, os.Environ()) +}
internal/host/host.go
diff --git a/internal/host/host.go b/internal/host/host.go new file mode 100644 index 0000000..0f44405 --- /dev/null +++ b/internal/host/host.go @@ -0,0 +1,113 @@ +// Package host définit le modèle d'un host SSH enregistré. +package host + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// Host représente une entrée SSH, provenant de hosts.toml ou de ~/.ssh/config. +type Host struct { + Name string `toml:"-"` + User string `toml:"user,omitempty"` + Host string `toml:"host"` + Port int `toml:"port,omitempty"` + Identity string `toml:"identity,omitempty"` + Jump string `toml:"jump,omitempty"` + Tags []string `toml:"tags,omitempty"` + + // ReadOnly indique un host issu de ~/.ssh/config, non modifiable par sssh. + ReadOnly bool `toml:"-"` +} + +// Parse construit un Host depuis une cible de la forme [user@]host[:port]. +func Parse(name, target string) (Host, error) { + h := Host{Name: name} + if i := strings.LastIndex(target, "@"); i >= 0 { + h.User = target[:i] + target = target[i+1:] + } + if i := strings.LastIndex(target, ":"); i >= 0 && !strings.Contains(target, "]") { + port, err := strconv.Atoi(target[i+1:]) + if err != nil { + return h, fmt.Errorf("port invalide dans %q", target) + } + h.Port = port + target = target[:i] + } + if target == "" { + return h, errors.New("hôte vide") + } + h.Host = target + return h, nil +} + +// Target rend la cible ssh, ex. "inoval@51.210.14.32". +func (h Host) Target() string { + t := h.Host + if h.User != "" { + t = h.User + "@" + t + } + return t +} + +// JumpSpec rend la forme [user@]host[:port] utilisable après -J. +func (h Host) JumpSpec() string { + s := h.Target() + if h.Port != 0 && h.Port != 22 { + s += ":" + strconv.Itoa(h.Port) + } + return s +} + +// SSHArgs construit les arguments à passer au binaire ssh. +// Pour un host de ~/.ssh/config, on passe juste son alias : ssh applique +// lui-même sa configuration. +func (h Host) SSHArgs(extra ...string) []string { + if h.ReadOnly { + return append([]string{h.Name}, extra...) + } + var args []string + if h.Port != 0 && h.Port != 22 { + args = append(args, "-p", strconv.Itoa(h.Port)) + } + if h.Identity != "" { + args = append(args, "-i", ExpandTilde(h.Identity)) + } + if h.Jump != "" { + args = append(args, "-J", h.Jump) + } + args = append(args, h.Target()) + return append(args, extra...) +} + +// Describe rend une description courte pour les listes (TUI, sssh list). +func (h Host) Describe() string { + d := h.Target() + if h.Port != 0 && h.Port != 22 { + d += ":" + strconv.Itoa(h.Port) + } + if len(h.Tags) > 0 { + d += " #" + strings.Join(h.Tags, " #") + } + if h.ReadOnly { + d += " (ssh_config)" + } + return d +} + +// ExpandTilde remplace un préfixe ~/ par le home de l'utilisateur. +func ExpandTilde(p string) string { + if p == "~" || strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return p + } + return filepath.Join(home, strings.TrimPrefix(p[1:], "/")) + } + return p +}
internal/sshconf/sshconf.go
diff --git a/internal/sshconf/sshconf.go b/internal/sshconf/sshconf.go new file mode 100644 index 0000000..b7f702e --- /dev/null +++ b/internal/sshconf/sshconf.go @@ -0,0 +1,63 @@ +// Package sshconf lit ~/.ssh/config en lecture seule pour afficher +// les hosts existants à côté de ceux gérés par sssh. +package sshconf + +import ( + "os" + "path/filepath" + "strconv" + "strings" + + ssh_config "github.com/kevinburke/ssh_config" + + "git.neuromancer.ovh/bastien-mrq/sssh/internal/host" +) + +// Hosts rend les alias concrets (sans jokers) déclarés dans ~/.ssh/config. +// Toute erreur de lecture ou de parsing donne une liste vide : ce fichier +// n'appartient pas à sssh, on ne bloque jamais dessus. +func Hosts() []host.Host { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + f, err := os.Open(filepath.Join(home, ".ssh", "config")) + if err != nil { + return nil + } + defer f.Close() + + cfg, err := ssh_config.Decode(f) + if err != nil { + return nil + } + + var hosts []host.Host + for _, block := range cfg.Hosts { + kv := map[string]string{} + for _, node := range block.Nodes { + if n, ok := node.(*ssh_config.KV); ok { + kv[strings.ToLower(n.Key)] = n.Value + } + } + for _, p := range block.Patterns { + name := p.String() + if strings.ContainsAny(name, "*?!") { + continue + } + h := host.Host{Name: name, ReadOnly: true} + h.Host = kv["hostname"] + if h.Host == "" { + h.Host = name + } + h.User = kv["user"] + if v := kv["port"]; v != "" { + h.Port, _ = strconv.Atoi(v) + } + h.Identity = kv["identityfile"] + h.Jump = kv["proxyjump"] + hosts = append(hosts, h) + } + } + return hosts +}
internal/store/store.go
diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..2db8ab1 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,137 @@ +// Package store gère la persistance des hosts dans hosts.toml. +package store + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/pelletier/go-toml/v2" + + "git.neuromancer.ovh/bastien-mrq/sssh/internal/host" +) + +type fileFormat struct { + Hosts map[string]host.Host `toml:"hosts"` +} + +// Store est le contenu chargé de hosts.toml. +type Store struct { + path string + Hosts map[string]host.Host +} + +// Path rend le chemin du fichier de configuration, surchargé par $SSSH_CONFIG. +func Path() string { + if p := os.Getenv("SSSH_CONFIG"); p != "" { + return p + } + base, err := os.UserConfigDir() + if err != nil { + base = filepath.Join(os.Getenv("HOME"), ".config") + } + return filepath.Join(base, "sssh", "hosts.toml") +} + +// Load lit hosts.toml ; un fichier absent donne un store vide. +func Load() (*Store, error) { + s := &Store{path: Path(), Hosts: map[string]host.Host{}} + data, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return s, nil + } + if err != nil { + return nil, err + } + var f fileFormat + if err := toml.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("%s : %w", s.path, err) + } + if f.Hosts != nil { + s.Hosts = f.Hosts + } + return s, nil +} + +// Save écrit le fichier de façon atomique (fichier temporaire puis rename). +func (s *Store) Save() error { + data, err := s.Marshal() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +// Marshal sérialise le store au format hosts.toml. +func (s *Store) Marshal() ([]byte, error) { + return toml.Marshal(fileFormat{Hosts: s.Hosts}) +} + +// Get rend le host nommé, avec son Name renseigné. +func (s *Store) Get(name string) (host.Host, bool) { + h, ok := s.Hosts[name] + if ok { + h.Name = name + } + return h, ok +} + +// Set ajoute ou remplace un host. +func (s *Store) Set(h host.Host) { + s.Hosts[h.Name] = h +} + +// Remove supprime un host ; rend false s'il n'existait pas. +func (s *Store) Remove(name string) bool { + if _, ok := s.Hosts[name]; !ok { + return false + } + delete(s.Hosts, name) + return true +} + +// List rend les hosts triés par nom. +func (s *Store) List() []host.Host { + hosts := make([]host.Host, 0, len(s.Hosts)) + for name, h := range s.Hosts { + h.Name = name + hosts = append(hosts, h) + } + sort.Slice(hosts, func(i, j int) bool { return hosts[i].Name < hosts[j].Name }) + return hosts +} + +// Resolve cherche name parmi hosts : correspondance exacte d'abord, +// sinon préfixe unique. En cas d'ambiguïté, l'erreur liste les candidats. +func Resolve(hosts []host.Host, name string) (host.Host, error) { + var candidates []host.Host + for _, h := range hosts { + if h.Name == name { + return h, nil + } + if strings.HasPrefix(h.Name, name) { + candidates = append(candidates, h) + } + } + switch len(candidates) { + case 1: + return candidates[0], nil + case 0: + return host.Host{}, fmt.Errorf("host inconnu : %q (voir sssh list)", name) + default: + names := make([]string, len(candidates)) + for i, h := range candidates { + names[i] = h.Name + } + return host.Host{}, fmt.Errorf("%q est ambigu : %s", name, strings.Join(names, ", ")) + } +}
internal/tui/form.go
diff --git a/internal/tui/form.go b/internal/tui/form.go new file mode 100644 index 0000000..cbe95b6 --- /dev/null +++ b/internal/tui/form.go @@ -0,0 +1,100 @@ +package tui + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/huh" + + "git.neuromancer.ovh/bastien-mrq/sssh/internal/host" +) + +// ErrAborted est rendu quand l'utilisateur annule un formulaire. +var ErrAborted = errors.New("annulé") + +// HostForm ouvre le formulaire d'ajout/édition et modifie h en place. +// oldName est vide pour un ajout ; exists vérifie l'unicité du nom. +func HostForm(h *host.Host, oldName string, exists func(string) bool) error { + name := h.Name + port := "" + if h.Port != 0 { + port = strconv.Itoa(h.Port) + } + tags := strings.Join(h.Tags, ",") + + title := "Nouveau host" + if oldName != "" { + title = "Édition de " + oldName + } + + form := huh.NewForm(huh.NewGroup( + huh.NewInput().Title("Nom").Description(title).Value(&name). + Validate(func(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return errors.New("le nom est obligatoire") + } + if strings.ContainsAny(s, " \t") { + return errors.New("pas d'espaces dans le nom") + } + if s != oldName && exists(s) { + return fmt.Errorf("%q existe déjà", s) + } + return nil + }), + huh.NewInput().Title("Hôte (IP ou domaine)").Value(&h.Host). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return errors.New("l'hôte est obligatoire") + } + return nil + }), + huh.NewInput().Title("Utilisateur").Value(&h.User), + huh.NewInput().Title("Port (défaut 22)").Value(&port). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + if _, err := strconv.Atoi(strings.TrimSpace(s)); err != nil { + return errors.New("port numérique attendu") + } + return nil + }), + huh.NewInput().Title("Clé privée (optionnel)").Placeholder("~/.ssh/id_ed25519").Value(&h.Identity), + huh.NewInput().Title("Jump host / ProxyJump (optionnel)").Value(&h.Jump), + huh.NewInput().Title("Tags, séparés par des virgules (optionnel)").Value(&tags), + )) + + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return ErrAborted + } + return err + } + + h.Name = strings.TrimSpace(name) + h.Host = strings.TrimSpace(h.Host) + h.User = strings.TrimSpace(h.User) + h.Identity = strings.TrimSpace(h.Identity) + h.Jump = strings.TrimSpace(h.Jump) + h.Port = 0 + if p := strings.TrimSpace(port); p != "" { + h.Port, _ = strconv.Atoi(p) + } + h.Tags = nil + for _, t := range strings.Split(tags, ",") { + if t = strings.TrimSpace(t); t != "" { + h.Tags = append(h.Tags, t) + } + } + return nil +} + +// Confirm pose une question oui/non ; rend false si l'utilisateur annule. +func Confirm(question string) bool { + ok := false + err := huh.NewConfirm().Title(question).Affirmative("Oui").Negative("Non").Value(&ok).Run() + return err == nil && ok +}
internal/tui/list.go
diff --git a/internal/tui/list.go b/internal/tui/list.go new file mode 100644 index 0000000..a95d380 --- /dev/null +++ b/internal/tui/list.go @@ -0,0 +1,124 @@ +// Package tui fournit la liste interactive et les formulaires de sssh. +package tui + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "git.neuromancer.ovh/bastien-mrq/sssh/internal/host" +) + +// Action est ce que l'utilisateur a demandé en quittant la liste. +type Action int + +const ( + ActionQuit Action = iota + ActionConnect + ActionAdd + ActionEdit + ActionDelete +) + +// Result porte l'action choisie et le host concerné le cas échéant. +type Result struct { + Action Action + Host host.Host +} + +type item struct{ h host.Host } + +func (i item) Title() string { return i.h.Name } +func (i item) Description() string { return i.h.Describe() } +func (i item) FilterValue() string { + v := i.h.Name + " " + i.h.Host + for _, t := range i.h.Tags { + v += " " + t + } + return v +} + +var docStyle = lipgloss.NewStyle().Margin(1, 2) + +type model struct { + list list.Model + result Result +} + +func (m model) Init() tea.Cmd { return nil } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + fw, fh := docStyle.GetFrameSize() + m.list.SetSize(msg.Width-fw, msg.Height-fh) + + case tea.KeyMsg: + if m.list.FilterState() == list.Filtering { + break + } + switch msg.String() { + case "enter": + if it, ok := m.list.SelectedItem().(item); ok { + m.result = Result{Action: ActionConnect, Host: it.h} + return m, tea.Quit + } + case "a": + m.result = Result{Action: ActionAdd} + return m, tea.Quit + case "e", "d": + it, ok := m.list.SelectedItem().(item) + if !ok { + break + } + if it.h.ReadOnly { + return m, m.list.NewStatusMessage("host en lecture seule (~/.ssh/config)") + } + action := ActionEdit + if msg.String() == "d" { + action = ActionDelete + } + m.result = Result{Action: action, Host: it.h} + return m, tea.Quit + case "q", "ctrl+c": + m.result = Result{Action: ActionQuit} + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m model) View() string { + return docStyle.Render(m.list.View()) +} + +// Run affiche la liste des hosts et rend l'action choisie. +func Run(hosts []host.Host) (Result, error) { + items := make([]list.Item, len(hosts)) + for i, h := range hosts { + items[i] = item{h} + } + + l := list.New(items, list.NewDefaultDelegate(), 0, 0) + l.Title = "sssh" + l.SetStatusBarItemName("host", "hosts") + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("entrée", "connexion")), + key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "ajouter")), + key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "éditer")), + key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "supprimer")), + } + } + + p := tea.NewProgram(model{list: l}, tea.WithAltScreen()) + out, err := p.Run() + if err != nil { + return Result{}, err + } + return out.(model).result, nil +}
main.go
diff --git a/main.go b/main.go new file mode 100644 index 0000000..7f0c32d --- /dev/null +++ b/main.go @@ -0,0 +1,353 @@ +// sssh — simple-ssh : un gestionnaire de connexions SSH minimaliste. +// +// sssh TUI de sélection +// sssh <nom> connexion directe (préfixe unique accepté) +// sssh add ... ajout en une ligne +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" + + "github.com/pelletier/go-toml/v2" + + "git.neuromancer.ovh/bastien-mrq/sssh/internal/connect" + "git.neuromancer.ovh/bastien-mrq/sssh/internal/host" + "git.neuromancer.ovh/bastien-mrq/sssh/internal/sshconf" + "git.neuromancer.ovh/bastien-mrq/sssh/internal/store" + "git.neuromancer.ovh/bastien-mrq/sssh/internal/tui" +) + +const version = "0.1.0" + +const usage = `sssh — simple-ssh + +Usage : + sssh ouvre le TUI de sélection + sssh <nom> [cmd...] se connecte au host (préfixe unique accepté) + sssh add <nom> <[user@]host[:port]> [-p port] [-i clé] [-J jump] [-t tags] + sssh edit <nom> édite un host (formulaire) + sssh rm <nom> supprime un host + sssh list liste les hosts (--names : noms seuls) + sssh export [fichier] exporte hosts.toml (stdout par défaut) + sssh import <fichier> importe en fusionnant (--force pour écraser) + sssh help | version + +Fichier : ` + "`$SSSH_CONFIG`" + ` ou ~/.config/sssh/hosts.toml +Les hosts de ~/.ssh/config apparaissent aussi (lecture seule).` + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "sssh :", err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) == 0 { + return runTUI() + } + switch args[0] { + case "add": + return cmdAdd(args[1:]) + case "rm", "remove": + return cmdRm(args[1:]) + case "edit": + return cmdEdit(args[1:]) + case "list", "ls": + return cmdList(args[1:]) + case "export": + return cmdExport(args[1:]) + case "import": + return cmdImport(args[1:]) + case "help", "-h", "--help": + fmt.Println(usage) + return nil + case "version", "-V", "--version": + fmt.Println("sssh", version) + return nil + default: + return cmdConnect(args[0], args[1:]) + } +} + +// allHosts fusionne les hosts du store et ceux de ~/.ssh/config +// (le store gagne en cas de nom identique), triés par nom. +func allHosts(st *store.Store) []host.Host { + hosts := st.List() + for _, h := range sshconf.Hosts() { + if _, ok := st.Get(h.Name); !ok { + hosts = append(hosts, h) + } + } + sort.Slice(hosts, func(i, j int) bool { return hosts[i].Name < hosts[j].Name }) + return hosts +} + +// resolveJump remplace un jump qui référence un host sssh par sa cible réelle. +func resolveJump(st *store.Store, h *host.Host) { + if h.Jump == "" || h.ReadOnly { + return + } + if j, ok := st.Get(h.Jump); ok { + h.Jump = j.JumpSpec() + } +} + +func cmdConnect(name string, extra []string) error { + st, err := store.Load() + if err != nil { + return err + } + h, err := store.Resolve(allHosts(st), name) + if err != nil { + return err + } + resolveJump(st, &h) + return connect.Exec(h, extra) +} + +func runTUI() error { + for { + st, err := store.Load() + if err != nil { + return err + } + res, err := tui.Run(allHosts(st)) + if err != nil { + return err + } + + switch res.Action { + case tui.ActionQuit: + return nil + + case tui.ActionConnect: + h := res.Host + resolveJump(st, &h) + return connect.Exec(h, nil) + + case tui.ActionAdd: + h := host.Host{} + if err := editForm(st, &h, ""); err != nil { + return err + } + + case tui.ActionEdit: + h := res.Host + if err := editForm(st, &h, res.Host.Name); err != nil { + return err + } + + case tui.ActionDelete: + if tui.Confirm(fmt.Sprintf("Supprimer %s (%s) ?", res.Host.Name, res.Host.Target())) { + st.Remove(res.Host.Name) + if err := st.Save(); err != nil { + return err + } + } + } + } +} + +// editForm ouvre le formulaire, puis persiste (gère aussi le renommage). +// Une annulation du formulaire n'est pas une erreur. +func editForm(st *store.Store, h *host.Host, oldName string) error { + exists := func(name string) bool { _, ok := st.Get(name); return ok } + if err := tui.HostForm(h, oldName, exists); err != nil { + if errors.Is(err, tui.ErrAborted) { + return nil + } + return err + } + if oldName != "" && oldName != h.Name { + st.Remove(oldName) + } + st.Set(*h) + return st.Save() +} + +func cmdAdd(args []string) error { + if len(args) < 2 { + return errors.New("usage : sssh add <nom> <[user@]host[:port]> [-p port] [-i clé] [-J jump] [-t tags]") + } + name, target := args[0], args[1] + + fs := flag.NewFlagSet("add", flag.ContinueOnError) + port := fs.Int("p", 0, "port") + identity := fs.String("i", "", "clé privée") + jump := fs.String("J", "", "jump host (ProxyJump)") + tags := fs.String("t", "", "tags séparés par des virgules") + if err := fs.Parse(args[2:]); err != nil { + return err + } + + st, err := store.Load() + if err != nil { + return err + } + if _, ok := st.Get(name); ok { + return fmt.Errorf("%q existe déjà (sssh edit %s pour le modifier)", name, name) + } + + h, err := host.Parse(name, target) + if err != nil { + return err + } + if *port != 0 { + h.Port = *port + } + h.Identity = *identity + h.Jump = *jump + for _, t := range strings.Split(*tags, ",") { + if t = strings.TrimSpace(t); t != "" { + h.Tags = append(h.Tags, t) + } + } + + st.Set(h) + if err := st.Save(); err != nil { + return err + } + fmt.Printf("✓ %s ajouté (%s)\n", h.Name, h.Describe()) + return nil +} + +func cmdRm(args []string) error { + if len(args) != 1 { + return errors.New("usage : sssh rm <nom>") + } + st, err := store.Load() + if err != nil { + return err + } + if !st.Remove(args[0]) { + return fmt.Errorf("host inconnu : %q", args[0]) + } + if err := st.Save(); err != nil { + return err + } + fmt.Printf("✓ %s supprimé\n", args[0]) + return nil +} + +func cmdEdit(args []string) error { + if len(args) != 1 { + return errors.New("usage : sssh edit <nom>") + } + st, err := store.Load() + if err != nil { + return err + } + h, ok := st.Get(args[0]) + if !ok { + return fmt.Errorf("host inconnu : %q", args[0]) + } + return editForm(st, &h, args[0]) +} + +func cmdList(args []string) error { + st, err := store.Load() + if err != nil { + return err + } + hosts := allHosts(st) + + if len(args) > 0 && args[0] == "--names" { + for _, h := range hosts { + fmt.Println(h.Name) + } + return nil + } + + if len(hosts) == 0 { + fmt.Println("Aucun host. Ajoute-en un : sssh add <nom> <user@host>") + return nil + } + w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0) + fmt.Fprintln(w, "NOM\tCIBLE\tPORT\tTAGS\tSOURCE") + for _, h := range hosts { + port := "" + if h.Port != 0 && h.Port != 22 { + port = fmt.Sprint(h.Port) + } + source := "sssh" + if h.ReadOnly { + source = "ssh_config" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + h.Name, h.Target(), port, strings.Join(h.Tags, ","), source) + } + return w.Flush() +} + +func cmdExport(args []string) error { + st, err := store.Load() + if err != nil { + return err + } + data, err := st.Marshal() + if err != nil { + return err + } + if len(args) > 0 { + if err := os.WriteFile(args[0], data, 0o600); err != nil { + return err + } + fmt.Printf("✓ %d hosts exportés vers %s\n", len(st.Hosts), args[0]) + return nil + } + _, err = os.Stdout.Write(data) + return err +} + +func cmdImport(args []string) error { + force := false + var path string + for _, a := range args { + if a == "--force" || a == "-f" { + force = true + } else { + path = a + } + } + if path == "" { + return errors.New("usage : sssh import <fichier> [--force]") + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + var in struct { + Hosts map[string]host.Host `toml:"hosts"` + } + if err := toml.Unmarshal(data, &in); err != nil { + return fmt.Errorf("%s : %w", path, err) + } + + st, err := store.Load() + if err != nil { + return err + } + added, skipped := 0, 0 + for name, h := range in.Hosts { + if _, ok := st.Get(name); ok && !force { + skipped++ + continue + } + h.Name = name + st.Set(h) + added++ + } + if err := st.Save(); err != nil { + return err + } + fmt.Printf("✓ %d importés, %d ignorés (déjà présents, --force pour écraser)\n", added, skipped) + return nil +}
vhs/add.gif
diff --git a/vhs/add.gif b/vhs/add.gif new file mode 100644 index 0000000..a9f9ba1 Binary files /dev/null and b/vhs/add.gif differ
vhs/add.tape
diff --git a/vhs/add.tape b/vhs/add.tape new file mode 100644 index 0000000..59fc3cb --- /dev/null +++ b/vhs/add.tape @@ -0,0 +1,31 @@ +# Démo one-liner : add, list, connexion directe par préfixe. +# Générer depuis la racine du repo : go build -o vhs/fakebin/sssh . && vhs vhs/add.tape +Output vhs/add.gif + +Set Shell "bash" +Set FontSize 18 +Set Width 1000 +Set Height 560 +Set Padding 16 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 60ms + +Hide +Type `export PATH="$PWD/vhs/fakebin:$PATH" SSSH_CONFIG="$(mktemp -d)/hosts.toml" HOME="$(mktemp -d)" PS1='$ ' && sssh import vhs/demo-hosts.toml >/dev/null && clear` +Enter +Show + +Type "sssh add api deploy@203.0.113.7:2222 -t client,prod" +Sleep 300ms +Enter +Sleep 1.2s + +Type "sssh list" +Sleep 300ms +Enter +Sleep 2s + +Type "sssh api" +Sleep 400ms +Enter +Sleep 4s
vhs/demo-hosts.toml
diff --git a/vhs/demo-hosts.toml b/vhs/demo-hosts.toml new file mode 100644 index 0000000..99fccd6 --- /dev/null +++ b/vhs/demo-hosts.toml @@ -0,0 +1,27 @@ +[hosts] +[hosts.bastion] +user = "admin" +host = "203.0.113.10" +tags = ["infra"] + +[hosts.pi-home] +user = "pi" +host = "192.168.1.42" +port = 2222 + +[hosts.serveur-1] +user = "root" +host = "192.0.2.11" +tags = ["client", "prod"] + +[hosts.serveur-2] +user = "deploy" +host = "192.0.2.12" +jump = "bastion" +tags = ["staging"] + +[hosts.vps-ovh] +user = "debian" +host = "203.0.113.21" +identity = "~/.ssh/id_deploy" +tags = ["perso"]
vhs/demo.gif
diff --git a/vhs/demo.gif b/vhs/demo.gif new file mode 100644 index 0000000..031b749 Binary files /dev/null and b/vhs/demo.gif differ
vhs/demo.tape
diff --git a/vhs/demo.tape b/vhs/demo.tape new file mode 100644 index 0000000..a4ca0cb --- /dev/null +++ b/vhs/demo.tape @@ -0,0 +1,36 @@ +# Démo principale : TUI, filtre, connexion. +# Générer depuis la racine du repo : go build -o vhs/fakebin/sssh . && vhs vhs/demo.tape +Output vhs/demo.gif + +Set Shell "bash" +Set FontSize 18 +Set Width 1000 +Set Height 560 +Set Padding 16 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 80ms + +Hide +Type `export PATH="$PWD/vhs/fakebin:$PATH" SSSH_CONFIG="$PWD/vhs/demo-hosts.toml" HOME="$(mktemp -d)" PS1='$ ' && clear` +Enter +Show + +Type "sssh" +Sleep 500ms +Enter +Sleep 1.5s + +Down +Sleep 600ms +Down +Sleep 800ms + +Type "/" +Sleep 400ms +Type "vps" +Sleep 800ms +Enter +Sleep 600ms + +Enter +Sleep 4s
vhs/fakebin/ssh
diff --git a/vhs/fakebin/ssh b/vhs/fakebin/ssh new file mode 100755 index 0000000..937587f --- /dev/null +++ b/vhs/fakebin/ssh @@ -0,0 +1,11 @@ +#!/bin/sh +# Faux ssh pour les démos VHS : simule une connexion sans réseau. +for a in "$@"; do target="$a"; done +sleep 0.5 +echo "Linux vps-01 6.1.0-18-amd64 #1 SMP Debian x86_64" +echo "" +echo "Last login: Wed Aug 12 09:14:02 2026 from 192.0.2.50" +sleep 0.3 +printf '%s:~$ ' "${target%%@*}@vps-01" +sleep 1.5 +echo ""