- Cross-compiled Go-based shell for Windows (PE32+ executable) - Builtins: cd, pwd, echo, exit, export, source, alias, type - Coreutils: ls, cat, grep, sort, wc, head, find, cp, mv, rm, mkdir, touch, clear - Command chaining: &&, ||, ; - Pipes between builtins and external commands - Variable expansion (, ) and assignment (NAME=VALUE) - Tokenizer with single/double quote handling - Linux and Windows (x86_64) builds via build.sh
449 lines
11 KiB
Go
449 lines
11 KiB
Go
package shell
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
type Shell struct {
|
|
vars map[string]string
|
|
builtins map[string]func([]string) error
|
|
lastExit int
|
|
}
|
|
|
|
func New() *Shell {
|
|
s := &Shell{
|
|
vars: map[string]string{},
|
|
lastExit: 0,
|
|
}
|
|
s.initBuiltins()
|
|
s.vars["SHELL"] = "bash-for-windows"
|
|
s.vars["BASH_VERSION"] = "1.0.0"
|
|
s.vars["?"] = "0"
|
|
if pwd, err := os.Getwd(); err == nil {
|
|
s.vars["PWD"] = pwd
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
s.vars["HOME"] = home
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *Shell) Execute(input string) error {
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return nil
|
|
}
|
|
|
|
lines := strings.Split(input, "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if err := s.executeLine(line); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Shell) executeLine(line string) error {
|
|
// Tokenize the line into its components, handling &&, ||, ;
|
|
return s.executeChain(line)
|
|
}
|
|
|
|
// executeChain: parse && / || / ; with left-to-right precedence
|
|
func (s *Shell) executeChain(line string) error {
|
|
// Strategy: split by ; first (semicolons always separate),
|
|
// then by &&/|| within each segment
|
|
segments := splitBySemicolon(line)
|
|
|
|
for _, seg := range segments {
|
|
seg = strings.TrimSpace(seg)
|
|
if seg == "" {
|
|
continue
|
|
}
|
|
if err := s.executeAndOrList(seg); err != nil {
|
|
// In ; chains, errors in one command don't stop execution
|
|
s.setExitCode(err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func splitBySemicolon(line string) []string {
|
|
var parts []string
|
|
current := ""
|
|
inSingle := false
|
|
inDouble := false
|
|
|
|
for i := 0; i < len(line); i++ {
|
|
c := line[i]
|
|
switch {
|
|
case c == '\'' && !inDouble:
|
|
inSingle = !inSingle
|
|
current += string(c)
|
|
case c == '"' && !inSingle:
|
|
inDouble = !inDouble
|
|
current += string(c)
|
|
case c == ';' && !inSingle && !inDouble:
|
|
parts = append(parts, current)
|
|
current = ""
|
|
default:
|
|
current += string(c)
|
|
}
|
|
}
|
|
if current != "" {
|
|
parts = append(parts, current)
|
|
}
|
|
return parts
|
|
}
|
|
|
|
// executeAndOrList: parse && / || with left-to-right precedence
|
|
func (s *Shell) executeAndOrList(line string) error {
|
|
type token struct {
|
|
text string
|
|
op string // operator BEFORE this token (except first = "")
|
|
}
|
|
|
|
var tokens []token
|
|
current := ""
|
|
op := ""
|
|
inSingle := false
|
|
inDouble := false
|
|
|
|
for i := 0; i < len(line); i++ {
|
|
c := line[i]
|
|
switch {
|
|
case c == '\'' && !inDouble:
|
|
inSingle = !inSingle
|
|
current += string(c)
|
|
case c == '"' && !inSingle:
|
|
inDouble = !inDouble
|
|
current += string(c)
|
|
case c == '&' && !inSingle && !inDouble:
|
|
if i+1 < len(line) && line[i+1] == '&' {
|
|
if current != "" {
|
|
tokens = append(tokens, token{current, op})
|
|
current = ""
|
|
}
|
|
op = "&&"
|
|
i++
|
|
} else {
|
|
current += string(c)
|
|
}
|
|
case c == '|' && !inSingle && !inDouble:
|
|
if i+1 < len(line) && line[i+1] == '|' {
|
|
if current != "" {
|
|
tokens = append(tokens, token{current, op})
|
|
current = ""
|
|
}
|
|
op = "||"
|
|
i++
|
|
} else {
|
|
current += string(c)
|
|
}
|
|
default:
|
|
current += string(c)
|
|
}
|
|
}
|
|
if current != "" {
|
|
tokens = append(tokens, token{current, op})
|
|
}
|
|
|
|
if len(tokens) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var lastErr error
|
|
for i, tok := range tokens {
|
|
cmd := strings.TrimSpace(tok.text)
|
|
if cmd == "" {
|
|
continue
|
|
}
|
|
|
|
shouldRun := true
|
|
if i > 0 && tok.op == "&&" {
|
|
shouldRun = (lastErr == nil)
|
|
} else if i > 0 && tok.op == "||" {
|
|
shouldRun = (lastErr != nil)
|
|
}
|
|
|
|
if shouldRun {
|
|
err := s.executePipeline(cmd)
|
|
lastErr = err
|
|
s.setExitCode(err)
|
|
}
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
func (s *Shell) setExitCode(err error) {
|
|
if err != nil {
|
|
s.vars["?"] = "1"
|
|
} else {
|
|
s.vars["?"] = "0"
|
|
}
|
|
}
|
|
|
|
func (s *Shell) executePipeline(input string) error {
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return nil
|
|
}
|
|
|
|
if strings.Contains(input, "|") {
|
|
return s.doPipe(input)
|
|
}
|
|
|
|
return s.executeCommand(input)
|
|
}
|
|
|
|
func (s *Shell) executeCommand(input string) error {
|
|
parts := s.tokenize(input)
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
cmdName := parts[0]
|
|
args := parts[1:]
|
|
|
|
if alias, ok := aliases[cmdName]; ok {
|
|
fullCmd := alias
|
|
if len(args) > 0 {
|
|
fullCmd += " " + strings.Join(args, " ")
|
|
}
|
|
return s.Execute(fullCmd)
|
|
}
|
|
|
|
if builtin, ok := s.builtins[cmdName]; ok {
|
|
return builtin(args)
|
|
}
|
|
|
|
return s.executeExternal(cmdName, args)
|
|
}
|
|
|
|
func (s *Shell) tokenize(input string) []string {
|
|
var tokens []string
|
|
current := ""
|
|
inSingle := false
|
|
inDouble := false
|
|
|
|
for i := 0; i < len(input); i++ {
|
|
c := input[i]
|
|
|
|
switch {
|
|
case c == '\'' && !inDouble:
|
|
inSingle = !inSingle
|
|
case c == '"' && !inSingle:
|
|
inDouble = !inDouble
|
|
case (c == ' ' || c == '\t') && !inSingle && !inDouble:
|
|
if current != "" {
|
|
tokens = append(tokens, current)
|
|
current = ""
|
|
}
|
|
case c == '=' && !inSingle && !inDouble:
|
|
current += string(c)
|
|
default:
|
|
current += string(c)
|
|
}
|
|
}
|
|
|
|
if current != "" {
|
|
tokens = append(tokens, current)
|
|
}
|
|
|
|
for i, tok := range tokens {
|
|
if strings.HasPrefix(tok, "$") {
|
|
key := tok[1:]
|
|
if strings.HasPrefix(key, "{") && strings.HasSuffix(key, "}") {
|
|
key = key[1 : len(key)-1]
|
|
}
|
|
if val, ok := s.vars[key]; ok {
|
|
tokens[i] = val
|
|
} else if val := os.Getenv(key); val != "" {
|
|
tokens[i] = val
|
|
}
|
|
}
|
|
}
|
|
|
|
varAssignIdx := -1
|
|
for i, tok := range tokens {
|
|
if strings.Contains(tok, "=") && i == 0 {
|
|
eqIdx := strings.Index(tok, "=")
|
|
if eqIdx > 0 && eqIdx < len(tok)-1 {
|
|
name := tok[:eqIdx]
|
|
value := tok[eqIdx+1:]
|
|
s.vars[name] = value
|
|
os.Setenv(name, value)
|
|
varAssignIdx = i
|
|
}
|
|
}
|
|
}
|
|
if varAssignIdx == 0 && len(tokens) > 1 {
|
|
tokens = tokens[1:]
|
|
}
|
|
|
|
return tokens
|
|
}
|
|
|
|
func (s *Shell) executeExternal(cmdName string, args []string) error {
|
|
cmdPath := findExecutable(cmdName)
|
|
if cmdPath == "" {
|
|
return fmt.Errorf("%s: command not found", cmdName)
|
|
}
|
|
|
|
cmd := exec.Command(cmdPath, args...)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
return cmd.Run()
|
|
}
|
|
|
|
func findExecutable(name string) string {
|
|
if _, err := os.Stat(name); err == nil {
|
|
if info, _ := os.Stat(name); info != nil && !info.IsDir() {
|
|
abs, _ := filepath.Abs(name)
|
|
return abs
|
|
}
|
|
}
|
|
|
|
path := os.Getenv("PATH")
|
|
for _, dir := range filepath.SplitList(path) {
|
|
fullPath := filepath.Join(dir, name)
|
|
info, err := os.Stat(fullPath)
|
|
if err == nil && !info.IsDir() {
|
|
return fullPath
|
|
}
|
|
fullPathExe := fullPath + ".exe"
|
|
info, err = os.Stat(fullPathExe)
|
|
if err == nil && !info.IsDir() {
|
|
return fullPathExe
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Shell) doPipe(input string) error {
|
|
commands := strings.Split(input, "|")
|
|
|
|
type cmdPart struct {
|
|
name string
|
|
args []string
|
|
builtin bool
|
|
}
|
|
|
|
var parts []cmdPart
|
|
for _, part := range commands {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
tokens := s.tokenize(part)
|
|
if len(tokens) == 0 {
|
|
continue
|
|
}
|
|
name := tokens[0]
|
|
_, isBuiltin := s.builtins[name]
|
|
parts = append(parts, cmdPart{name, tokens[1:], isBuiltin})
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var prevOutput []byte
|
|
|
|
for i, p := range parts {
|
|
var input []byte
|
|
if i > 0 {
|
|
input = prevOutput
|
|
}
|
|
|
|
if p.builtin {
|
|
output, err := s.captureBuiltin(p.name, p.args, input)
|
|
if err != nil {
|
|
// Don't return error, let it pass
|
|
prevOutput = nil
|
|
if i == len(parts)-1 {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if i == len(parts)-1 {
|
|
fmt.Print(string(output))
|
|
} else {
|
|
prevOutput = output
|
|
}
|
|
} else {
|
|
cmdPath := findExecutable(p.name)
|
|
if cmdPath == "" {
|
|
return fmt.Errorf("%s: command not found", p.name)
|
|
}
|
|
cmd := exec.Command(cmdPath, p.args...)
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if i == 0 && len(input) == 0 {
|
|
cmd.Stdin = os.Stdin
|
|
} else if len(input) > 0 {
|
|
stdin, _ := cmd.StdinPipe()
|
|
go func() {
|
|
stdin.Write(input)
|
|
stdin.Close()
|
|
}()
|
|
}
|
|
|
|
if i == len(parts)-1 {
|
|
cmd.Stdout = os.Stdout
|
|
if err := cmd.Run(); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
prevOutput = output
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Shell) captureBuiltin(name string, args []string, input []byte) ([]byte, error) {
|
|
oldStdout := os.Stdout
|
|
oldStdin := os.Stdin
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
|
|
if len(input) > 0 {
|
|
ir, iw, _ := os.Pipe()
|
|
iw.Write(input)
|
|
iw.Close()
|
|
os.Stdin = ir
|
|
defer ir.Close()
|
|
}
|
|
|
|
fn := s.builtins[name]
|
|
err := fn(args)
|
|
|
|
w.Close()
|
|
os.Stdout = oldStdout
|
|
os.Stdin = oldStdin
|
|
|
|
var buf bytes.Buffer
|
|
io.Copy(&buf, r)
|
|
r.Close()
|
|
|
|
return buf.Bytes(), err
|
|
}
|