From 5b4121bf80911cc2c091eaca49ac8abfc1d89df7 Mon Sep 17 00:00:00 2001 From: Daniel Jun Suguimoto Date: Sat, 11 Jul 2026 11:40:12 -0300 Subject: [PATCH 1/2] feat: add agentic json output mode --- commands/info.go | 68 ++++++++++++++++++++++ commands/info_test.go | 72 +++++++++++++++++++++++ commands/kool_task.go | 2 +- commands/logs.go | 86 +++++++++++++++++++++++++++ commands/logs_test.go | 91 +++++++++++++++++++++++++++++ commands/root.go | 5 ++ commands/root_test.go | 20 +++++++ commands/run.go | 28 ++++++++- commands/run_test.go | 122 +++++++++++++++++++++++++++++++++++++++ commands/status.go | 51 ++++++++++++++-- commands/status_test.go | 114 ++++++++++++++++++++++++++++++++++++ core/shell/fake_shell.go | 40 ++++++++----- core/shell/shell.go | 43 ++++++++++++-- core/shell/shell_test.go | 58 +++++++++++++++++-- main.go | 7 ++- 15 files changed, 773 insertions(+), 34 deletions(-) diff --git a/commands/info.go b/commands/info.go index 744e333e..1abbaebf 100644 --- a/commands/info.go +++ b/commands/info.go @@ -1,6 +1,7 @@ package commands import ( + "encoding/json" "fmt" "kool-dev/kool/core/builder" "kool-dev/kool/core/environment" @@ -19,6 +20,15 @@ type KoolInfo struct { cmdDocker, cmdDockerCompose builder.Command } +type infoOutputJSON struct { + KoolVersion string `json:"kool_version"` + KoolBinPath string `json:"kool_bin_path"` + DockerVersion string `json:"docker_version"` + DockerBinPath string `json:"docker_bin_path"` + DockerComposeVersion string `json:"docker_compose_version"` + Env map[string]string `json:"env"` +} + // NewInfoCmd initializes new kool info command func NewInfoCmd(info *KoolInfo) *cobra.Command { return &cobra.Command{ @@ -57,6 +67,10 @@ func (i *KoolInfo) Execute(args []string) (err error) { filter = args[0] } + if i.Shell().IsJSONOutput() { + return i.executeJSON(filter) + } + // kool CLI info i.Shell().Println("Kool Version ", version) if output, err = os.Executable(); err != nil { @@ -110,3 +124,57 @@ func (i *KoolInfo) Execute(args []string) (err error) { return } + +func (i *KoolInfo) executeJSON(filter string) (err error) { + var ( + output string + info infoOutputJSON + ) + + info.KoolVersion = version + + if output, err = os.Executable(); err != nil { + return + } + info.KoolBinPath = output + + if output, err = i.Shell().Exec(i.cmdDocker); err != nil { + return + } + info.DockerVersion = output + + if err = i.shell.LookPath(i.cmdDocker); err != nil { + return + } + info.DockerBinPath, _ = exec.LookPath(i.cmdDocker.Cmd()) + + if output, err = i.Shell().Exec(i.cmdDockerCompose); err != nil { + i.Shell().Warning("Docker Compose:", err.Error()) + i.Shell().Error(fmt.Errorf("you need to have Docker Compose V2 available; make sure to update your Docker installation")) + return + } + info.DockerComposeVersion = output + + info.Env = map[string]string{} + for _, envVar := range i.envStorage.All() { + if !strings.Contains(envVar, filter) { + continue + } + parts := strings.SplitN(envVar, "=", 2) + key, value := parts[0], "" + if len(parts) > 1 { + value = parts[1] + } + if key == "KOOL_API_TOKEN" { + value = "***************** [redacted]" + } + info.Env[key] = value + } + + var payload []byte + if payload, err = json.Marshal(info); err != nil { + return + } + i.Shell().Println(string(payload)) + return +} diff --git a/commands/info_test.go b/commands/info_test.go index 647cf31b..31605a86 100644 --- a/commands/info_test.go +++ b/commands/info_test.go @@ -1,6 +1,7 @@ package commands import ( + "encoding/json" "kool-dev/kool/core/builder" "kool-dev/kool/core/environment" "kool-dev/kool/core/shell" @@ -71,3 +72,74 @@ func execInfoCommand(cmd *cobra.Command, f *KoolInfo) (output string, err error) output = strings.Join(f.shell.(*shell.FakeShell).OutLines, "\n") return } + +func TestInfoJSONOutput(t *testing.T) { + f := fakeKoolInfo() + f.shell.(*shell.FakeShell).MockIsJSONOutput = true + f.cmdDocker.(*builder.FakeCommand).MockExecOut = "Docker version 29.0.0" + f.cmdDocker.(*builder.FakeCommand).MockCmd = "docker" + f.cmdDockerCompose.(*builder.FakeCommand).MockExecOut = "Docker Compose version v2.30.0" + f.cmdDockerCompose.(*builder.FakeCommand).MockCmd = "docker" + + setupInfoTest(f) + f.envStorage.Set("KOOL_OUTPUT", "json") + + cmd := NewInfoCmd(f) + + output, err := execInfoCommand(cmd, f) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var info infoOutputJSON + if err := json.Unmarshal([]byte(output), &info); err != nil { + t.Fatalf("failed to parse json output: %v\nraw: %s", err, output) + } + + if info.KoolVersion == "" { + t.Error("expected non-empty kool_version") + } + if info.DockerVersion != "Docker version 29.0.0" { + t.Errorf("expected docker_version 'Docker version 29.0.0', got '%s'", info.DockerVersion) + } + if info.DockerComposeVersion != "Docker Compose version v2.30.0" { + t.Errorf("expected docker_compose_version, got '%s'", info.DockerComposeVersion) + } + if info.Env["KOOL_TESTING"] != "1" { + t.Errorf("expected env KOOL_TESTING=1, got '%s'", info.Env["KOOL_TESTING"]) + } + if info.Env["KOOL_OUTPUT"] != "json" { + t.Errorf("expected env KOOL_OUTPUT=json, got '%s'", info.Env["KOOL_OUTPUT"]) + } +} + +func TestInfoJSONOutputRedactsAPIToken(t *testing.T) { + f := fakeKoolInfo() + f.shell.(*shell.FakeShell).MockIsJSONOutput = true + f.cmdDocker.(*builder.FakeCommand).MockExecOut = "Docker version 29.0.0" + f.cmdDocker.(*builder.FakeCommand).MockCmd = "docker" + f.cmdDockerCompose.(*builder.FakeCommand).MockExecOut = "Docker Compose version v2.30.0" + f.cmdDockerCompose.(*builder.FakeCommand).MockCmd = "docker" + + f.envStorage.Set("KOOL_API_TOKEN", "super-secret-token") + + cmd := NewInfoCmd(f) + + output, err := execInfoCommand(cmd, f) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.Contains(output, "super-secret-token") { + t.Error("KOOL_API_TOKEN value should be redacted in JSON output") + } + + var info infoOutputJSON + if err := json.Unmarshal([]byte(output), &info); err != nil { + t.Fatalf("failed to parse json output: %v", err) + } + + if info.Env["KOOL_API_TOKEN"] == "super-secret-token" { + t.Error("KOOL_API_TOKEN should be redacted") + } +} diff --git a/commands/kool_task.go b/commands/kool_task.go index accf0bfc..1208aa58 100644 --- a/commands/kool_task.go +++ b/commands/kool_task.go @@ -40,7 +40,7 @@ func NewKoolTask(message string, service KoolService) *DefaultKoolTask { // Run runs task func (t *DefaultKoolTask) Run(args []string) (err error) { - if !t.Shell().IsTerminal() { + if !t.Shell().IsTerminal() || t.Shell().IsJSONOutput() { return t.Execute(args) } diff --git a/commands/logs.go b/commands/logs.go index 82a9c195..d8a4eddf 100644 --- a/commands/logs.go +++ b/commands/logs.go @@ -1,7 +1,11 @@ package commands import ( + "bufio" + "encoding/json" "kool-dev/kool/core/builder" + "os" + "os/exec" "strconv" "strings" @@ -14,6 +18,13 @@ type KoolLogsFlags struct { Follow bool } +type logEntryJSON struct { + Service string `json:"service"` + Message string `json:"message"` +} + +var execLogsCmd = exec.Command + // KoolLogs holds handlers and functions to implement the logs command logic type KoolLogs struct { DefaultKoolService @@ -65,6 +76,10 @@ func (l *KoolLogs) Execute(args []string) (err error) { l.logs.AppendArgs("--follow") } + if l.Shell().IsJSONOutput() { + return l.printLogsJSON(args...) + } + err = l.Shell().Interactive(l.logs, args...) return } @@ -86,3 +101,74 @@ the command to follow the log output (i.e. 'kool logs -f [SERVICE...]').`, logsCmd.Flags().BoolVarP(&logs.Flags.Follow, "follow", "f", false, "Follow log output.") return } + +func (l *KoolLogs) printLogsJSON(args ...string) (err error) { + if l.Flags.Follow { + return l.streamLogsJSON(args...) + } + + var output string + if output, err = l.Shell().Exec(l.logs, args...); err != nil { + return + } + + for _, line := range strings.Split(output, "\n") { + if line = strings.TrimSpace(line); line == "" { + continue + } + entry := parseLogLine(line) + var payload []byte + if payload, err = json.Marshal(entry); err != nil { + return + } + l.Shell().Println(string(payload)) + } + return +} + +func (l *KoolLogs) streamLogsJSON(args ...string) (err error) { + cmdArgs := l.logs.Args() + if len(args) > 0 { + cmdArgs = append(cmdArgs, args...) + } + cmd := execLogsCmd(l.logs.Cmd(), cmdArgs...) + cmd.Env = os.Environ() + cmd.Stderr = l.Shell().ErrStream() + + stdout, e := cmd.StdoutPipe() + if e != nil { + err = e + return + } + + if err = cmd.Start(); err != nil { + return + } + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + entry := parseLogLine(line) + if payload, e := json.Marshal(entry); e == nil { + l.Shell().Println(string(payload)) + } + } + + err = cmd.Wait() + return +} + +// parseLogLine parses a docker-compose log line into a logEntryJSON. +// Docker compose log format: "service_name | message" (with optional padding). +// If the line doesn't match, service is empty and message is the full line. +func parseLogLine(line string) logEntryJSON { + if idx := strings.Index(line, "|"); idx >= 0 { + service := strings.TrimSpace(line[:idx]) + message := strings.TrimSpace(line[idx+1:]) + return logEntryJSON{Service: service, Message: message} + } + return logEntryJSON{Service: "", Message: line} +} diff --git a/commands/logs_test.go b/commands/logs_test.go index e561d43a..2d1e8685 100644 --- a/commands/logs_test.go +++ b/commands/logs_test.go @@ -1,8 +1,10 @@ package commands import ( + "encoding/json" "errors" "fmt" + "io" "kool-dev/kool/core/builder" "kool-dev/kool/core/shell" "testing" @@ -188,3 +190,92 @@ func TestFailingNoContainersNewLogsCommand(t *testing.T) { assertExecGotError(t, cmd, "error list") } + +func TestParseLogLine(t *testing.T) { + tests := []struct { + line string + service string + message string + }{ + {"web | GET / 200", "web", "GET / 200"}, + {"web | GET / 200", "web", "GET / 200"}, + {"app | starting worker process", "app", "starting worker process"}, + {"plain text without delimiter", "", "plain text without delimiter"}, + {"", "", ""}, + } + + for _, tt := range tests { + entry := parseLogLine(tt.line) + if entry.Service != tt.service { + t.Errorf("parseLogLine(%q): service = %q, want %q", tt.line, entry.Service, tt.service) + } + if entry.Message != tt.message { + t.Errorf("parseLogLine(%q): message = %q, want %q", tt.line, entry.Message, tt.message) + } + } +} + +func newFakeKoolLogsJSON() *KoolLogs { + f := &KoolLogs{ + *(newDefaultKoolService().Fake()), + &KoolLogsFlags{25, false}, + &builder.FakeCommand{MockCmd: "list", MockExecOut: "app"}, + &builder.FakeCommand{MockCmd: "logs"}, + } + f.shell.(*shell.FakeShell).MockIsJSONOutput = true + f.shell.(*shell.FakeShell).MockErrStream = io.Discard + f.shell.(*shell.FakeShell).MockOutStream = io.Discard + return f +} + +func TestLogsJSONOutput(t *testing.T) { + f := newFakeKoolLogsJSON() + f.logs.(*builder.FakeCommand).MockExecOut = "web | GET / 200\ndb | connected to redis" + + cmd := NewLogsCommand(f) + + if err := cmd.Execute(); err != nil { + t.Errorf("unexpected error executing logs command; error: %v", err) + } + + fakeShell := f.shell.(*shell.FakeShell) + if len(fakeShell.OutLines) != 2 { + t.Fatalf("expected 2 JSON lines, got %d: %v", len(fakeShell.OutLines), fakeShell.OutLines) + } + + var entry logEntryJSON + if err := json.Unmarshal([]byte(fakeShell.OutLines[0]), &entry); err != nil { + t.Fatalf("failed to parse first JSON line: %v", err) + } + if entry.Service != "web" { + t.Errorf("expected service 'web', got '%s'", entry.Service) + } + if entry.Message != "GET / 200" { + t.Errorf("expected message 'GET / 200', got '%s'", entry.Message) + } + + if err := json.Unmarshal([]byte(fakeShell.OutLines[1]), &entry); err != nil { + t.Fatalf("failed to parse second JSON line: %v", err) + } + if entry.Service != "db" { + t.Errorf("expected service 'db', got '%s'", entry.Service) + } + if entry.Message != "connected to redis" { + t.Errorf("expected message 'connected to redis', got '%s'", entry.Message) + } +} + +func TestLogsJSONNoContainers(t *testing.T) { + f := newFakeKoolLogsJSON() + f.list.(*builder.FakeCommand).MockExecOut = "" + + cmd := NewLogsCommand(f) + + if err := cmd.Execute(); err != nil { + t.Errorf("unexpected error executing logs command; error: %v", err) + } + + if !f.shell.(*shell.FakeShell).CalledWarning { + t.Error("expected Warning to be called when no containers") + } +} diff --git a/commands/root.go b/commands/root.go index 7cbd52a2..59b91957 100644 --- a/commands/root.go +++ b/commands/root.go @@ -80,6 +80,10 @@ Complete documentation is available at https://kool.dev/docs`, env.Set("KOOL_VERBOSE", verbose.Value.String()) } + if output := cmd.Flags().Lookup("output"); output != nil && output.Value.String() == "json" { + env.Set("KOOL_OUTPUT", "json") + } + if !hasWarnedDevelopmentVersion && version == DEV_VERSION && shell.NewTerminalChecker().IsTerminal(cmd.OutOrStdout()) { shell.NewShell().Warning("Warning: you are executing a development version of kool.") hasWarnedDevelopmentVersion = true @@ -153,6 +157,7 @@ Complete documentation is available at https://kool.dev/docs`, } cmd.PersistentFlags().Bool("verbose", false, "Increases output verbosity") + cmd.PersistentFlags().String("output", "", "Output format: json (machine-readable). For 'kool run', place before the script name.") cmd.PersistentFlags().StringP("working_dir", "w", "", "Changes the working directory for the command") return } diff --git a/commands/root_test.go b/commands/root_test.go index 607f2fec..4e002f58 100644 --- a/commands/root_test.go +++ b/commands/root_test.go @@ -191,6 +191,26 @@ func TestVerboseFlagRootCommand(t *testing.T) { } } +func TestOutputFlagRootCommand(t *testing.T) { + fakeEnv := environment.NewFakeEnvStorage() + + fInfo := fakeKoolInfo() + + root := NewRootCmd(fakeEnv) + info := NewInfoCmd(fInfo) + root.AddCommand(info) + + root.SetArgs([]string{"--output", "json", "info"}) + + if err := root.Execute(); err != nil { + t.Errorf("unexpected error executing command; error: %v", err) + } + + if fakeEnv.Get("KOOL_OUTPUT") != "json" { + t.Errorf("expecting 'KOOL_OUTPUT' to be 'json', got '%s'", fakeEnv.Get("KOOL_OUTPUT")) + } +} + func TestRecursiveCall(t *testing.T) { recursive := &cobra.Command{ Use: "recursive", diff --git a/commands/run.go b/commands/run.go index 036a78ce..2486ded6 100644 --- a/commands/run.go +++ b/commands/run.go @@ -3,6 +3,7 @@ package commands import ( "encoding/json" "errors" + "fmt" "kool-dev/kool/core/builder" "kool-dev/kool/core/environment" "kool-dev/kool/core/parser" @@ -66,7 +67,7 @@ func (r *KoolRun) Execute(originalArgs []string) (err error) { _ = r.parser.AddLookupPath(path.Join(r.env.Get("HOME"), "kool")) if len(originalArgs) == 0 { - if r.Flags.JSON { + if r.Flags.JSON || r.Shell().IsJSONOutput() { return r.printScriptsJSON("") } r.shell.Info("\nAvailable scripts:\n") @@ -89,6 +90,7 @@ func (r *KoolRun) Execute(originalArgs []string) (err error) { } if len(r.commands) == 0 { + r.emitJSONError("script not found", []string{}) err = ErrKoolScriptNotFound return } @@ -131,6 +133,7 @@ A single-line SCRIPT can be run with optional arguments.`, runCmd.Flags().StringArrayVarP(&run.Flags.EnvVariables, "env", "e", []string{}, "Environment variables.") runCmd.Flags().BoolVar(&run.Flags.JSON, "json", false, "Output available scripts as JSON (use without script argument)") + _ = runCmd.Flags().MarkHidden("json") // after a non-flag arg, stop parsing flags runCmd.Flags().SetInterspersed(false) @@ -144,6 +147,18 @@ func SetRunUsageFunc(run *KoolRun, runCmd *cobra.Command) { runCmd.SetUsageFunc(getRunUsageFunc(run, originalUsageText)) } +func (r *KoolRun) emitJSONError(errorMsg string, suggestions []string) { + if !r.Shell().IsJSONOutput() { + return + } + payload := map[string]interface{}{ + "error": errorMsg, + "suggestions": suggestions, + } + errPayload, _ := json.Marshal(payload) + fmt.Fprintln(r.Shell().ErrStream(), string(errPayload)) +} + func (r *KoolRun) parseScript(script string) (err error) { var ( originalEnvs = make(map[string]string) @@ -164,6 +179,12 @@ func (r *KoolRun) parseScript(script string) (err error) { }() if r.commands, err = r.parser.Parse(script); err != nil { + if parser.IsPossibleTypoError(err) && r.Shell().IsJSONOutput() { + r.emitJSONError("script not found", err.(*parser.ErrPossibleTypo).Similars()) + err = ErrKoolScriptNotFound + return + } + if parser.IsPossibleTypoError(err) && r.Shell().IsTerminal() { var promptError error @@ -195,9 +216,12 @@ func (r *KoolRun) parseScript(script string) (err error) { } if parser.IsMultipleDefinedScriptError(err) { - // we should just warn the user about multiple finds for the script r.Shell().Warning("Attention: the script was found in more than one kool.yml file") err = nil + } else if r.Shell().IsJSONOutput() { + r.emitJSONError("script not found", []string{}) + err = ErrKoolScriptNotFound + return } } diff --git a/commands/run_test.go b/commands/run_test.go index 95a88555..a1704d19 100644 --- a/commands/run_test.go +++ b/commands/run_test.go @@ -1,6 +1,7 @@ package commands import ( + "bytes" "encoding/json" "errors" "fmt" @@ -850,3 +851,124 @@ func TestNewRunCommandJsonOutputNullSafety(t *testing.T) { t.Error("Commands should not be nil in JSON output") } } + +func TestNewRunCommandOutputJSONFlag(t *testing.T) { + f := newFakeKoolRun(nil, nil) + f.shell.(*shell.FakeShell).MockIsJSONOutput = true + f.parser.(*parser.FakeParser).MockScriptDetails = []parser.ScriptDetail{ + {Name: "test", Comments: []string{}, Commands: []string{"echo hello"}}, + } + + cmd := NewRunCommand(f) + + if err := cmd.Execute(); err != nil { + t.Errorf("unexpected error executing run command with --output json; error: %v", err) + } + + fakeShell := f.shell.(*shell.FakeShell) + + if len(fakeShell.OutLines) == 0 { + t.Error("expected JSON output from --output json") + return + } + + var output []parser.ScriptDetail + if err := json.Unmarshal([]byte(fakeShell.OutLines[0]), &output); err != nil { + t.Fatalf("failed to parse json output: %v", err) + } + + if len(output) != 1 || output[0].Name != "test" { + t.Errorf("expected script 'test', got %v", output) + } +} + +func TestNewRunCommandJSONModeTypoNoPrompt(t *testing.T) { + typoErr := &parser.ErrPossibleTypo{} + typoErr.SetSimilars([]string{"script"}) + + f := newFakeKoolRun(nil, map[string]error{"scrip": typoErr}) + f.shell.(*shell.FakeShell).MockIsJSONOutput = true + f.shell.(*shell.FakeShell).MockIsTerminal = true + f.env.(*environment.FakeEnvStorage).Envs["KOOL_OUTPUT"] = "json" + + errBuf := &bytes.Buffer{} + f.shell.(*shell.FakeShell).MockErrStream = errBuf + + cmd := NewRunCommand(f) + cmd.SetArgs([]string{"scrip"}) + + err := cmd.Execute() + + if err == nil { + t.Fatal("expected error for typo in JSON mode") + } + if err.Error() != ErrKoolScriptNotFound.Error() { + t.Errorf("expected ErrKoolScriptNotFound, got: %v", err) + } + + if f.promptSelect.(*shell.FakePromptSelect).CalledAsk { + t.Error("should not call prompt in JSON mode") + } + + if errBuf.Len() == 0 { + t.Error("expected structured error JSON on stderr") + } + + var typoPayload struct { + Error string `json:"error"` + Suggestions []string `json:"suggestions"` + } + if err := json.Unmarshal(errBuf.Bytes(), &typoPayload); err != nil { + t.Fatalf("failed to parse JSON error payload: %v", err) + } + if typoPayload.Error != "script not found" { + t.Errorf("expected error 'script not found', got '%s'", typoPayload.Error) + } + if len(typoPayload.Suggestions) != 1 || typoPayload.Suggestions[0] != "script" { + t.Errorf("expected suggestions ['script'], got %v", typoPayload.Suggestions) + } +} + +func TestNewRunCommandJSONModeNotFoundNoSuggestions(t *testing.T) { + f := newFakeKoolRun(nil, nil) + f.shell.(*shell.FakeShell).MockIsJSONOutput = true + f.env.(*environment.FakeEnvStorage).Envs["KOOL_OUTPUT"] = "json" + + errBuf := &bytes.Buffer{} + f.shell.(*shell.FakeShell).MockErrStream = errBuf + + cmd := NewRunCommand(f) + cmd.SetArgs([]string{"totally-fake-script"}) + + err := cmd.Execute() + + if err == nil { + t.Fatal("expected error for nonexistent script in JSON mode") + } + if err.Error() != ErrKoolScriptNotFound.Error() { + t.Errorf("expected ErrKoolScriptNotFound, got: %v", err) + } + + if errBuf.Len() == 0 { + t.Error("expected structured error JSON on stderr") + } + + var payload struct { + Error string `json:"error"` + Suggestions []string `json:"suggestions"` + } + if err := json.Unmarshal(errBuf.Bytes(), &payload); err != nil { + t.Fatalf("failed to parse JSON error payload: %v", err) + } + if payload.Error != "script not found" { + t.Errorf("expected error 'script not found', got '%s'", payload.Error) + } + if len(payload.Suggestions) != 0 { + t.Errorf("expected empty suggestions, got %v", payload.Suggestions) + } + + fakeShell := f.shell.(*shell.FakeShell) + if len(fakeShell.OutLines) > 0 { + t.Errorf("expected no stdout output in JSON error mode, got %v", fakeShell.OutLines) + } +} diff --git a/commands/status.go b/commands/status.go index ca2acf91..806f1b73 100644 --- a/commands/status.go +++ b/commands/status.go @@ -1,6 +1,7 @@ package commands import ( + "encoding/json" "kool-dev/kool/core/builder" "kool-dev/kool/core/environment" "kool-dev/kool/core/network" @@ -33,6 +34,18 @@ type statusService struct { err error } +type statusServiceJSON struct { + Service string `json:"service"` + Running bool `json:"running"` + Ports string `json:"ports"` + State string `json:"state"` +} + +type statusOutputJSON struct { + Services []statusServiceJSON `json:"services"` + Count int `json:"count"` +} + func AddKoolStatus(root *cobra.Command) { var ( status = NewKoolStatus() @@ -74,8 +87,10 @@ func (s *KoolStatus) Execute(args []string) (err error) { chStatus := make(chan *statusService, len(services)) - s.table.SetWriter(s.Shell().OutStream()) - s.table.AppendHeader("Service", "Running", "Ports", "State") + if !s.Shell().IsJSONOutput() { + s.table.SetWriter(s.Shell().OutStream()) + s.table.AppendHeader("Service", "Running", "Ports", "State") + } go func() { var wg sync.WaitGroup @@ -90,17 +105,43 @@ func (s *KoolStatus) Execute(args []string) (err error) { wg.Wait() }() + var statuses []*statusService + for ss := range chStatus { if ss.err != nil { err = ss.err return } - s.table.AppendRow(ss.service, ss.running, ss.ports, ss.state) + if s.Shell().IsJSONOutput() { + statuses = append(statuses, ss) + } else { + s.table.AppendRow(ss.service, ss.running, ss.ports, ss.state) + } } - s.table.SortBy(1) - s.table.Render() + if s.Shell().IsJSONOutput() { + output := statusOutputJSON{ + Services: make([]statusServiceJSON, 0, len(statuses)), + Count: len(statuses), + } + for _, ss := range statuses { + output.Services = append(output.Services, statusServiceJSON{ + Service: ss.service, + Running: ss.running == "Running", + Ports: ss.ports, + State: ss.state, + }) + } + var payload []byte + if payload, err = json.Marshal(output); err != nil { + return + } + s.Shell().Println(string(payload)) + } else { + s.table.SortBy(1) + s.table.Render() + } return } diff --git a/commands/status_test.go b/commands/status_test.go index af0d7351..410e017f 100644 --- a/commands/status_test.go +++ b/commands/status_test.go @@ -1,6 +1,7 @@ package commands import ( + "encoding/json" "errors" "fmt" "io" @@ -257,3 +258,116 @@ cache | Not running | | output` t.Errorf("Expected '%s', got '%s'", expected, output) } } + +func newFakeKoolStatusJSON() *KoolStatus { + f := &KoolStatus{ + *(newDefaultKoolService().Fake()), + &checker.FakeChecker{}, + &network.FakeHandler{}, + environment.NewFakeEnvStorage(), + &builder.FakeCommand{}, + &builder.FakeCommand{}, + &builder.FakeCommand{}, + &shell.FakeTableWriter{}, + } + + f.shell.(*shell.FakeShell).MockIsJSONOutput = true + f.shell.(*shell.FakeShell).MockErrStream = io.Discard + f.shell.(*shell.FakeShell).MockOutStream = io.Discard + + return f +} + +func TestStatusJSONOutput(t *testing.T) { + f := newFakeKoolStatusJSON() + + f.getServicesCmd.(*builder.FakeCommand).MockExecOut = "app" + f.getServiceIDCmd.(*builder.FakeCommand).MockExecOut = "100" + f.getServiceStatusPortCmd.(*builder.FakeCommand).MockExecOut = "Up About an hour|0.0.0.0:80->80/tcp, 9000/tcp" + + cmd := NewStatusCommand(f) + + if err := cmd.Execute(); err != nil { + t.Errorf("unexpected error executing status command; error: %v", err) + } + + fakeShell := f.shell.(*shell.FakeShell) + if len(fakeShell.OutLines) == 0 { + t.Fatal("expected JSON output") + } + + var output statusOutputJSON + if err := json.Unmarshal([]byte(fakeShell.OutLines[0]), &output); err != nil { + t.Fatalf("failed to parse JSON output: %v\nraw: %s", err, fakeShell.OutLines[0]) + } + + if output.Count != 1 { + t.Errorf("expected count 1, got %d", output.Count) + } + + if len(output.Services) != 1 { + t.Fatalf("expected 1 service, got %d", len(output.Services)) + } + + svc := output.Services[0] + if svc.Service != "app" { + t.Errorf("expected service 'app', got '%s'", svc.Service) + } + if !svc.Running { + t.Error("expected running to be true") + } + if svc.State != "Up About an hour" { + t.Errorf("expected state 'Up About an hour', got '%s'", svc.State) + } + if svc.Ports != "0.0.0.0:80->80/tcp, 9000/tcp" { + t.Errorf("expected ports '0.0.0.0:80->80/tcp, 9000/tcp', got '%s'", svc.Ports) + } +} + +func TestStatusJSONOutputNotRunning(t *testing.T) { + f := newFakeKoolStatusJSON() + + f.getServicesCmd.(*builder.FakeCommand).MockExecOut = "app" + f.getServiceIDCmd.(*builder.FakeCommand).MockExecOut = "100" + f.getServiceStatusPortCmd.(*builder.FakeCommand).MockExecOut = "Exited an hour ago" + + cmd := NewStatusCommand(f) + + if err := cmd.Execute(); err != nil { + t.Errorf("unexpected error executing status command; error: %v", err) + } + + fakeShell := f.shell.(*shell.FakeShell) + var output statusOutputJSON + if err := json.Unmarshal([]byte(fakeShell.OutLines[0]), &output); err != nil { + t.Fatalf("failed to parse JSON output: %v", err) + } + + if output.Services[0].Running { + t.Error("expected running to be false for exited container") + } +} + +func TestStatusJSONOutputMultipleServices(t *testing.T) { + f := newFakeKoolStatusJSON() + + f.getServicesCmd.(*builder.FakeCommand).MockExecOut = "cache\napp" + f.getServiceIDCmd.(*builder.FakeCommand).MockExecOut = "100" + f.getServiceStatusPortCmd.(*builder.FakeCommand).MockExecOut = "Up|0.0.0.0:80->80/tcp" + + cmd := NewStatusCommand(f) + + if err := cmd.Execute(); err != nil { + t.Errorf("unexpected error executing status command; error: %v", err) + } + + fakeShell := f.shell.(*shell.FakeShell) + var output statusOutputJSON + if err := json.Unmarshal([]byte(fakeShell.OutLines[0]), &output); err != nil { + t.Fatalf("failed to parse JSON output: %v", err) + } + + if output.Count != 2 { + t.Errorf("expected count 2, got %d", output.Count) + } +} diff --git a/core/shell/fake_shell.go b/core/shell/fake_shell.go index 1d21799c..f87a21ac 100644 --- a/core/shell/fake_shell.go +++ b/core/shell/fake_shell.go @@ -9,17 +9,18 @@ import ( // FakeShell fake shell data type FakeShell struct { - CalledInStream bool - CalledSetInStream bool - CalledOutStream bool - CalledSetOutStream bool - CalledErrStream bool - CalledSetErrStream bool - CalledIsTerminal bool - CalledExec map[string]bool - CalledInteractive map[string]bool - CalledLookPath map[string]bool - ArgsInteractive map[string][]string + CalledInStream bool + CalledSetInStream bool + CalledOutStream bool + CalledSetOutStream bool + CalledErrStream bool + CalledSetErrStream bool + CalledIsTerminal bool + CalledIsJSONOutput bool + CalledExec map[string]bool + CalledInteractive map[string]bool + CalledLookPath map[string]bool + ArgsInteractive map[string][]string Err error OutLines []string @@ -30,11 +31,12 @@ type FakeShell struct { CalledPrintln, CalledPrintf, CalledError, CalledWarning, CalledSuccess, CalledInfo bool - MockOutStream io.Writer - MockErrStream io.Writer - MockInStream io.Reader - MockLookPath error - MockIsTerminal bool + MockOutStream io.Writer + MockErrStream io.Writer + MockInStream io.Reader + MockLookPath error + MockIsTerminal bool + MockIsJSONOutput bool } // InStream is a mocked testing function @@ -49,6 +51,12 @@ func (f *FakeShell) IsTerminal() bool { return f.MockIsTerminal } +// IsJSONOutput is a mocked testing function +func (f *FakeShell) IsJSONOutput() bool { + f.CalledIsJSONOutput = true + return f.MockIsJSONOutput +} + // SetInStream is a mocked testing function func (f *FakeShell) SetInStream(inStream io.Reader) { f.CalledSetInStream = true diff --git a/core/shell/shell.go b/core/shell/shell.go index 5e9d79c2..42403d8d 100644 --- a/core/shell/shell.go +++ b/core/shell/shell.go @@ -71,6 +71,7 @@ type Shell interface { Error(error) IsTerminal() bool + IsJSONOutput() bool } // NewShell creates a new shell @@ -95,6 +96,11 @@ func (s *DefaultShell) IsTerminal() bool { return NewTerminalChecker().IsTerminal(s.inStream, s.outStream) } +// IsJSONOutput tells whether the shell is in JSON output mode +func (s *DefaultShell) IsJSONOutput() bool { + return s.env.Get("KOOL_OUTPUT") == "json" +} + // SetInStream set input stream func (s *DefaultShell) SetInStream(inStream io.Reader) { s.inStream = inStream @@ -235,24 +241,53 @@ func (s *DefaultShell) Printf(format string, a ...interface{}) { _, _ = fmt.Fprintf(s.OutStream(), format, a...) } +// diagnosticStream returns the stream for diagnostic messages. +// In JSON output mode, diagnostics go to stderr so stdout stays clean for data. +func (s *DefaultShell) diagnosticStream() io.Writer { + if s.IsJSONOutput() { + return s.errStream + } + return s.outStream +} + +// useColor returns whether color output should be applied. +// Color is disabled when NO_COLOR env is set (handled by gookit/color) +// or when the output stream is not a terminal. +func (s *DefaultShell) useColor() bool { + return color.Enable && NewTerminalChecker().IsTerminal(s.outStream) +} + // Error error output func (s *DefaultShell) Error(err error) { - _, _ = fmt.Fprintf(s.OutStream(), "%v\n", color.New(color.BgRed, color.FgWhite).Sprintf("error: %v", err)) + msg := fmt.Sprintf("error: %v", err) + if s.useColor() { + msg = color.New(color.BgRed, color.FgWhite).Sprint(msg) + } + _, _ = fmt.Fprintln(s.diagnosticStream(), msg) } // Warning warning message func (s *DefaultShell) Warning(out ...interface{}) { - _, _ = fmt.Fprintln(s.OutStream(), color.New(color.Yellow).Sprint(out...)) + if s.useColor() { + out = []interface{}{color.New(color.Yellow).Sprint(out...)} + } + _, _ = fmt.Fprintln(s.diagnosticStream(), out...) } // Success success message func (s *DefaultShell) Success(out ...interface{}) { - _, _ = fmt.Fprintln(s.OutStream(), color.New(color.Green).Sprint(out...)) + if s.useColor() { + out = []interface{}{color.New(color.Green).Sprint(out...)} + } + _, _ = fmt.Fprintln(s.diagnosticStream(), out...) } // Info info message func (s *DefaultShell) Info(out ...interface{}) { - _, _ = fmt.Fprintln(s.OutStream(), color.New(color.Cyan).Sprint(out...)) + if s.useColor() { + out = []interface{}{color.New(color.Cyan).Sprint(out...)} + } + _, _ = fmt.Fprintln(s.diagnosticStream(), out...) } // Exec will execute the given command silently and return the combined diff --git a/core/shell/shell_test.go b/core/shell/shell_test.go index 8b8896d3..d3caa07a 100644 --- a/core/shell/shell_test.go +++ b/core/shell/shell_test.go @@ -11,8 +11,6 @@ import ( "reflect" "strings" "testing" - - "github.com/gookit/color" ) func readOutput(r io.Reader) (output string, err error) { @@ -350,7 +348,7 @@ func TestErrorShell(t *testing.T) { t.Fatal(err) } - expected := color.New(color.BgRed, color.FgWhite).Sprint("error: testing error") + expected := "error: testing error" if output != expected { t.Errorf("expecting output '%s', got '%s'", expected, output) @@ -368,7 +366,7 @@ func TestWarningShell(t *testing.T) { t.Fatal(err) } - expected := color.New(color.Yellow).Sprint("testing warning") + expected := "testing warning" if output != expected { t.Errorf("expecting output '%s', got '%s'", expected, output) @@ -386,13 +384,63 @@ func TestSuccessShell(t *testing.T) { t.Fatal(err) } - expected := color.New(color.Green).Sprint("testing success") + expected := "testing success" if output != expected { t.Errorf("expecting output '%s', got '%s'", expected, output) } } +func TestIsJSONOutput(t *testing.T) { + s := NewShell() + s.(*DefaultShell).env = environment.NewFakeEnvStorage() + + if s.IsJSONOutput() { + t.Error("expected IsJSONOutput to be false by default") + } + + s.(*DefaultShell).env.Set("KOOL_OUTPUT", "json") + if !s.IsJSONOutput() { + t.Error("expected IsJSONOutput to be true when KOOL_OUTPUT=json") + } +} + +func TestDiagnosticsRoutedToStderrInJSONMode(t *testing.T) { + s := NewShell() + s.(*DefaultShell).env = environment.NewFakeEnvStorage() + s.(*DefaultShell).env.Set("KOOL_OUTPUT", "json") + + outBuf := bytes.NewBufferString("") + errBuf := bytes.NewBufferString("") + s.SetOutStream(outBuf) + s.SetErrStream(errBuf) + + s.Warning("test warning") + + if outBuf.Len() > 0 { + t.Errorf("stdout should be empty in JSON mode, got: %s", outBuf.String()) + } + + if errBuf.Len() == 0 { + t.Error("stderr should have diagnostic output in JSON mode") + } +} + +func TestColorDisabledOnNonTTY(t *testing.T) { + s := NewShell() + s.(*DefaultShell).env = environment.NewFakeEnvStorage() + + outBuf := bytes.NewBufferString("") + s.SetOutStream(outBuf) + + s.Error(errors.New("test")) + + output := outBuf.String() + if strings.Contains(output, "\x1b[") { + t.Errorf("expected no ANSI codes on non-TTY output, got: %s", output) + } +} + func TestRecursiveInteractiveCommand(t *testing.T) { s := NewShell() command := builder.NewCommand("kool", "-v") diff --git a/main.go b/main.go index f6a7ba40..0cf319d9 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,12 @@ func main() { environment.InitEnvironmentVariables(environment.NewEnvStorage()) if err := commands.Execute(); err != nil { - shell.NewShell().Println(err) + s := shell.NewShell() + if s.IsJSONOutput() { + s.Error(err) + } else { + s.Println(err) + } code := 1 if ex, ok := err.(shell.ErrExitable); ok { code = ex.Code From 3b0d29cbb2395db3365ad7cc4b2ca56006c4c7f3 Mon Sep 17 00:00:00 2001 From: Daniel Jun Suguimoto Date: Sun, 12 Jul 2026 10:55:41 -0300 Subject: [PATCH 2/2] fix(ci): resolve lint, race, and grype failures --- .grype.yaml | 14 ++++++++++++++ commands/run.go | 2 +- core/shell/fake_shell.go | 12 ++++++++++++ go.mod | 9 ++++----- go.sum | 20 ++++++++++---------- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/.grype.yaml b/.grype.yaml index 21de5897..f5364679 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -16,3 +16,17 @@ ignore: # https://github.com/docker-library/docker # Remove this entry once `grype kooldev/kool:` no longer reports it. - vulnerability: CVE-2026-27143 + # Inherited from docker:29-cli (Alpine 3.22). libcurl 8.20.0-r1 is + # transitively pulled in by `apk add git`; fixed in 8.21.0-r0. Will clear + # automatically when docker-library/docker rebuilds the 29-cli image with + # an updated Alpine base. Tracked upstream: + # https://github.com/docker-library/docker + # Remove these entries once `grype kooldev/kool:` no longer reports them. + - vulnerability: CVE-2026-8925 + - vulnerability: CVE-2026-11856 + - vulnerability: CVE-2026-9079 + - vulnerability: CVE-2026-10536 + - vulnerability: CVE-2026-8927 + - vulnerability: CVE-2026-8924 + - vulnerability: CVE-2026-8926 + - vulnerability: CVE-2026-11564 diff --git a/commands/run.go b/commands/run.go index 2486ded6..609150db 100644 --- a/commands/run.go +++ b/commands/run.go @@ -156,7 +156,7 @@ func (r *KoolRun) emitJSONError(errorMsg string, suggestions []string) { "suggestions": suggestions, } errPayload, _ := json.Marshal(payload) - fmt.Fprintln(r.Shell().ErrStream(), string(errPayload)) + _, _ = fmt.Fprintln(r.Shell().ErrStream(), string(errPayload)) } func (r *KoolRun) parseScript(script string) (err error) { diff --git a/core/shell/fake_shell.go b/core/shell/fake_shell.go index f87a21ac..d21474b9 100644 --- a/core/shell/fake_shell.go +++ b/core/shell/fake_shell.go @@ -5,6 +5,7 @@ import ( "io" "kool-dev/kool/core/builder" "strings" + "sync" ) // FakeShell fake shell data @@ -22,6 +23,8 @@ type FakeShell struct { CalledLookPath map[string]bool ArgsInteractive map[string][]string + mu sync.Mutex + Err error OutLines []string WarningOutput []interface{} @@ -86,6 +89,9 @@ func (f *FakeShell) SetErrStream(errStream io.Writer) { // Exec is a mocked testing function func (f *FakeShell) Exec(command builder.Command, extraArgs ...string) (outStr string, err error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.CalledExec == nil { f.CalledExec = make(map[string]bool) } @@ -101,6 +107,9 @@ func (f *FakeShell) Exec(command builder.Command, extraArgs ...string) (outStr s // Interactive is a mocked testing function func (f *FakeShell) Interactive(command builder.Command, extraArgs ...string) (err error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.CalledInteractive == nil { f.CalledInteractive = make(map[string]bool) } @@ -121,6 +130,9 @@ func (f *FakeShell) Interactive(command builder.Command, extraArgs ...string) (e // LookPath is a mocked testing function func (f *FakeShell) LookPath(command builder.Command) (err error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.CalledLookPath == nil { f.CalledLookPath = make(map[string]bool) } diff --git a/go.mod b/go.mod index 8b53fed5..b47ffb21 100644 --- a/go.mod +++ b/go.mod @@ -18,11 +18,10 @@ require ( github.com/rhysd/go-github-selfupdate v1.2.3 github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 - golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.43.0 - golang.org/x/term v0.42.0 - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.45.0 + golang.org/x/term v0.43.0 + golang.org/x/text v0.37.0 // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 @@ -51,5 +50,5 @@ require ( github.com/tcnksm/go-gitconfig v0.1.2 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/crypto v0.50.0 // indirect + golang.org/x/crypto v0.52.0 // indirect ) diff --git a/go.sum b/go.sum index 92d3258f..d73ef233 100755 --- a/go.sum +++ b/go.sum @@ -118,8 +118,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -130,8 +130,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -148,20 +148,20 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=