Files
seaweedfs/weed/shell/shell_liner.go
Chris Lu 7f3908297c fix(weed/shell): suppress prompt when piped (#8990)
* fix(weed/shell): suppress prompt when stdin or stdout is not a TTY

When piping weed shell output (e.g. `echo "s3.user.list" | weed shell | jq`),
the "> " prompt was written to stdout, breaking JSON parsers.

`liner.TerminalSupported()` only checks platform support, not whether
stdin/stdout are actual TTYs. Add explicit checks using `term.IsTerminal()`
so the shell falls back to the non-interactive scanner path when piped.

Fixes #8962

* fix(weed/shell): suppress informational logs unless -verbose is set

Suppress glog info messages and connection status logs on stderr by
default. Add -verbose flag to opt in to the previous noisy behavior.
This keeps piped output clean (e.g. `echo "s3.user.list" | weed shell | jq`).

* fix(weed/shell): defer liner init until after TTY check

Move liner.NewLiner() and related setup (history, completion, interrupt
handler) inside the interactive block so the terminal is not put into
raw mode when stdout is redirected. Previously, liner would set raw mode
unconditionally at startup, leaving the terminal broken when falling
back to the scanner path.

Addresses review feedback from gemini-code-assist.

* refactor(weed/shell): consolidate verbose logging into single block

Group all verbose stderr output within one conditional block instead of
scattering three separate if-verbose checks around the filer logic.

Addresses review feedback from gemini-code-assist.

* fix(weed/shell): clean up global liner state and suppress logtostderr

- Set line=nil after Close() to prevent stale state if RunShell is
  called again (e.g. in tests)
- Add nil check in OnInterrupt handler for non-interactive sessions
- Also set logtostderr=false when not verbose, in case it was enabled

Addresses review feedback from gemini-code-assist.

* refactor(weed/shell): make liner state local to eliminate data race

Replace the package-level `line` variable with a local variable in
RunShell, passing it explicitly to setCompletionHandler, loadHistory,
and saveHistory. This eliminates a data race between the OnInterrupt
goroutine and the defer that previously set the global to nil.

Addresses review feedback from gemini-code-assist.

* rename(weed/shell): rename -verbose flag to -debug

Avoid conflict with -verbose flags already used by individual shell
commands (e.g. ec.encode, volume.fix.replication, volume.check.disk).
2026-04-08 13:07:15 -07:00

273 lines
5.8 KiB
Go

package shell
import (
"bufio"
"context"
"fmt"
"io"
"math/rand/v2"
"os"
"path"
"slices"
"strings"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/grace"
"github.com/peterh/liner"
flag "github.com/seaweedfs/seaweedfs/weed/util/fla9"
"golang.org/x/term"
)
var historyPath = path.Join(os.TempDir(), "weed-shell")
func RunShell(options ShellOptions) {
slices.SortFunc(Commands, func(a, b command) int {
return strings.Compare(a.Name(), b.Name())
})
if !options.Debug {
flag.Set("alsologtostderr", "false")
flag.Set("logtostderr", "false")
}
interactive := liner.TerminalSupported() && term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
var line *liner.State
if interactive {
line = liner.NewLiner()
defer line.Close()
grace.OnInterrupt(func() {
line.Close()
})
line.SetCtrlCAborts(true)
line.SetTabCompletionStyle(liner.TabPrints)
setCompletionHandler(line)
loadHistory(line)
defer saveHistory(line)
}
commandEnv := NewCommandEnv(&options)
ctx := context.Background()
go commandEnv.MasterClient.KeepConnectedToMaster(ctx)
commandEnv.MasterClient.WaitUntilConnected(ctx)
if commandEnv.option.FilerAddress == "" {
var filers []pb.ServerAddress
commandEnv.MasterClient.WithClient(false, func(client master_pb.SeaweedClient) error {
resp, err := client.ListClusterNodes(context.Background(), &master_pb.ListClusterNodesRequest{
ClientType: cluster.FilerType,
FilerGroup: *options.FilerGroup,
})
if err != nil {
return err
}
for _, clusterNode := range resp.ClusterNodes {
filers = append(filers, pb.ServerAddress(clusterNode.Address))
}
return nil
})
if len(filers) > 0 {
commandEnv.option.FilerAddress = filers[rand.IntN(len(filers))]
}
if options.Debug {
if len(filers) > 0 {
fmt.Fprintf(os.Stderr, "master: %s filers: %v\n", *options.Masters, filers)
} else {
fmt.Fprintf(os.Stderr, "master: %s\n", *options.Masters)
}
}
}
if interactive {
for {
cmd, err := line.Prompt("> ")
if err != nil {
if err != io.EOF {
fmt.Fprintf(os.Stderr, "%v\n", err)
}
return
}
if strings.TrimSpace(cmd) != "" {
line.AppendHistory(cmd)
}
for _, c := range util.StringSplit(cmd, ";") {
if processEachCmd(c, commandEnv) {
return
}
}
}
} else {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
cmd := scanner.Text()
for _, c := range util.StringSplit(cmd, ";") {
if processEachCmd(c, commandEnv) {
return
}
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "error reading stdin: %v\n", err)
}
}
}
func processEachCmd(cmd string, commandEnv *CommandEnv) bool {
cmds := splitCommandLine(cmd)
if len(cmds) == 0 {
return false
} else {
args := cmds[1:]
cmd := cmds[0]
if cmd == "help" || cmd == "?" {
printHelp(cmds)
} else if cmd == "exit" || cmd == "quit" {
return true
} else {
foundCommand := false
for _, c := range Commands {
if c.Name() == cmd || c.Name() == "fs."+cmd {
if err := c.Do(args, commandEnv, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
}
foundCommand = true
}
}
if !foundCommand {
fmt.Fprintf(os.Stderr, "unknown command: %v\n", cmd)
}
}
}
return false
}
func splitCommandLine(line string) []string {
tokens, _ := parseShellInput(line, true)
return tokens
}
func parseShellInput(line string, split bool) (args []string, unbalanced bool) {
var current strings.Builder
inDoubleQuotes := false
inSingleQuotes := false
escaped := false
for i := 0; i < len(line); i++ {
c := line[i]
if escaped {
current.WriteByte(c)
escaped = false
continue
}
if c == '\\' && !inSingleQuotes {
escaped = true
continue
}
if c == '"' && !inSingleQuotes {
inDoubleQuotes = !inDoubleQuotes
continue
}
if c == '\'' && !inDoubleQuotes {
inSingleQuotes = !inSingleQuotes
continue
}
if split && (c == ' ' || c == '\t' || c == '\n' || c == '\r') && !inDoubleQuotes && !inSingleQuotes {
if current.Len() > 0 {
args = append(args, current.String())
current.Reset()
}
continue
}
current.WriteByte(c)
}
if current.Len() > 0 {
args = append(args, current.String())
}
return args, inDoubleQuotes || inSingleQuotes || escaped
}
func printGenericHelp() {
msg :=
`Type: "help <command>" for help on <command>. Most commands support "<command> -h" also for options.
`
fmt.Print(msg)
for _, c := range Commands {
if c.HasTag(Hidden) {
continue
}
helpTexts := strings.SplitN(c.Help(), "\n", 2)
fmt.Printf(" %-30s\t# %s \n", c.Name(), helpTexts[0])
}
}
func printHelp(cmds []string) {
args := cmds[1:]
if len(args) == 0 {
printGenericHelp()
} else if len(args) > 1 {
fmt.Println()
} else {
cmd := strings.ToLower(args[0])
for _, c := range Commands {
if strings.ToLower(c.Name()) == cmd {
fmt.Printf(" %s\t# %s\n", c.Name(), c.Help())
fmt.Printf("use \"%s -h\" for more details\n", c.Name())
}
}
}
}
func setCompletionHandler(line *liner.State) {
line.SetCompleter(func(line string) (c []string) {
for _, i := range Commands {
if strings.HasPrefix(i.Name(), strings.ToLower(line)) {
c = append(c, i.Name())
}
}
return
})
}
func loadHistory(line *liner.State) {
if f, err := os.Open(historyPath); err == nil {
line.ReadHistory(f)
f.Close()
}
}
func saveHistory(line *liner.State) {
if f, err := os.Create(historyPath); err != nil {
fmt.Fprintf(os.Stderr, "Error creating history file: %v\n", err)
} else {
if _, err = line.WriteHistory(f); err != nil {
fmt.Fprintf(os.Stderr, "Error writing history file: %v\n", err)
}
f.Close()
}
}