diff --git a/go.mod b/go.mod index 567032ef..3fe97fe9 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/mattn/go-shellwords v1.0.12 // indirect go.uber.org/atomic v1.7.0 // indirect golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect + mvdan.cc/sh/v3 v3.5.1 // indirect ) replace github.com/scripthaus-dev/mshell v0.0.0 => /Users/mike/work/gopath/src/github.com/scripthaus-dev/mshell/ diff --git a/go.sum b/go.sum index 809fecbb..1e458fc3 100644 --- a/go.sum +++ b/go.sum @@ -1798,6 +1798,8 @@ modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.0.1-0.20210308123920-1f282aa71362/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA= modernc.org/z v1.0.1/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA= modernc.org/zappy v1.0.0/go.mod h1:hHe+oGahLVII/aTTyWK/b53VDHMAGCBYYeZ9sn83HC4= +mvdan.cc/sh/v3 v3.5.1 h1:hmP3UOw4f+EYexsJjFxvU38+kn+V/s2CclXHanIBkmQ= +mvdan.cc/sh/v3 v3.5.1/go.mod h1:1JcoyAKm1lZw/2bZje/iYKWicU/KMd0rsyJeKHnsK4E= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= diff --git a/pkg/cmdrunner/cmdrunner.go b/pkg/cmdrunner/cmdrunner.go index 86a3b652..ef042544 100644 --- a/pkg/cmdrunner/cmdrunner.go +++ b/pkg/cmdrunner/cmdrunner.go @@ -45,25 +45,6 @@ type resolvedIds struct { RState remote.RemoteState } -func SubMetaCmd(cmd string) string { - switch cmd { - case "s": - return "screen" - case "w": - return "window" - case "r": - return "run" - case "c": - return "comment" - case "e": - return "eval" - case "export": - return "setenv" - default: - return cmd - } -} - var ValidCommands = []string{ "/run", "/eval", @@ -377,12 +358,11 @@ func EvalCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore. if len(pk.Args) == 0 { return nil, fmt.Errorf("usage: /eval [command], no command passed to eval") } - // parse metacmd - commandStr := strings.TrimSpace(pk.Args[0]) - if commandStr == "" { - return nil, fmt.Errorf("/eval, invalid emtpty command") + newPk, err := EvalMetaCommand(ctx, pk) + if err != nil { + return nil, err } - update, err := evalCommandInternal(ctx, pk) + update, err := HandleCommand(ctx, newPk) if !resolveBool(pk.Kwargs["nohist"], false) { err := addToHistory(ctx, pk, update, (err != nil)) if err != nil { @@ -393,73 +373,6 @@ func EvalCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore. return update, err } -func evalCommandInternal(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { - commandStr := strings.TrimSpace(pk.Args[0]) - metaCmd := "" - metaSubCmd := "" - if commandStr == "cd" || strings.HasPrefix(commandStr, "cd ") { - metaCmd = "cd" - commandStr = strings.TrimSpace(commandStr[2:]) - } else if commandStr == "cr" || strings.HasPrefix(commandStr, "cr ") { - metaCmd = "cr" - commandStr = strings.TrimSpace(commandStr[2:]) - } else if commandStr == "export" || strings.HasPrefix(commandStr, "export ") { - metaCmd = "setenv" - commandStr = strings.TrimSpace(commandStr[6:]) - } else if commandStr == "setenv" || strings.HasPrefix(commandStr, "setenv ") { - metaCmd = "setenv" - commandStr = strings.TrimSpace(commandStr[6:]) - } else if commandStr == "unset" || strings.HasPrefix(commandStr, "unset ") { - metaCmd = "unset" - commandStr = strings.TrimSpace(commandStr[5:]) - } else if commandStr[0] == '/' { - spaceIdx := strings.Index(commandStr, " ") - if spaceIdx == -1 { - metaCmd = commandStr[1:] - commandStr = "" - } else { - metaCmd = commandStr[1:spaceIdx] - commandStr = strings.TrimSpace(commandStr[spaceIdx+1:]) - } - colonIdx := strings.Index(metaCmd, ":") - if colonIdx != -1 { - metaCmd, metaSubCmd = metaCmd[0:colonIdx], metaCmd[colonIdx+1:] - } - if metaCmd == "" { - return nil, fmt.Errorf("invalid command, got bare '/', with no command") - } - } - if metaCmd == "" { - metaCmd = "run" - } - metaCmd = SubMetaCmd(metaCmd) - newPk := &scpacket.FeCommandPacketType{ - MetaCmd: metaCmd, - MetaSubCmd: metaSubCmd, - Kwargs: pk.Kwargs, - } - if strings.HasSuffix(commandStr, " ?") { - newPk.Kwargs["ephemeral"] = "1" - commandStr = commandStr[0 : len(commandStr)-2] - } - if metaCmd == "run" || metaCmd == "comment" { - newPk.Args = []string{commandStr} - } else if (metaCmd == "setenv" || metaCmd == "unset") && metaSubCmd == "" { - newPk.Args = strings.Fields(commandStr) - } else { - allArgs := strings.Fields(commandStr) - for _, arg := range allArgs { - if strings.Index(arg, "=") == -1 { - newPk.Args = append(newPk.Args, arg) - continue - } - fields := strings.SplitN(arg, "=", 2) - newPk.Kwargs[fields[0]] = fields[1] - } - } - return HandleCommand(ctx, newPk) -} - func ScreenCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { if pk.MetaSubCmd == "close" { ids, err := resolveIds(ctx, pk, R_Session|R_Screen) @@ -976,6 +889,27 @@ func SessionCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (ssto } return update, nil } + if pk.MetaSubCmd == "set" { + ids, err := resolveIds(ctx, pk, R_Session) + if err != nil { + return nil, err + } + bareSession, err := sstore.GetBareSessionById(ctx, ids.SessionId) + if err != nil { + return nil, err + } + if bareSession == nil { + return nil, fmt.Errorf("session '%s' not found", ids.SessionId) + } + update := sstore.ModelUpdate{ + Sessions: nil, + Info: &sstore.InfoMsgType{ + InfoMsg: fmt.Sprintf("[%s]: update", bareSession.Name), + TimeoutMs: 2000, + }, + } + return update, nil + } if pk.MetaSubCmd != "" { return nil, fmt.Errorf("invalid /session subcommand '%s'", pk.MetaSubCmd) } diff --git a/pkg/cmdrunner/shparse.go b/pkg/cmdrunner/shparse.go new file mode 100644 index 00000000..96e84852 --- /dev/null +++ b/pkg/cmdrunner/shparse.go @@ -0,0 +1,210 @@ +package cmdrunner + +import ( + "context" + "fmt" + "io" + "regexp" + "strings" + + "github.com/scripthaus-dev/sh2-server/pkg/scpacket" + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/syntax" +) + +type parseEnviron struct { + Env map[string]string +} + +func (e *parseEnviron) Get(name string) expand.Variable { + val, ok := e.Env[name] + if !ok { + return expand.Variable{} + } + return expand.Variable{ + Exported: true, + Kind: expand.String, + Str: val, + } +} + +func (e *parseEnviron) Each(fn func(name string, vr expand.Variable) bool) { + for key, _ := range e.Env { + rtn := fn(key, e.Get(key)) + if !rtn { + break + } + } +} + +func DumpPacket(pk *scpacket.FeCommandPacketType) { + if pk == nil || pk.MetaCmd == "" { + fmt.Printf("[no metacmd]\n") + return + } + if pk.MetaSubCmd == "" { + fmt.Printf("/%s\n", pk.MetaCmd) + } else { + fmt.Printf("/%s:%s\n", pk.MetaCmd, pk.MetaSubCmd) + } + for _, arg := range pk.Args { + fmt.Printf(" %q\n", arg) + } + for key, val := range pk.Kwargs { + fmt.Printf(" [%s]=%q\n", key, val) + } +} + +func doCmdSubst(commandStr string, w io.Writer, word *syntax.CmdSubst) error { + return nil +} + +func doProcSubst(w *syntax.ProcSubst) (string, error) { + return "", nil +} + +func isQuoted(source string, w *syntax.Word) bool { + if w == nil { + return false + } + offset := w.Pos().Offset() + if int(offset) >= len(source) { + return false + } + return source[offset] == '"' || source[offset] == '\'' +} + +func getSourceStr(source string, w *syntax.Word) string { + if w == nil { + return "" + } + offset := w.Pos().Offset() + end := w.End().Offset() + return source[offset:end] +} + +var ValidMetaCmdRe = regexp.MustCompile("^/([a-z][a-z0-9_-]*)(:[a-z][a-z0-9_-]*)?$") + +type BareMetaCmdDecl struct { + CmdStr string + MetaCmd string +} + +var BareMetaCmds = []BareMetaCmdDecl{ + BareMetaCmdDecl{"cd", "cd"}, + BareMetaCmdDecl{"cr", "cr"}, + BareMetaCmdDecl{"setenv", "setenv"}, + BareMetaCmdDecl{"export", "setenv"}, + BareMetaCmdDecl{"unset", "unset"}, +} + +func SubMetaCmd(cmd string) string { + switch cmd { + case "s": + return "screen" + case "w": + return "window" + case "r": + return "run" + case "c": + return "comment" + case "e": + return "eval" + case "export": + return "setenv" + default: + return cmd + } +} + +// returns (metaCmd, metaSubCmd, rest) +// if metaCmd is "" then this isn't a valid metacmd string +func parseMetaCmd(origCommandStr string) (string, string, string) { + commandStr := strings.TrimSpace(origCommandStr) + if len(commandStr) < 2 { + return "run", "", origCommandStr + } + fields := strings.SplitN(commandStr, " ", 2) + firstArg := fields[0] + rest := "" + if len(fields) > 1 { + rest = strings.TrimSpace(fields[1]) + } + for _, decl := range BareMetaCmds { + if firstArg == decl.CmdStr { + return decl.MetaCmd, "", rest + } + } + m := ValidMetaCmdRe.FindStringSubmatch(firstArg) + if m == nil { + return "run", "", origCommandStr + } + return SubMetaCmd(m[1]), m[2], rest +} + +func onlyPositionalArgs(metaCmd string, metaSubCmd string) bool { + return (metaCmd == "setenv" || metaCmd == "unset") && metaSubCmd == "" +} + +func onlyRawArgs(metaCmd string, metaSubCmd string) bool { + return metaCmd == "run" || metaCmd == "comment" +} + +func EvalMetaCommand(ctx context.Context, origPk *scpacket.FeCommandPacketType) (*scpacket.FeCommandPacketType, error) { + if len(origPk.Args) == 0 { + return nil, fmt.Errorf("empty command (no fields)") + } + metaCmd, metaSubCmd, commandArgs := parseMetaCmd(origPk.Args[0]) + rtnPk := scpacket.MakeFeCommandPacket() + rtnPk.MetaCmd = metaCmd + rtnPk.MetaSubCmd = metaSubCmd + rtnPk.Kwargs = make(map[string]string) + for key, val := range origPk.Kwargs { + rtnPk.Kwargs[key] = val + } + if onlyRawArgs(metaCmd, metaSubCmd) { + // don't evaluate arguments for /run or /comment + rtnPk.Args = []string{commandArgs} + return rtnPk, nil + } + commandReader := strings.NewReader(commandArgs) + parser := syntax.NewParser(syntax.Variant(syntax.LangBash)) + var words []*syntax.Word + err := parser.Words(commandReader, func(w *syntax.Word) bool { + words = append(words, w) + return true + }) + if err != nil { + return nil, fmt.Errorf("parsing metacmd, position %v", err) + } + envMap := make(map[string]string) // later we can add vars like session, window, screen, remote, and user + cfg := &expand.Config{ + Env: &parseEnviron{Env: envMap}, + GlobStar: false, + NullGlob: false, + NoUnset: false, + CmdSubst: func(w io.Writer, word *syntax.CmdSubst) error { return doCmdSubst(commandArgs, w, word) }, + ProcSubst: doProcSubst, + ReadDir: nil, + } + // process arguments + for idx, w := range words { + literalVal, err := expand.Literal(cfg, w) + if err != nil { + return nil, fmt.Errorf("error evaluating metacmd argument %d [%s]: %v", idx+1, getSourceStr(commandArgs, w), err) + } + if isQuoted(commandArgs, w) || onlyPositionalArgs(metaCmd, metaSubCmd) { + rtnPk.Args = append(rtnPk.Args, literalVal) + continue + } + eqIdx := strings.Index(literalVal, "=") + if eqIdx != -1 && eqIdx != 0 { + varName := literalVal[:eqIdx] + varVal := literalVal[eqIdx+1:] + rtnPk.Kwargs[varName] = varVal + continue + } + rtnPk.Args = append(rtnPk.Args, literalVal) + } + return rtnPk, nil +} diff --git a/pkg/remote/remote.go b/pkg/remote/remote.go index 3f94c0b4..0c31ad8d 100644 --- a/pkg/remote/remote.go +++ b/pkg/remote/remote.go @@ -250,6 +250,12 @@ func (proc *MShellProc) GetRemoteState() RemoteState { return state } +func (msh *MShellProc) NotifyUpdate() { + rstate := msh.GetRemoteState() + update := &sstore.ModelUpdate{Remote: rstate} + sstore.MainBus.SendUpdate("", update) +} + func GetAllRemoteState() []RemoteState { GlobalStore.Lock.Lock() defer GlobalStore.Lock.Unlock() diff --git a/pkg/sstore/dbops.go b/pkg/sstore/dbops.go index 735c9275..7fd0d8c1 100644 --- a/pkg/sstore/dbops.go +++ b/pkg/sstore/dbops.go @@ -142,6 +142,22 @@ func GetBareSessions(ctx context.Context) ([]*SessionType, error) { return rtn, nil } +func GetBareSessionById(ctx context.Context, sessionId string) (*SessionType, error) { + var rtn SessionType + txErr := WithTx(ctx, func(tx *TxWrap) error { + query := `SELECT * FROM session WHERE sessionid = ?` + tx.GetWrap(&rtn, query, sessionId) + return nil + }) + if txErr != nil { + return nil, txErr + } + if rtn.SessionId == "" { + return nil, nil + } + return &rtn, nil +} + func GetAllSessions(ctx context.Context) ([]*SessionType, error) { var rtn []*SessionType err := WithTx(ctx, func(tx *TxWrap) error { @@ -636,3 +652,54 @@ func UpdateCurRemote(ctx context.Context, sessionId string, windowId string, rem }) return txErr } + +func reorderStrings(strs []string, toMove string, newIndex int) []string { + if toMove == "" { + return strs + } + var newStrs []string + if newIndex < 0 { + newStrs = append(newStrs, toMove) + } + for _, sval := range strs { + if len(newStrs) == newIndex { + newStrs = append(newStrs, toMove) + } + if sval != toMove { + newStrs = append(newStrs, sval) + } + } + if newIndex >= len(newStrs) { + newStrs = append(newStrs, toMove) + } + return newStrs +} + +func ReIndexSessions(ctx context.Context, sessionId string, newIndex int) error { + txErr := WithTx(ctx, func(tx *TxWrap) error { + query := `SELECT sessionid FROM sessions ORDER BY sessionidx, name, sessionid` + ids := tx.SelectStrings(query) + if sessionId != "" { + ids = reorderStrings(ids, sessionId, newIndex) + } + query = `UPDATE sessions SET sessionid = ? WHERE sessionid = ?` + for idx, id := range ids { + tx.ExecWrap(query, id, idx+1) + } + return nil + }) + return txErr +} + +func SetSessionName(ctx context.Context, sessionId string, name string) error { + txErr := WithTx(ctx, func(tx *TxWrap) error { + query := `SELECT sessionid FROM sessions WHERE sessionid = ?` + if !tx.Exists(query, sessionId) { + return fmt.Errorf("session does not exist") + } + query = `UPDATE sessions SET name = ? WHERE sessionid = ?` + tx.ExecWrap(query, name, sessionId) + return nil + }) + return txErr +} diff --git a/pkg/sstore/updatebus.go b/pkg/sstore/updatebus.go index 911dd138..47057778 100644 --- a/pkg/sstore/updatebus.go +++ b/pkg/sstore/updatebus.go @@ -31,6 +31,7 @@ type ModelUpdate struct { Cmd *CmdType `json:"cmd,omitempty"` CmdLine *CmdLineType `json:"cmdline,omitempty"` Info *InfoMsgType `json:"info,omitempty"` + Remote interface{} `json:"remote,omitempty"` // *remote.RemoteState } func (ModelUpdate) UpdateType() string {