Skip to content

Commit

Permalink
docs: improving examples and docs (#235)
Browse files Browse the repository at this point in the history
* docs: improving examples and docs

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* refactor: renamed some vars

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* docs: more examples

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* chore: typo

* docs: wording

* Apply suggestions from code review

Co-authored-by: Ayman Bagabas <[email protected]>

---------

Signed-off-by: Carlos Alexandro Becker <[email protected]>
Co-authored-by: Ayman Bagabas <[email protected]>
  • Loading branch information
caarlos0 and aymanbagabas authored Feb 6, 2024
1 parent 5790d56 commit 23261db
Show file tree
Hide file tree
Showing 22 changed files with 483 additions and 253 deletions.
18 changes: 8 additions & 10 deletions activeterm/activeterm.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,21 @@
package activeterm

import (
"fmt"

"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
)

// Middleware will exit 1 connections trying with no active terminals.
func Middleware() wish.Middleware {
return func(sh ssh.Handler) ssh.Handler {
return func(s ssh.Session) {
_, _, active := s.Pty()
if !active {
fmt.Fprintln(s, "Requires an active PTY")
s.Exit(1) // nolint: errcheck
return // unreachable
return func(next ssh.Handler) ssh.Handler {
return func(sess ssh.Session) {
_, _, active := sess.Pty()
if active {
next(sess)
return
}
sh(s)
wish.Println(sess, "Requires an active PTY")
_ = sess.Exit(1)
}
}
}
58 changes: 29 additions & 29 deletions bubbletea/tea.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type BubbleTeaHandler = Handler // nolint: revive
// Handler is the function Bubble Tea apps implement to hook into the
// SSH Middleware. This will create a new tea.Program for every connection and
// start it with the tea.ProgramOptions returned.
type Handler func(ssh.Session) (tea.Model, []tea.ProgramOption)
type Handler func(sess ssh.Session) (tea.Model, []tea.ProgramOption)

// ProgramHandler is the function Bubble Tea apps implement to hook into the SSH
// Middleware. This should return a new tea.Program. This handler is different
Expand All @@ -32,24 +32,24 @@ type Handler func(ssh.Session) (tea.Model, []tea.ProgramOption)
//
// Make sure to set the tea.WithInput and tea.WithOutput to the ssh.Session
// otherwise the program will not function properly.
type ProgramHandler func(ssh.Session) *tea.Program
type ProgramHandler func(sess ssh.Session) *tea.Program

// Middleware takes a Handler and hooks the input and output for the
// ssh.Session into the tea.Program.
//
// It also captures window resize events and sends them to the tea.Program
// as tea.WindowSizeMsgs.
func Middleware(bth Handler) wish.Middleware {
return MiddlewareWithProgramHandler(newDefaultProgramHandler(bth), termenv.Ascii)
func Middleware(handler Handler) wish.Middleware {
return MiddlewareWithProgramHandler(newDefaultProgramHandler(handler), termenv.Ascii)
}

// MiddlewareWithColorProfile allows you to specify the minimum number of colors
// this program needs to work properly.
//
// If the client's color profile has less colors than p, p will be forced.
// Use with caution.
func MiddlewareWithColorProfile(bth Handler, p termenv.Profile) wish.Middleware {
return MiddlewareWithProgramHandler(newDefaultProgramHandler(bth), p)
func MiddlewareWithColorProfile(handler Handler, profile termenv.Profile) wish.Middleware {
return MiddlewareWithProgramHandler(newDefaultProgramHandler(handler), profile)
}

// MiddlewareWithProgramHandler allows you to specify the ProgramHandler to be
Expand All @@ -65,41 +65,41 @@ func MiddlewareWithColorProfile(bth Handler, p termenv.Profile) wish.Middleware
//
// If the client's color profile has less colors than p, p will be forced.
// Use with caution.
func MiddlewareWithProgramHandler(bth ProgramHandler, p termenv.Profile) wish.Middleware {
return func(h ssh.Handler) ssh.Handler {
return func(s ssh.Session) {
s.Context().SetValue(minColorProfileKey, p)
_, windowChanges, ok := s.Pty()
func MiddlewareWithProgramHandler(handler ProgramHandler, profile termenv.Profile) wish.Middleware {
return func(next ssh.Handler) ssh.Handler {
return func(sess ssh.Session) {
sess.Context().SetValue(minColorProfileKey, profile)
_, windowChanges, ok := sess.Pty()
if !ok {
wish.Fatalln(s, "no active terminal, skipping")
wish.Fatalln(sess, "no active terminal, skipping")
return
}
p := bth(s)
if p == nil {
h(s)
program := handler(sess)
if program == nil {
next(sess)
return
}
ctx, cancel := context.WithCancel(s.Context())
ctx, cancel := context.WithCancel(sess.Context())
go func() {
for {
select {
case <-ctx.Done():
p.Quit()
program.Quit()
return
case w := <-windowChanges:
p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})
program.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})
}
}
}()
if _, err := p.Run(); err != nil {
if _, err := program.Run(); err != nil {
log.Error("app exit with error", "error", err)
}
// p.Kill() will force kill the program if it's still running,
// and restore the terminal to its original state in case of a
// tui crash
p.Kill()
program.Kill()
cancel()
h(s)
next(sess)
}
}
}
Expand All @@ -110,23 +110,23 @@ var profileNames = [4]string{"TrueColor", "ANSI256", "ANSI", "Ascii"}

// MakeRenderer returns a lipgloss renderer for the current session.
// This function handle PTYs as well, and should be used to style your application.
func MakeRenderer(s ssh.Session) *lipgloss.Renderer {
cp, ok := s.Context().Value(minColorProfileKey).(termenv.Profile)
func MakeRenderer(sess ssh.Session) *lipgloss.Renderer {
cp, ok := sess.Context().Value(minColorProfileKey).(termenv.Profile)
if !ok {
cp = termenv.Ascii
}
r := newRenderer(s)
r := newRenderer(sess)
if r.ColorProfile() > cp {
wish.Printf(s, "Warning: Client's terminal is %q, forcing %q\r\n", profileNames[r.ColorProfile()], profileNames[cp])
wish.Printf(sess, "Warning: Client's terminal is %q, forcing %q\r\n", profileNames[r.ColorProfile()], profileNames[cp])
r.SetColorProfile(cp)
}
return r
}

// MakeOptions returns the tea.WithInput and tea.WithOutput program options
// taking into account possible Emulated or Allocated PTYs.
func MakeOptions(s ssh.Session) []tea.ProgramOption {
return makeOpts(s)
func MakeOptions(sess ssh.Session) []tea.ProgramOption {
return makeOpts(sess)
}

type sshEnviron []string
Expand All @@ -148,9 +148,9 @@ func (e sshEnviron) Getenv(k string) string {
return ""
}

func newDefaultProgramHandler(bth Handler) ProgramHandler {
func newDefaultProgramHandler(handler Handler) ProgramHandler {
return func(s ssh.Session) *tea.Program {
m, opts := bth(s)
m, opts := handler(s)
if m == nil {
return nil
}
Expand Down
28 changes: 28 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Wish Examples

We recommend you follow the examples in the following order:

## Basics

1. [Simple](./simple)
1. [Server banner and middleware](./banner)
1. [Identifying Users](./identity)
1. [Multiple authentication types](./multi-auth)

## Making SSH apps

1. [Using spf13/cobra](./cobra)
1. [Serving Bubble Tea apps](./bubbletea)
1. [Serving Bubble Tea programs](./bubbleteaprogram)
1. [Reverse Port Forwarding](./forward)
1. [Multichat](./multichat)

## SCP, SFTP, and Git

1. [Serving a Git repository](./git)
1. [SCP and SFTP](./scp)

## Pseudo Terminals

1. [Allocate a PTY](./pty)
1. [Running Bubble Tea, and executing another program on an allocated PTY](./wish-exec)
File renamed without changes.
25 changes: 14 additions & 11 deletions examples/pwd-banner/main.go → examples/banner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"net"
"os"
"os/signal"
"syscall"
Expand All @@ -20,43 +21,45 @@ import (

const (
host = "localhost"
port = 23234
port = "23234"
)

//go:embed banner.txt
var banner string

func main() {
s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf("%s:%d", host, port)),
wish.WithHostKeyPath(".ssh/term_info_ed25519"),
wish.WithAddress(net.JoinHostPort(host, port)),
wish.WithHostKeyPath(".ssh/id_ed25519"),
// A banner is always shown, even before authentication.
wish.WithBannerHandler(func(ctx ssh.Context) string {
return fmt.Sprintf(banner, ctx.User())
}),
wish.WithPasswordAuth(func(ctx ssh.Context, password string) bool {
return password == "asd123"
}),
wish.WithMiddleware(
func(h ssh.Handler) ssh.Handler {
return func(s ssh.Session) {
wish.Println(s, "Hello, world!")
h(s)
func(next ssh.Handler) ssh.Handler {
return func(sess ssh.Session) {
wish.Println(sess, fmt.Sprintf("Hello, %s!", sess.User()))
next(sess)
}
},
elapsed.Middleware(),
logging.Middleware(),
// This middleware prints the session duration before disconnecting.
elapsed.Middleware(),
),
)
if err != nil {
log.Error("could not start server", "error", err)
log.Error("Could not start server", "error", err)
}

done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
log.Info("Starting SSH server", "host", host, "port", port)
go func() {
if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Error("could not start server", "error", err)
log.Error("Could not start server", "error", err)
done <- nil
}
}()
Expand All @@ -66,6 +69,6 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer func() { cancel() }()
if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Error("could not stop server", "error", err)
log.Error("Could not stop server", "error", err)
}
}
49 changes: 31 additions & 18 deletions examples/bubbletea/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"net"
"os"
"os/signal"
"syscall"
Expand All @@ -17,34 +18,36 @@ import (
"github.com/charmbracelet/log"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
bm "github.com/charmbracelet/wish/bubbletea"
lm "github.com/charmbracelet/wish/logging"
"github.com/charmbracelet/wish/activeterm"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/logging"
)

const (
host = "localhost"
port = 23234
port = "23234"
)

func main() {
s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf("%s:%d", host, port)),
wish.WithHostKeyPath(".ssh/term_info_ed25519"),
wish.WithAddress(net.JoinHostPort(host, port)),
wish.WithHostKeyPath(".ssh/id_ed25519"),
wish.WithMiddleware(
bm.Middleware(teaHandler),
lm.Middleware(),
bubbletea.Middleware(teaHandler),
activeterm.Middleware(), // Bubble Tea apps usually require a PTY.
logging.Middleware(),
),
)
if err != nil {
log.Error("could not start server", "error", err)
log.Error("Could not start server", "error", err)
}

done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
log.Info("Starting SSH server", "host", host, "port", port)
go func() {
if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Error("could not start server", "error", err)
log.Error("Could not start server", "error", err)
done <- nil
}
}()
Expand All @@ -54,7 +57,7 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer func() { cancel() }()
if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Error("could not stop server", "error", err)
log.Error("Could not stop server", "error", err)
}
}

Expand All @@ -63,18 +66,28 @@ func main() {
// pass it to the new model. You can also return tea.ProgramOptions (such as
// tea.WithAltScreen) on a session by session basis.
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
pty, _, active := s.Pty()
if !active {
wish.Fatalln(s, "no active terminal, skipping")
return nil, nil
}
renderer := bm.MakeRenderer(s)
// This should never fail, as we are using the activeterm middleware.
pty, _, _ := s.Pty()

// When running a Bubble Tea app over SSH, you shouldn't use the default
// lipgloss.NewStyle function.
// That function will use the color profile from the os.Stdin, which is the
// server, not the client.
// We provide a MakeRenderer function in the bubbletea middleware package,
// so you can easily get the correct renderer for the current session, and
// use it to create the styles.
// The recommended way to use these styles is to then pass them down to
// your Bubble Tea model.
renderer := bubbletea.MakeRenderer(s)
txtStyle := renderer.NewStyle().Foreground(lipgloss.Color("10"))
quitStyle := renderer.NewStyle().Foreground(lipgloss.Color("8"))

m := model{
term: pty.Term,
width: pty.Window.Width,
height: pty.Window.Height,
txtStyle: renderer.NewStyle().Foreground(lipgloss.Color("10")),
quitStyle: renderer.NewStyle().Foreground(lipgloss.Color("8")),
txtStyle: txtStyle,
quitStyle: quitStyle,
}
return m, []tea.ProgramOption{tea.WithAltScreen()}
}
Expand Down
Loading

0 comments on commit 23261db

Please sign in to comment.