163 lines
4.0 KiB
Go
163 lines
4.0 KiB
Go
// Package exec plans and performs the final process handoff.
|
|
package exec
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
osexec "os/exec"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/KofTwentyTwo/limen/internal/config"
|
|
"github.com/KofTwentyTwo/limen/internal/probe"
|
|
"github.com/KofTwentyTwo/limen/internal/ssh"
|
|
"github.com/KofTwentyTwo/limen/internal/state"
|
|
)
|
|
|
|
const maxSessionNameLength = 64
|
|
|
|
// ErrEmptyArgv reports an invalid handoff plan.
|
|
var ErrEmptyArgv = errors.New("empty argv")
|
|
|
|
// Plan is the final command vector and the host it targets.
|
|
type Plan struct {
|
|
HostName string
|
|
Argv []string
|
|
}
|
|
|
|
// SyscallExec is the injectable boundary around syscall.Exec.
|
|
type SyscallExec func(path string, argv []string, env []string) error
|
|
|
|
// Options supplies dependencies for Handoff.
|
|
type Options struct {
|
|
StatePath string
|
|
Now func() time.Time
|
|
Environ func() []string
|
|
Stderr io.Writer
|
|
LoadState func(string) state.State
|
|
SaveState func(string, state.State) error
|
|
Exec SyscallExec
|
|
}
|
|
|
|
// LocalAttach plans `tmux attach -t <session>` for localhost.
|
|
func LocalAttach(session string) Plan {
|
|
return Plan{
|
|
HostName: probe.LocalhostName,
|
|
Argv: ssh.LocalTmuxArgv("attach", "-t", session),
|
|
}
|
|
}
|
|
|
|
// LocalNew plans `tmux new`, optionally with `-s <name>`.
|
|
func LocalNew(name string) (Plan, error) {
|
|
args, err := newSessionArgs(name)
|
|
if err != nil {
|
|
return Plan{}, err
|
|
}
|
|
return Plan{
|
|
HostName: probe.LocalhostName,
|
|
Argv: ssh.LocalTmuxArgv(args...),
|
|
}, nil
|
|
}
|
|
|
|
// RemoteAttach plans `ssh -t <host> tmux attach -t <session>`.
|
|
func RemoteAttach(host config.Host, session string) Plan {
|
|
return Plan{
|
|
HostName: host.Name,
|
|
Argv: ssh.SessionArgv(host, "attach", "-t", session),
|
|
}
|
|
}
|
|
|
|
// RemoteNew plans `ssh -t <host> tmux new`, optionally with `-s <name>`.
|
|
func RemoteNew(host config.Host, name string) (Plan, error) {
|
|
args, err := newSessionArgs(name)
|
|
if err != nil {
|
|
return Plan{}, err
|
|
}
|
|
return Plan{
|
|
HostName: host.Name,
|
|
Argv: ssh.SessionArgv(host, args...),
|
|
}, nil
|
|
}
|
|
|
|
// NormalizeSessionName trims and validates a name for `tmux new -s`.
|
|
// Empty input is valid and means tmux should create an unnamed session.
|
|
func NormalizeSessionName(name string) (string, bool, error) {
|
|
normalized := strings.TrimSpace(name)
|
|
if normalized == "" {
|
|
return "", false, nil
|
|
}
|
|
if strings.ContainsAny(normalized, ":.") {
|
|
return "", false, errors.New("name cannot contain : or .")
|
|
}
|
|
if len([]rune(normalized)) > maxSessionNameLength {
|
|
return "", false, fmt.Errorf("name cannot exceed %d characters", maxSessionNameLength)
|
|
}
|
|
return normalized, true, nil
|
|
}
|
|
|
|
// Handoff records the destination host, then replaces the current process.
|
|
// State is advisory: a write failure must not strand the user before exec.
|
|
func Handoff(plan Plan, opts Options) int {
|
|
deps := opts.withDefaults()
|
|
if len(plan.Argv) == 0 {
|
|
fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", ErrEmptyArgv)
|
|
return 127
|
|
}
|
|
|
|
st := deps.LoadState(deps.StatePath)
|
|
if st.LastAttached == nil {
|
|
st.LastAttached = map[string]time.Time{}
|
|
}
|
|
st.LastAttached[plan.HostName] = deps.Now().UTC()
|
|
_ = deps.SaveState(deps.StatePath, st)
|
|
|
|
execPath, err := osexec.LookPath(plan.Argv[0])
|
|
if err != nil {
|
|
fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", err)
|
|
return 127
|
|
}
|
|
|
|
if err := deps.Exec(execPath, plan.Argv, deps.Environ()); err != nil {
|
|
fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", err)
|
|
return 127
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
func newSessionArgs(name string) ([]string, error) {
|
|
normalized, named, err := NormalizeSessionName(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !named {
|
|
return []string{"new"}, nil
|
|
}
|
|
return []string{"new", "-s", normalized}, nil
|
|
}
|
|
|
|
func (opts Options) withDefaults() Options {
|
|
if opts.Now == nil {
|
|
opts.Now = time.Now
|
|
}
|
|
if opts.Environ == nil {
|
|
opts.Environ = os.Environ
|
|
}
|
|
if opts.Stderr == nil {
|
|
opts.Stderr = io.Discard
|
|
}
|
|
if opts.LoadState == nil {
|
|
opts.LoadState = state.Load
|
|
}
|
|
if opts.SaveState == nil {
|
|
opts.SaveState = state.Save
|
|
}
|
|
if opts.Exec == nil {
|
|
opts.Exec = syscall.Exec
|
|
}
|
|
return opts
|
|
}
|