diff --git a/README.md b/README.md index bfdf2d1..7fbc51a 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,32 @@ Features: disabled with $NO_COLOR, TERM=dumb or when output is not a terminal - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); using an unset variable is an error, use ${VAR:-} for an empty default + - Non-interactive use: gosh -c 'command', gosh script, command | gosh; + the exit status is the one of the last command ``` +### Aufruf + +``` +gosh [-l] [-i] [script] +gosh [-l] -c command +gosh -version +``` + +| Aufruf | Verhalten | +|------------------------|------------------------------------------------------------------| +| `gosh` | interaktive Shell (wenn stdin ein Terminal ist) | +| `gosh -c 'befehl'` | führt den Befehl aus und beendet sich | +| `gosh skript.gosh` | führt das Skript Zeile für Zeile aus | +| `befehle \| gosh` | liest die Befehle von stdin | +| `-i` | erzwingt den interaktiven Modus, auch ohne Terminal | +| `-l`, `--login` | Login-Shell (wird akzeptiert, eine Startdatei gibt es noch nicht) | + +Ohne Terminal bricht gosh bei einem fehlgeschlagenen Befehl nicht ab und beendet sich mit dem Exit-Status des letzten Befehls: `127`, wenn ein Befehl nicht gefunden wurde, `126`, wenn er nicht ausführbar ist, `128+n`, wenn er durch Signal `n` beendet wurde. Leere Zeilen und Zeilen, die mit `#` beginnen, werden übersprungen, Skripte können also mit `#!/usr/bin/gosh` beginnen. + +Damit funktionieren auch `ssh host befehl`, `scp` und `rsync`, wenn gosh als Login-Shell gesetzt ist. Da gosh `;`, `&&` und Pipes noch nicht kennt, darf der Befehl dabei aber nur aus einem einfachen Kommando bestehen. + ### Farben Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`: diff --git a/main.go b/main.go index 4054309..01933ef 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,11 @@ package main import ( + "bufio" "errors" "fmt" "io" + "io/fs" "os" "os/exec" "os/signal" @@ -11,6 +13,7 @@ import ( "path/filepath" "runtime/debug" "strings" + "syscall" "unicode" "unicode/utf8" @@ -611,15 +614,151 @@ func completeFilePath(line, prefix string) []string { return matches } +const usage = `usage: gosh [-l] [-i] [script] + gosh [-l] -c command + gosh -version` + func main() { - if len(os.Args) > 1 { - switch os.Args[1] { - case "-version", "--version", "-V": + os.Exit(run(os.Args[1:])) +} + +// run parses the command line and runs gosh in the matching mode. It returns +// the exit status of the shell. +func run(args []string) int { + // -l/--login is accepted for compatibility (e.g. "su -l", login(1)), but + // gosh does not read a startup file yet. -i forces interactive mode even + // if stdin is not a terminal. + forceInteractive := false +flags: + for len(args) > 0 { + switch args[0] { + case "-l", "--login": + case "-i": + forceInteractive = true + default: + break flags + } + args = args[1:] + } + + if len(args) > 0 { + switch arg := args[0]; { + case arg == "-version" || arg == "--version" || arg == "-V": fmt.Println("gosh", versionInfo()) - return + return 0 + case arg == "-h" || arg == "--help": + fmt.Println(usage) + return 0 + case arg == "-c": + // Like sh, further arguments are allowed (they would become $0, + // $1, ...), but gosh has no positional parameters yet. + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "gosh: -c: option requires an argument") + fmt.Fprintln(os.Stderr, usage) + return 2 + } + return runScript(strings.NewReader(args[1])) + case strings.HasPrefix(arg, "-") && arg != "-": + fmt.Fprintf(os.Stderr, "gosh: %s: invalid option\n", arg) + fmt.Fprintln(os.Stderr, usage) + return 2 + case arg == "-": + return runScript(os.Stdin) + default: + f, err := os.Open(arg) + if err != nil { + fmt.Fprintln(os.Stderr, "gosh:", err) + return 127 + } + defer f.Close() + return runScript(bufio.NewReader(f)) } } + // Without a terminal (e.g. "echo ls | gosh") read the commands from stdin. + if !forceInteractive && !readline.IsTerminal(int(os.Stdin.Fd())) { + return runScript(os.Stdin) + } + + interactive() + return 0 +} + +// runScript executes the commands from r line by line. Like sh, it does not +// stop at a failing command and returns the exit status of the last one. +// Empty lines and lines starting with # (e.g. a #!/usr/bin/gosh line) are +// skipped. +func runScript(r io.Reader) int { + status := 0 + for { + line, err := readLine(r) + if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") { + cmdErr := execInput(line) + status = exitStatus(cmdErr) + // A command that ran and failed reports its own errors. + var exitErr *exec.ExitError + if cmdErr != nil && !errors.As(cmdErr, &exitErr) { + fmt.Fprintln(os.Stderr, "gosh:", cmdErr) + } + } + if err != nil { + if err != io.EOF { + fmt.Fprintln(os.Stderr, "gosh:", err) + return 1 + } + return status + } + } +} + +// readLine reads a single line from r without the trailing newline. It reads +// byte by byte so that nothing after the line is consumed: when the script +// comes from stdin, the commands it starts read the rest of stdin (like in +// sh, "printf 'cat\nhello\n' | gosh" prints hello). Pass a bufio.Reader if +// that does not matter. +func readLine(r io.Reader) (string, error) { + var b strings.Builder + buf := make([]byte, 1) + for { + n, err := r.Read(buf) + if n > 0 { + if buf[0] == '\n' { + return b.String(), nil + } + b.WriteByte(buf[0]) + } + if err != nil { + return b.String(), err + } + } +} + +// exitStatus converts the result of execInput into a shell exit status: the +// exit code of the command, 128+n if it was killed by signal n, 127 if it was +// not found, 126 if it could not be executed and 1 for other errors. +func exitStatus(err error) int { + if err == nil { + return 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { + return 128 + int(ws.Signal()) + } + return exitErr.ExitCode() + } + if errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist) { + return 127 + } + if errors.Is(err, fs.ErrPermission) { + return 126 + } + return 1 +} + +// interactive runs the interactive shell with line editing, history and +// completion until the user exits. +func interactive() { // Ctrl+C sends SIGINT to the whole foreground process group. Catch it so // that it only stops the running command and not the shell. (signal.Ignore // would be inherited by the child processes.) @@ -800,7 +939,9 @@ Features: (keys user, root, path; values are ANSI SGR codes, empty = no color); disabled with $NO_COLOR, TERM=dumb or when output is not a terminal - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); - using an unset variable is an error, use ${VAR:-} for an empty default`) + using an unset variable is an error, use ${VAR:-} for an empty default + - Non-interactive use: gosh -c 'command', gosh script, command | gosh; + the exit status is the one of the last command`) return nil } diff --git a/main_test.go b/main_test.go index d8a5ef9..ff11bae 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "bytes" "io" "os" @@ -1173,7 +1174,7 @@ func TestShell_History(t *testing.T) { histFile := filepath.Join(t.TempDir(), "gosh", "history") workDir := t.TempDir() - cmd := exec.Command(bin) + cmd := exec.Command(bin, "-i") cmd.Dir = workDir cmd.Env = append(os.Environ(), "GOSH_HISTFILE="+histFile) cmd.Stdin = strings.NewReader("echo first\n set SECRET=hunter2\nexit\n") @@ -1205,7 +1206,7 @@ func TestShell_History(t *testing.T) { func TestShell_SIGINTKillsOnlyChild(t *testing.T) { bin := buildShell(t) - cmd := exec.Command(bin) + cmd := exec.Command(bin, "-i") // Own process group, so that we can signal it like the terminal does cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} stdin, err := cmd.StdinPipe() @@ -1267,3 +1268,83 @@ func TestExecInput_PassesStdin(t *testing.T) { t.Errorf("command read %q from stdin, want %q", got, "hello") } } + +func TestExitStatus(t *testing.T) { + tests := []struct { + input string + want int + }{ + {"true", 0}, + {"false", 1}, + {`sh -c "exit 42"`, 42}, + {`sh -c "kill -TERM \$\$"`, 128 + 15}, + {"gosh-no-such-command", 127}, + {"cd /gosh-no-such-dir", 127}, + } + for _, tc := range tests { + if got := exitStatus(execInput(tc.input)); got != tc.want { + t.Errorf("exitStatus(execInput(%q)) = %d, want %d", tc.input, got, tc.want) + } + } + if got := exitStatus(errors.New("other")); got != 1 { + t.Errorf("exitStatus(other error) = %d, want 1", got) + } +} + +func TestRunScript(t *testing.T) { + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + + dir := t.TempDir() + script := "#!/usr/bin/gosh\n" + + "# comment\n" + + "\n" + + "cd " + dir + "\n" + + " # indented comment\n" + + "touch first\n" + + "false\n" + + "touch second" // no trailing newline + if got := runScript(strings.NewReader(script)); got != 0 { + t.Errorf("runScript() = %d, want 0", got) + } + for _, name := range []string{"first", "second"} { + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Errorf("expected %s to be created: %v", name, err) + } + } + + // The status of the last command is returned + if got := runScript(strings.NewReader("true\nsh -c 'exit 3'\n")); got != 3 { + t.Errorf("runScript() = %d, want 3", got) + } +} + +func TestRun_Options(t *testing.T) { + tests := []struct { + args []string + want int + }{ + {[]string{"-c", "true"}, 0}, + {[]string{"-c", "false"}, 1}, + {[]string{"-l", "-c", "sh -c 'exit 5'"}, 5}, + {[]string{"-c"}, 2}, + {[]string{"-x"}, 2}, + {[]string{"/gosh/no/such/script"}, 127}, + } + for _, tc := range tests { + if got := run(tc.args); got != tc.want { + t.Errorf("run(%q) = %d, want %d", tc.args, got, tc.want) + } + } +} + +func TestReadLine_DoesNotReadAhead(t *testing.T) { + r := strings.NewReader("first\nsecond\n") + line, err := readLine(r) + if line != "first" || err != nil { + t.Fatalf("readLine() = %q, %v", line, err) + } + if r.Len() != len("second\n") { + t.Errorf("readLine() consumed more than one line, %d bytes left", r.Len()) + } +}