diff --git a/README.md b/README.md index c4051d9..904b9d0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ A universal command-line tool for managing iOS and Android devices, simulators, - **Device Control**: Reboot devices, tap screen coordinates, press hardware buttons - **App Management**: Launch, terminate, install, uninstall, list, and get foreground apps - **Crash Reports**: List and fetch crash reports from iOS and Android devices +- **Device Logs**: Stream real-time device logs with filtering from iOS and Android devices ### 🎯 Platform Support @@ -281,6 +282,33 @@ Example output for `crashes list`: **Note**: On iOS real devices, crash reports are fetched via the Apple crashreport service. On iOS simulators, they are read from `~/Library/Logs/DiagnosticReports/`. On Android, crashes are parsed from `adb logcat -b crash`. +### Device Logs 📋 + +```bash +# Stream logs from a device (Ctrl+C to stop) +mobilecli device logs --device + +# Stop after 100 entries +mobilecli device logs --device --limit 100 + +# Filter by field (exact match) +mobilecli device logs --filter process=SpringBoard +mobilecli device logs --filter tag=ActivityManager + +# Exclude by field +mobilecli device logs --filter process!=SpringBoard + +# Combine filters (AND logic) +mobilecli device logs --filter level=Error --filter process!=SpringBoard +``` + +Supported filter keys: `pid`, `process`, `tag`, `level`, `subsystem`, `category`, `message` + +Each log entry is printed as a JSON line: +```json +{"timestamp":"2026-04-15 12:17:14.224451+0300","message":"Start proc...","level":"Default","subsystem":"com.apple.UIKit","category":"EventDispatch","pid":54052,"process":"SpringBoard"} +``` + ## HTTP API 🔌 ***mobilecli*** provides an http interface for all the functionality that is available through command line. As a matter of fact, it is preferable to diff --git a/cli/logs.go b/cli/logs.go new file mode 100644 index 0000000..386d008 --- /dev/null +++ b/cli/logs.go @@ -0,0 +1,57 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/mobile-next/mobilecli/commands" + "github.com/spf13/cobra" +) + +var logsLimit int +var logsFilters []string + +var deviceLogsCmd = &cobra.Command{ + Use: "logs", + Short: "Stream device logs", + Long: `Streams real-time logs from a device. Press Ctrl+C to stop. + +Filters use key=value (include) or key!=value (exclude) syntax. +Multiple --filter flags are ANDed together. + +Supported keys: pid, process, tag, level, subsystem, category, message + +Examples: + mobilecli device logs --filter tag=ActivityManager + mobilecli device logs --filter process!=SpringBoard + mobilecli device logs --filter level=Error --filter process=backboardd`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + filters, err := commands.ParseLogFilters(logsFilters) + if err != nil { + return err + } + response := commands.LogsCommand(ctx, commands.LogsRequest{ + DeviceID: deviceId, + Limit: logsLimit, + Filters: filters, + }) + if response.Status == "error" { + printJson(response) + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +func init() { + deviceCmd.AddCommand(deviceLogsCmd) + deviceLogsCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to stream logs from") + deviceLogsCmd.Flags().IntVar(&logsLimit, "limit", 0, "Stop after N log entries (0 = unlimited)") + deviceLogsCmd.Flags().StringArrayVar(&logsFilters, "filter", nil, "Filter logs (key=value or key!=value, repeatable)") +} diff --git a/cli/root.go b/cli/root.go index 651e88d..da4ad14 100644 --- a/cli/root.go +++ b/cli/root.go @@ -111,6 +111,19 @@ AGENT: # Install on a real iOS device (requires provisioning profile) mobilecli agent install --device --provisioning-profile /path/to/profile.mobileprovision +DEVICE LOGS: + # Stream logs from a device (Ctrl+C to stop) + mobilecli device logs --device + + # Stop after N entries + mobilecli device logs --device --limit 100 + + # Filter (key=value to include, key!=value to exclude; --filter is repeatable) + mobilecli device logs --device --filter process=SpringBoard + mobilecli device logs --device --filter level=Error --filter process!=SpringBoard + + # Filter keys: pid, process, tag, level, subsystem, category, message + UTILITIES: # Open a URL or deep link mobilecli url --device https://example.com diff --git a/commands/logs.go b/commands/logs.go new file mode 100644 index 0000000..2938be1 --- /dev/null +++ b/commands/logs.go @@ -0,0 +1,130 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/mobile-next/mobilecli/devices" +) + +type LogFilter struct { + Key string + Value string + Negate bool +} + +type LogsRequest struct { + DeviceID string + Limit int + Filters []LogFilter +} + +// ParseLogFilters parses filter strings like "key=value" or "key!=value" +func ParseLogFilters(raw []string) ([]LogFilter, error) { + var filters []LogFilter + for _, s := range raw { + f, err := parseOneFilter(s) + if err != nil { + return nil, err + } + filters = append(filters, f) + } + return filters, nil +} + +var validFilterKeys = map[string]bool{ + "pid": true, "process": true, "tag": true, + "level": true, "subsystem": true, "category": true, + "message": true, +} + +func parseOneFilter(s string) (LogFilter, error) { + // try != first (before =) + if idx := strings.Index(s, "!="); idx > 0 { + key := s[:idx] + if !validFilterKeys[key] { + return LogFilter{}, fmt.Errorf("unknown filter key %q (valid: pid, process, tag, level, subsystem, category, message)", key) + } + return LogFilter{Key: key, Value: s[idx+2:], Negate: true}, nil + } + if idx := strings.Index(s, "="); idx > 0 { + key := s[:idx] + if !validFilterKeys[key] { + return LogFilter{}, fmt.Errorf("unknown filter key %q (valid: pid, process, tag, level, subsystem, category, message)", key) + } + return LogFilter{Key: key, Value: s[idx+1:]}, nil + } + return LogFilter{}, fmt.Errorf("invalid filter %q (expected key=value or key!=value)", s) +} + +func getFieldValue(entry devices.LogEntry, key string) string { + switch key { + case "pid": + return strconv.Itoa(entry.PID) + case "process": + return entry.Process + case "tag": + return entry.Tag + case "level": + return entry.Level + case "subsystem": + return entry.Subsystem + case "category": + return entry.Category + case "message": + return entry.Message + default: + return "" + } +} + +func matchesFilters(entry devices.LogEntry, filters []LogFilter) bool { + for _, f := range filters { + fieldValue := getFieldValue(entry, f.Key) + match := fieldValue == f.Value + if f.Negate { + match = !match + } + if !match { + return false + } + } + return true +} + +func LogsCommand(ctx context.Context, req LogsRequest) *CommandResponse { + device, err := FindDeviceOrAutoSelect(req.DeviceID) + if err != nil { + return NewErrorResponse(fmt.Errorf("error finding device: %w", err)) + } + + encoder := json.NewEncoder(os.Stdout) + count := 0 + + emit := func(entry devices.LogEntry) bool { + if err := encoder.Encode(entry); err != nil { + return false + } + count++ + if req.Limit > 0 && count >= req.Limit { + return false + } + return true + } + + err = device.StreamLogs(ctx, func(entry devices.LogEntry) bool { + if !matchesFilters(entry, req.Filters) { + return true + } + return emit(entry) + }) + if err != nil { + return NewErrorResponse(fmt.Errorf("error streaming logs: %w", err)) + } + + return NewSuccessResponse("done") +} diff --git a/devices/android.go b/devices/android.go index a10a5aa..e630060 100644 --- a/devices/android.go +++ b/devices/android.go @@ -1,6 +1,8 @@ package devices import ( + "bufio" + "bytes" "context" "encoding/base64" "encoding/xml" @@ -13,6 +15,7 @@ import ( "runtime" "strconv" "strings" + "sync" "syscall" "time" @@ -1370,3 +1373,100 @@ func (d *AndroidDevice) GetCrashReport(id string) ([]byte, error) { } return []byte(content), nil } + +type androidPidCache struct { + mu sync.Mutex + names map[int]string +} + +func (c *androidPidCache) resolveProcessNameByPid(d *AndroidDevice, pid int) string { + c.mu.Lock() + name, ok := c.names[pid] + c.mu.Unlock() + if ok { + return name + } + + fmt.Fprintf(os.Stderr, "resolving pid %d\n", pid) + out, err := d.runAdbCommand("shell", "cat", fmt.Sprintf("/proc/%d/cmdline", pid)) + if err != nil || len(out) == 0 { + return "" + } + // cmdline is null-delimited; first entry is the executable path + first, _, _ := bytes.Cut(out, []byte{0}) + name = processNameFromPath(string(first)) + + c.mu.Lock() + c.names[pid] = name + c.mu.Unlock() + return name +} + +var logcatLevelMap = map[string]string{ + "V": "Verbose", + "D": "Debug", + "I": "Info", + "W": "Warning", + "E": "Error", + "F": "Fatal", + "A": "Assert", +} + +func (d *AndroidDevice) StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error { + pidCache := &androidPidCache{names: make(map[int]string)} + + args := []string{"logcat", "-v", "threadtime,year", "-T", "1"} + cmdArgs := append([]string{"-s", d.getAdbIdentifier()}, args...) + cmd := exec.CommandContext(ctx, getAdbPath(), cmdArgs...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start logcat: %w", err) + } + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + stoppedByCaller := false + for scanner.Scan() { + line := scanner.Text() + + parsed := parseLogcatLine(line) + if parsed == nil { + continue + } + + pid, _ := strconv.Atoi(parsed.PID) + level := logcatLevelMap[parsed.Level] + if level == "" { + level = parsed.Level + } + + entry := LogEntry{ + Timestamp: parsed.Date + " " + parsed.Time, + PID: pid, + Level: level, + Tag: parsed.Tag, + Message: parsed.Message, + Process: pidCache.resolveProcessNameByPid(d, pid), + } + + if !onLog(entry) { + _ = cmd.Process.Kill() + stoppedByCaller = true + break + } + } + + _ = cmd.Wait() + if stoppedByCaller || ctx.Err() != nil { + return nil + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("logcat read error: %w", err) + } + return nil +} diff --git a/devices/common.go b/devices/common.go index 494705d..ad5d85b 100644 --- a/devices/common.go +++ b/devices/common.go @@ -1,10 +1,12 @@ package devices import ( + "context" "encoding/json" "fmt" "os" "regexp" + "strings" "time" "github.com/mobile-next/mobilecli/devices/wda" @@ -12,6 +14,26 @@ import ( "github.com/mobile-next/mobilecli/utils" ) +// LogEntry represents a single parsed log entry from a device +type LogEntry struct { + Timestamp string `json:"timestamp"` + Message string `json:"message"` + Level string `json:"level"` + Subsystem string `json:"subsystem,omitempty"` + Category string `json:"category,omitempty"` + PID int `json:"pid"` + Process string `json:"process,omitempty"` + Tag string `json:"tag,omitempty"` +} + +// processNameFromPath extracts the binary name from a full image path +func processNameFromPath(path string) string { + if idx := strings.LastIndex(path, "/"); idx != -1 { + return path[idx+1:] + } + return path +} + type CrashReport struct { ProcessName string `json:"processName"` Timestamp string `json:"timestamp"` @@ -128,6 +150,7 @@ type ControllableDevice interface { SetOrientation(orientation string) error ListCrashReports() ([]CrashReport, error) GetCrashReport(id string) ([]byte, error) + StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error } // GetAllControllableDevices aggregates all known devices with options diff --git a/devices/ios.go b/devices/ios.go index d6deda1..b9c0ff1 100644 --- a/devices/ios.go +++ b/devices/ios.go @@ -19,6 +19,7 @@ import ( "github.com/danielpaulus/go-ios/ios/crashreport" "github.com/danielpaulus/go-ios/ios/diagnostics" "github.com/danielpaulus/go-ios/ios/installationproxy" + "github.com/danielpaulus/go-ios/ios/ostrace" "github.com/danielpaulus/go-ios/ios/instruments" "github.com/danielpaulus/go-ios/ios/testmanagerd" "github.com/danielpaulus/go-ios/ios/tunnel" @@ -1627,3 +1628,84 @@ func (d *IOSDevice) GetCrashReport(id string) ([]byte, error) { return content, nil } + +func (d *IOSDevice) StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error { + // ensure tunnel is running for iOS 17+ + err := d.startTunnel() + if err != nil { + return fmt.Errorf("failed to start tunnel: %w", err) + } + + device, err := d.getEnhancedDevice() + if err != nil { + return fmt.Errorf("failed to get device: %w", err) + } + + spec := ostrace.DefaultLevelFilter() + conn, err := ostrace.New(device, -1, spec.MessageFilter, spec.StreamFlags) + if err != nil { + return fmt.Errorf("failed to connect to os_trace_relay: %w", err) + } + + type readResult struct { + entry LogEntry + err error + } + + ch := make(chan readResult, 1) + done := make(chan struct{}) + var closeOnce sync.Once + stopStreaming := func() { + closeOnce.Do(func() { + close(done) + _ = conn.Close() + }) + } + defer stopStreaming() + + go func() { + for { + raw, err := conn.ReadEntry() + result := readResult{err: err} + if err == nil { + result.entry = LogEntry{ + Timestamp: raw.Timestamp.Format("2006-01-02 15:04:05.000000-0700"), + Message: raw.Message, + Level: raw.LevelName, + PID: int(raw.PID), + Process: processNameFromPath(raw.Filename), + } + if raw.Label != nil { + result.entry.Subsystem = raw.Label.Subsystem + result.entry.Category = raw.Label.Category + } + } + select { + case <-done: + return + case ch <- result: + if err != nil { + return + } + } + } + }() + + for { + select { + case <-ctx.Done(): + return nil + case r := <-ch: + if r.err != nil { + if errors.Is(r.err, io.EOF) { + return nil + } + return fmt.Errorf("os_trace read error: %w", r.err) + } + if !onLog(r.entry) { + return nil + } + } + } +} + diff --git a/devices/remote.go b/devices/remote.go index fcd2956..349508a 100644 --- a/devices/remote.go +++ b/devices/remote.go @@ -1,6 +1,7 @@ package devices import ( + "context" "encoding/base64" "fmt" "io" @@ -500,3 +501,7 @@ func (r *RemoteDevice) GetCrashReport(id string) ([]byte, error) { } return []byte(result.Content), nil } + +func (r *RemoteDevice) StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error { + return fmt.Errorf("device logs not yet supported for remote devices") +} diff --git a/devices/simulator.go b/devices/simulator.go index 74a2b5e..f77a64b 100644 --- a/devices/simulator.go +++ b/devices/simulator.go @@ -1,7 +1,11 @@ package devices import ( + "context" + "encoding/json" + "errors" "fmt" + "io" "os" "os/exec" "os/signal" @@ -888,3 +892,83 @@ func (s SimulatorDevice) GetCrashReport(id string) ([]byte, error) { return os.ReadFile(filepath.Join(diagnosticReportsDir, id)) } + +// simctlLogEntry is the raw structure from xcrun simctl log stream --style json +type simctlLogEntry struct { + Timestamp string `json:"timestamp"` + EventMessage string `json:"eventMessage"` + MessageType string `json:"messageType"` + Subsystem string `json:"subsystem"` + Category string `json:"category"` + ProcessImagePath string `json:"processImagePath"` + ProcessID int `json:"processID"` +} + +func (s *SimulatorDevice) StreamLogs(ctx context.Context, onLog func(LogEntry) bool) error { + args := []string{"simctl", "spawn", s.UDID, "log", "stream", "--level", "info", "--style", "json"} + utils.Verbose("Running: xcrun %s", strings.Join(args, " ")) + + cmd := exec.CommandContext(ctx, "xcrun", args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start log stream: %w", err) + } + + decoder := json.NewDecoder(stdout) + + // read opening '[' of the JSON array + token, err := decoder.Token() + if err != nil { + _ = cmd.Process.Kill() + waitErr := cmd.Wait() + return fmt.Errorf("failed to read opening token: %w (process exit: %v)", err, waitErr) + } + if delim, ok := token.(json.Delim); !ok || delim != '[' { + _ = cmd.Process.Kill() + waitErr := cmd.Wait() + return fmt.Errorf("expected '[', got %v (process exit: %v)", token, waitErr) + } + + // decode entries one at a time until the stream ends + stoppedByCaller := false + for decoder.More() { + var raw simctlLogEntry + if err := decoder.Decode(&raw); err != nil { + if errors.Is(err, io.EOF) { + break + } + _ = cmd.Process.Kill() + waitErr := cmd.Wait() + return fmt.Errorf("failed to decode log entry: %w (process exit: %v)", err, waitErr) + } + + processName := processNameFromPath(raw.ProcessImagePath) + + if !onLog(LogEntry{ + Timestamp: raw.Timestamp, + Message: raw.EventMessage, + Level: raw.MessageType, + Subsystem: raw.Subsystem, + Category: raw.Category, + PID: raw.ProcessID, + Process: processName, + }) { + _ = cmd.Process.Kill() + stoppedByCaller = true + break + } + } + + waitErr := cmd.Wait() + if stoppedByCaller || ctx.Err() != nil { + return nil + } + if waitErr != nil { + return fmt.Errorf("log stream ended with error: %w", waitErr) + } + return nil +} diff --git a/go.mod b/go.mod index 2090f2e..9cdf4bb 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/mobile-next/mobilecli go 1.25.7 require ( - github.com/danielpaulus/go-ios v1.0.182 + github.com/danielpaulus/go-ios v1.0.211 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/golang-lru/v2 v2.0.7 @@ -36,11 +36,14 @@ require ( github.com/miekg/dns v1.1.57 // indirect github.com/onsi/ginkgo/v2 v2.9.5 // indirect github.com/pierrec/lz4 v2.6.1+incompatible // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/quic-go v0.40.1-0.20231203135336-87ef8ec48d55 // indirect github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect + github.com/vishvananda/netlink v1.3.1 // indirect + github.com/vishvananda/netns v0.0.5 // indirect go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/crypto v0.45.0 // indirect diff --git a/go.sum b/go.sum index e5d19ce..ff3a28c 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= -github.com/danielpaulus/go-ios v1.0.182 h1:SnJKO3rzQClRviPwMqFf2uWX6AFxBDbUCnl88ZsfJlw= -github.com/danielpaulus/go-ios v1.0.182/go.mod h1:ZkUcaC59yNba47j/+ULKsCi3dYPFwY9r39PxdmVmLHE= +github.com/danielpaulus/go-ios v1.0.211 h1:REv11Hc+kt3LEAEwYkps0r7KUew0kYWs1rgw2UJeEug= +github.com/danielpaulus/go-ios v1.0.211/go.mod h1:f5q5S4XJT53AA8cdgp3rLA41YaIpyaDg+w8aURzLNhM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -90,6 +90,10 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE= +github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w= github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= @@ -126,6 +130,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=