waveterm/waveshell/pkg/shellapi/bashapi.go
Mike Sawka 3c3eec73aa
reinit updates (#500)
* working on re-init when you create a tab.  some refactoring of existing reinit to make the messaging clearer.  auto-connect, etc.

* working to remove the 'default' shell states out of MShellProc.  each tab should have its own state that gets set on open.

* refactor newtab settings into individual components (and move to a new file)

* more refactoring of tab settings -- use same control in settings and newtab

* have screensettings use the same newtab settings components

* use same conn dropdown, fix classes, update some of the confirm messages to be less confusing (replace screen with tab)

* force a cr on a new tab to initialize state in a new line.  poc right now, need to add to new workspace workflow as well

* small fixups

* remove nohist from GetRawStr, make const

* update hover behavior for tabs

* fix interaction between change remote dropdown, cmdinput, error handling, and selecting a remote

* only switch screen remote if the activemainview is session (new remote flow).  don't switch it if we're on the connections page which is confusing.  also make it interactive

* fix wording on tos modal

* allow empty workspaces. also allow the last workspace to be deleted.  (prep for new startup sequence where we initialize the first session after tos modal)

* add some dead code that might come in use later (when we change how we show connection in cmdinput)

* working a cople different angles.  new settings tab-pulldown (likely orphaned).  and then allowing null activeScreen and null activeSession in workspaceview (show appropriate messages, and give buttons to create new tabs/workspaces).  prep for new startup flow

* don't call initActiveShells anymore.  also call ensureWorkspace() on TOS close

* trying to use new pulldown screen settings

* experiment with an escape keybinding

* working on tab settings close triggers

* close tab settings on tab switch

* small updates to tos popup, reorder, update button text/size, small wording updates

* when deleting a screen, send SIGHUP to all running commands

* not sure how this happened, lineid should not be passed to setLineFocus

* remove context timeouts for ReInit (it is now interactive, so it gets canceled like a normal command -- via ^C, and should not timeout on its own)

* deal with screen/session tombstones updates (ignore to quite warning)

* remove defaultfestate from remote

* fix issue with removing default ris

* remove dead code

* open the settings pulldown for new screens

* update prompt to show when the shell is still initializing (or if it failed)

* switch buttons to use wave button class, update messages, and add warning for no shell state

* all an override of rptr for dyncmds.  needed for the 'connect' command (we need to set the rptr to the *new* connection rather than the old one)

* remove old commented out code
2024-03-27 00:22:57 -07:00

306 lines
9.2 KiB
Go

// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package shellapi
import (
"bytes"
"context"
"fmt"
"os/exec"
"runtime"
"strings"
"sync"
"github.com/alessio/shellescape"
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
"github.com/wavetermdev/waveterm/waveshell/pkg/shellenv"
"github.com/wavetermdev/waveterm/waveshell/pkg/statediff"
"github.com/wavetermdev/waveterm/waveshell/pkg/utilfn"
)
const BaseBashOpts = `set +m; set +H; shopt -s extglob`
const BashShellVersionCmdStr = `echo bash v${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}.${BASH_VERSINFO[2]}`
const RemoteBashPath = "bash"
// TODO fix bash path in these constants
const RunBashSudoCommandFmt = `sudo -n -C %d bash /dev/fd/%d`
const RunBashSudoPasswordCommandFmt = `cat /dev/fd/%d | sudo -k -S -C %d bash -c "echo '[from-mshell]'; exec %d>&-; bash /dev/fd/%d < /dev/fd/%d"`
// do not use these directly, call GetLocalMajorVersion()
var localBashMajorVersionOnce = &sync.Once{}
var localBashMajorVersion = ""
// the "exec 2>" line also adds an extra printf at the *beginning* to strip out spurious rc file output
var GetBashShellStateCmds = []string{
"exec 2> /dev/null;",
BashShellVersionCmdStr + ";",
`pwd;`,
`declare -p $(compgen -A variable);`,
`alias -p;`,
`declare -f;`,
GetGitBranchCmdStr + ";",
}
type bashShellApi struct{}
func (b bashShellApi) GetShellType() string {
return packet.ShellType_bash
}
func (b bashShellApi) MakeExitTrap(fdNum int) (string, []byte) {
return MakeBashExitTrap(fdNum)
}
func (b bashShellApi) GetLocalMajorVersion() string {
return GetLocalBashMajorVersion()
}
func (b bashShellApi) GetLocalShellPath() string {
return GetLocalBashPath()
}
func (b bashShellApi) GetRemoteShellPath() string {
return RemoteBashPath
}
func (b bashShellApi) MakeRunCommand(cmdStr string, opts RunCommandOpts) string {
if !opts.Sudo {
return fmt.Sprintf(RunCommandFmt, cmdStr)
}
if opts.SudoWithPass {
return fmt.Sprintf(RunBashSudoPasswordCommandFmt, opts.PwFdNum, opts.MaxFdNum+1, opts.PwFdNum, opts.CommandFdNum, opts.CommandStdinFdNum)
} else {
return fmt.Sprintf(RunBashSudoCommandFmt, opts.MaxFdNum+1, opts.CommandFdNum)
}
}
func (b bashShellApi) MakeShExecCommand(cmdStr string, rcFileName string, usePty bool) *exec.Cmd {
return MakeBashShExecCommand(cmdStr, rcFileName, usePty)
}
func (b bashShellApi) GetShellState(ctx context.Context, outCh chan ShellStateOutput, stdinDataCh chan []byte) {
GetBashShellState(ctx, outCh, stdinDataCh)
}
func (b bashShellApi) GetBaseShellOpts() string {
return BaseBashOpts
}
func (b bashShellApi) ParseShellStateOutput(output []byte) (*packet.ShellState, *packet.ShellStateStats, error) {
return parseBashShellStateOutput(output)
}
func (b bashShellApi) MakeRcFileStr(pk *packet.RunPacketType) string {
var rcBuf bytes.Buffer
rcBuf.WriteString(b.GetBaseShellOpts() + "\n")
varDecls := shellenv.VarDeclsFromState(pk.State)
for _, varDecl := range varDecls {
if varDecl.IsExport() || varDecl.IsReadOnly() {
continue
}
if varDecl.IsExtVar {
continue
}
rcBuf.WriteString(BashDeclareStmt(varDecl))
rcBuf.WriteString("\n")
}
if pk.State != nil && pk.State.Funcs != "" {
rcBuf.WriteString(pk.State.Funcs)
rcBuf.WriteString("\n")
}
if pk.State != nil && pk.State.Aliases != "" {
rcBuf.WriteString(pk.State.Aliases)
rcBuf.WriteString("\n")
}
return rcBuf.String()
}
func GetBashShellStateCmd(fdNum int) (string, []byte) {
endBytes := utilfn.AppendNonZeroRandomBytes(nil, NumRandomEndBytes)
endBytes = append(endBytes, '\n')
cmdStr := strings.TrimSpace(`
exec 2> /dev/null;
exec > [%OUTPUTFD%];
printf "\x00\x00";
[%BASHVERSIONCMD%];
printf "\x00\x00";
pwd;
printf "\x00\x00";
declare -p $(compgen -A variable);
printf "\x00\x00";
alias -p;
printf "\x00\x00";
declare -f;
printf "\x00\x00";
[%GITBRANCHCMD%];
printf "\x00\x00";
printf "[%ENDBYTES%]";
`)
cmdStr = strings.ReplaceAll(cmdStr, "[%OUTPUTFD%]", fmt.Sprintf("/dev/fd/%d", fdNum))
cmdStr = strings.ReplaceAll(cmdStr, "[%BASHVERSIONCMD%]", BashShellVersionCmdStr)
cmdStr = strings.ReplaceAll(cmdStr, "[%GITBRANCHCMD%]", GetGitBranchCmdStr)
cmdStr = strings.ReplaceAll(cmdStr, "[%ENDBYTES%]", utilfn.ShellHexEscape(string(endBytes)))
return cmdStr, endBytes
}
func execGetLocalBashShellVersion() string {
ctx, cancelFn := context.WithTimeout(context.Background(), GetVersionTimeout)
defer cancelFn()
ecmd := exec.CommandContext(ctx, "bash", "-c", BashShellVersionCmdStr)
out, err := ecmd.Output()
if err != nil {
return ""
}
versionStr := strings.TrimSpace(string(out))
if strings.Index(versionStr, "bash ") == -1 {
// invalid shell version (only bash is supported)
return ""
}
return versionStr
}
func GetLocalBashMajorVersion() string {
localBashMajorVersionOnce.Do(func() {
fullVersion := execGetLocalBashShellVersion()
localBashMajorVersion = packet.GetMajorVersion(fullVersion)
})
return localBashMajorVersion
}
func GetBashShellState(ctx context.Context, outCh chan ShellStateOutput, stdinDataCh chan []byte) {
defer close(outCh)
stateCmd, endBytes := GetBashShellStateCmd(StateOutputFdNum)
cmdStr := BaseBashOpts + "; " + stateCmd
ecmd := exec.CommandContext(ctx, GetLocalBashPath(), "-l", "-i", "-c", cmdStr)
outputCh := make(chan []byte, 10)
var outputWg sync.WaitGroup
outputWg.Add(1)
go func() {
defer outputWg.Done()
for outputBytes := range outputCh {
outCh <- ShellStateOutput{Output: outputBytes}
}
}()
outputBytes, err := StreamCommandWithExtraFd(ctx, ecmd, outputCh, StateOutputFdNum, endBytes, stdinDataCh)
outputWg.Wait()
if err != nil {
outCh <- ShellStateOutput{Error: err.Error()}
return
}
rtn, stats, err := parseBashShellStateOutput(outputBytes)
if err != nil {
outCh <- ShellStateOutput{Error: err.Error()}
return
}
outCh <- ShellStateOutput{ShellState: rtn, Stats: stats}
}
func GetLocalBashPath() string {
if runtime.GOOS == "darwin" {
macShell := GetMacUserShell()
if strings.Index(macShell, "bash") != -1 {
return shellescape.Quote(macShell)
}
}
return "bash"
}
func GetLocalZshPath() string {
if runtime.GOOS == "darwin" {
macShell := GetMacUserShell()
if strings.Index(macShell, "zsh") != -1 {
return shellescape.Quote(macShell)
}
}
return "zsh"
}
func GetBashShellStateRedirectCommandStr(outputFdNum int) (string, []byte) {
cmdStr, endBytes := GetBashShellStateCmd(outputFdNum)
return cmdStr, endBytes
}
func MakeBashExitTrap(fdNum int) (string, []byte) {
stateCmd, endBytes := GetBashShellStateRedirectCommandStr(fdNum)
fmtStr := `
_waveshell_exittrap () {
%s
}
trap _waveshell_exittrap EXIT
`
return fmt.Sprintf(fmtStr, stateCmd), endBytes
}
func MakeBashShExecCommand(cmdStr string, rcFileName string, usePty bool) *exec.Cmd {
if usePty {
return exec.Command(GetLocalBashPath(), "--rcfile", rcFileName, "-i", "-c", cmdStr)
} else {
return exec.Command(GetLocalBashPath(), "--rcfile", rcFileName, "-c", cmdStr)
}
}
func (bashShellApi) MakeShellStateDiff(oldState *packet.ShellState, oldStateHash string, newState *packet.ShellState) (*packet.ShellStateDiff, error) {
if oldState == nil {
return nil, fmt.Errorf("cannot diff, oldState is nil")
}
if newState == nil {
return nil, fmt.Errorf("cannot diff, newState is nil")
}
if !packet.StateVersionsCompatible(oldState.Version, newState.Version) {
return nil, fmt.Errorf("cannot diff, incompatible shell versions: %q %q", oldState.Version, newState.Version)
}
rtn := &packet.ShellStateDiff{}
rtn.BaseHash = oldStateHash
rtn.Version = newState.Version // always set version in the diff
if oldState.Cwd != newState.Cwd {
rtn.Cwd = newState.Cwd
}
rtn.Error = newState.Error
oldVars := shellenv.ShellStateVarsToMap(oldState.ShellVars)
newVars := shellenv.ShellStateVarsToMap(newState.ShellVars)
rtn.VarsDiff = statediff.MakeMapDiff(oldVars, newVars)
rtn.AliasesDiff = statediff.MakeLineDiff(oldState.Aliases, newState.Aliases, oldState.GetLineDiffSplitString())
rtn.FuncsDiff = statediff.MakeLineDiff(oldState.Funcs, newState.Funcs, oldState.GetLineDiffSplitString())
return rtn, nil
}
func (bashShellApi) ApplyShellStateDiff(oldState *packet.ShellState, diff *packet.ShellStateDiff) (*packet.ShellState, error) {
if oldState == nil {
return nil, fmt.Errorf("cannot apply diff, oldState is nil")
}
if diff == nil {
return oldState, nil
}
rtnState := &packet.ShellState{}
var err error
rtnState.Version = oldState.Version
// work around a bug (before v0.6.0) where version could be invalid.
// so only overwrite the oldversion if diff version is valid
_, _, diffVersionErr := packet.ParseShellStateVersion(diff.Version)
if diffVersionErr == nil {
rtnState.Version = diff.Version
}
rtnState.Cwd = oldState.Cwd
if diff.Cwd != "" {
rtnState.Cwd = diff.Cwd
}
rtnState.Error = diff.Error
oldVars := shellenv.ShellStateVarsToMap(oldState.ShellVars)
newVars, err := statediff.ApplyMapDiff(oldVars, diff.VarsDiff)
if err != nil {
return nil, fmt.Errorf("applying mapdiff 'vars': %v", err)
}
rtnState.ShellVars = shellenv.StrMapToShellStateVars(newVars)
rtnState.Aliases, err = statediff.ApplyLineDiff(oldState.Aliases, diff.AliasesDiff)
if err != nil {
return nil, fmt.Errorf("applying diff 'aliases': %v", err)
}
rtnState.Funcs, err = statediff.ApplyLineDiff(oldState.Funcs, diff.FuncsDiff)
if err != nil {
return nil, fmt.Errorf("applying diff 'funcs': %v", err)
}
return rtnState, nil
}