diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c55ad4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config/* +runners/* +output/* \ No newline at end of file diff --git a/README.md b/README.md index 4b4bfe3..a045048 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,137 @@ # dengitool -horrible scripting tool for microslop bindows \ No newline at end of file +simple but horrible scripting tool focused on microslop bindows servers written in go + +--- + +## example instalation + +> `dtool.exe` is portable, you can _use any directory_ + +1. Create folder and copy `dtool.exe`. For example in `C:\dtooll` +1. Add to env **`PATH`** your folder +1. That's it! Open a terminal and try to run `dtool -h` + +## setup app + +example tree + +``` +config +└─── + └─── + └───run + customscript.ps1 + check.ps1 + config.yml + fix.ps1 + restart.ps1 + start.ps1 + stop.ps1 +``` + +> **TIP:** you can leave only scripts that you need + +you can write scripts **in any language you want** which is supported by [built-in script runners](#built-in), OR you can write **your own** [custom runner](#custom-runners) + +#### check + +for checkking status you can use either your script or you can write in config.yml `check-url` [(details)](#config) + +#### restart + +you can define restart script or it would use start and stop + +#### config + +currently there are config for `application`, example: + +```yaml +name: my-application +``` + +and for `module` + +```yaml +name: my-module +# +check-url: https://example.com +# or +check-url: https://example.com 200 +# or even +check-url: https://example.com 200 Test%20Body%20Text +# or don't define at all +# +``` + +for now that's it. + +## usage + +Usage: + +``` +dtool [application] [action] [module] [flags] +``` + +Flags: + +``` + -h, --help help for dtool + --list list configured applications and modules + --list-runners list configured script runners + --silent suppress all output + --verbose enable debug output +``` + +you can use `everything` for `application` + +just `dtool` is alias for `dtool everything check` + +#### actions + +| | | +| ------------------------------ | ------------------------------------------------------ | +| `check/fix/restart/start/stop` | runs it's script [(or check have url defined)](#check) | +| `run [script name]` | runs script in `run` subdirectory | + +## runners + +dengitool automaticly detects available script extension + +#### built-in + +| name | extension | +| ------------------ | --------- | +| Windows Executable | `.exe` | +| PowerShell Script | `.ps1` | +| Python | `.py` | +| CMD Batch File | `.bat` | +| NodeJS | `.js` | + +#### custom runners + +you can create your own runners + +for this, you need to create a .yml file at folder `runners` near dtool.exe + +you can **override** script for built-in extension + +**here is an example:** + +```yaml +# runners/bun.yml +name: My Bun Runner +extension: .ts +runner: bun run %v +``` + +> `%v` - absolute script path + +now you can see it in `dtool --list-runners` + +--- + +# thanks + +![insert here meme](https://assets.ktkz.ru/meme/photo_2026-05-22_11-26-14.jpg) diff --git a/appmodules.go b/appmodules.go new file mode 100644 index 0000000..d2a419e --- /dev/null +++ b/appmodules.go @@ -0,0 +1,182 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +type AppModule struct { + Name string + Folder string + CheckS string +} + +type AppModuleConfig struct { + Name string `yaml:"name"` + CheckUrl *string `yaml:"check-url"` +} + +type Script struct { + Name string + AbsPath string + Runner ScriptRunner +} + +func (s *Script) Run() (string, error) { + args := runnerArgs(s.Runner.Runner, s.AbsPath) + if len(args) == 0 { + return "", errors.New("runner command is empty") + } + + cmd := exec.Command(args[0], args[1:]...) + output, err := cmd.CombinedOutput() + return string(output), err +} + +func runnerArgs(template string, scriptPath string) []string { + fields := strings.Fields(template) + for i, field := range fields { + fields[i] = strings.ReplaceAll(field, "%v", scriptPath) + } + return fields +} + +func (m *AppModule) HasScript(name string, subfolder *string) (bool, *Script) { + folder := m.Folder + if subfolder != nil { + folder = filepath.Join(folder, *subfolder) + } + + for _, r := range runners { + ext := r.Extension + if ext != "" && !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + + scriptPath := filepath.Join(folder, name+ext) + if fileExists(scriptPath) { + abs, err := filepath.Abs(scriptPath) + if err != nil { + return false, nil + } + return true, &Script{Name: name, AbsPath: abs, Runner: r} + } + } + return false, nil +} +func (m *AppModule) Check() (bool, string, error) { + if strings.HasPrefix(m.CheckS, "URL ") { + check := strings.Split(m.CheckS, " ")[1:] + if len(check) < 1 || len(check) > 3 { + return false, "", errors.New("check url is bad") + } + checkUrl := check[0] + code := "" + if len(check) > 1 { + code = check[1] + } + body := "" + if len(check) > 2 { + dBody, err := url.QueryUnescape(check[2]) + if err != nil { + return false, "", errors.New("check body decode failed") + } + body = dBody + } + + cCode, cBody, err := fetch(checkUrl, defaultFetchTimeout) + if err != nil { + return false, "", err + } + if code != "" && strconv.Itoa(cCode) != code { + return false, "", fmt.Errorf("code is different: expected %s, got %d", code, cCode) + } + if body != "" && body != cBody { + return false, "", errors.New("body is different") + } + + return true, "", nil + + } else { + exists, script := m.HasScript("check", nil) + if exists && script != nil { + output, err := script.Run() + if err != nil { + return false, output, err + } else { + return true, output, nil + } + } else { + return false, "", errors.New("check script not found") + } + } +} + +func (m *AppModule) Start() (string, error) { + exists, script := m.HasScript("start", nil) + if exists && script != nil { + return script.Run() + } else { + return "", errors.New("start script not found") + } +} + +func (m *AppModule) Stop() (string, error) { + exists, script := m.HasScript("stop", nil) + if exists && script != nil { + return script.Run() + } else { + return "", errors.New("stop script not found") + } +} + +func (m *AppModule) Restart() (string, error) { + exists, script := m.HasScript("restart", nil) + if exists && script != nil { + return script.Run() + } else { + stopOutput, err := m.Stop() + if err != nil { + return stopOutput, err + } + startOutput, err := m.Start() + return joinOutput(stopOutput, startOutput), err + } + +} + +func (m *AppModule) Fix() (string, error) { + exists, script := m.HasScript("fix", nil) + + if exists && script != nil { + return script.Run() + } else { + return m.Restart() + } +} + +func (m *AppModule) Run(scriptN string) (string, error) { + var sub = "run" + exists, script := m.HasScript(scriptN, &sub) + + if exists && script != nil { + return script.Run() + } else { + return "", errors.New("run script not found") + } +} + +type Application struct { + Name string + Folder string + AppModules []AppModule +} + +type AppConfig struct { + Name string `yaml:"name"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7f4f410 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module git.ktkz.ru/katamaz/dengitool + +go 1.26.3 + +require ( + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ff4d6ec --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..69b9091 --- /dev/null +++ b/logger.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "os" +) + +var ( + silentMode bool + verboseMode bool +) + +const ( + colorRed = "\x1b[31m" + colorReset = "\x1b[0m" +) + +type logger struct{} + +func (logger) Info(args ...any) { + if silentMode { + return + } + fmt.Fprintln(os.Stdout, args...) +} + +func (logger) Debug(args ...any) { + if silentMode || !verboseMode { + return + } + fmt.Fprintln(os.Stdout, args...) +} + +func (logger) Println(args ...any) { + if silentMode { + return + } + fmt.Fprintln(os.Stdout, args...) +} + +func (logger) Error(args ...any) { + fmt.Fprint(os.Stderr, colorRed) + fmt.Fprintln(os.Stderr, args...) + fmt.Fprint(os.Stderr, colorReset) +} + +func (logger) Fatalln(args ...any) { + log.Error(args...) + os.Exit(1) +} + +var log = logger{} diff --git a/main.go b/main.go new file mode 100644 index 0000000..11a9ff2 --- /dev/null +++ b/main.go @@ -0,0 +1,413 @@ +// +// project dengitool +// Copyright (C) 2026 katamaz +// + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var runners = GetScriptsRunners() + +var silent, verbose, listModules, listRunners bool + +func init() { + rootCmd.PersistentFlags().BoolVar(&silent, "silent", false, "suppress all output") + rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "enable debug output") + rootCmd.PersistentFlags().BoolVar(&listModules, "list", false, "list configured applications and modules") + rootCmd.PersistentFlags().BoolVar(&listRunners, "list-runners", false, "list configured script runners") + + rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + // silent wins + initLogger(silent, verbose && !silent) + } + + if !(folderExists(("./config/"))) { + log.Info("No config folder found, creating new...") + if err := os.Mkdir("./config/", 0750); err != nil { + log.Fatalln("Failed to create config folder") + } + } +} + +func initLogger(silent bool, verbose bool) { + silentMode = silent + verboseMode = verbose +} + +var rootCmd = &cobra.Command{ + Use: "dtool [application] [action] [module]", + Short: "DengiTool", + Long: "DengiTool CLI", + SilenceErrors: true, + SilenceUsage: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runCLI(args) + }, +} + +func main() { + if err := rootCmd.Execute(); err != nil { + log.Error(err) + os.Exit(1) + } +} + +func runCLI(args []string) error { + if listModules { + return printModules() + } + if listRunners { + return printRunners() + } + + request, err := parseRequest(args) + if err != nil { + return err + } + + apps := listApplications() + if len(apps) == 0 { + log.Println("No apps found\nNothing to do") + return nil + } + + targets, err := resolveTargets(apps, request) + if err != nil { + return err + } + + if len(targets) == 0 { + return errors.New("no modules matched") + } + + var results []actionResult + currentApp := "" + for i, target := range targets { + if target.app.Folder != currentApp { + currentApp = target.app.Folder + if !silent { + log.Println("▸ " + displayName(target.app.Name, filepath.Base(target.app.Folder))) + } + } + results = append(results, runModuleAction(target.app, target.module, request, treePrefix(i, targets))) + } + + showSummary(results) + if failed := countFailed(results); failed > 0 { + return fmt.Errorf("%d of %d action(s) failed", failed, len(results)) + } + + return nil +} + +func printModules() error { + apps := listApplications() + if len(apps) == 0 { + log.Println("No apps found") + return nil + } + + for _, app := range apps { + log.Println(displayName(app.Name, filepath.Base(app.Folder))) + if len(app.AppModules) == 0 { + log.Println(" (no modules)") + continue + } + + for _, module := range app.AppModules { + log.Println(" " + displayName(module.Name, filepath.Base(module.Folder))) + } + } + + return nil +} + +func printRunners() error { + if len(runners) == 0 { + log.Println("No runners found") + return nil + } + + for _, runner := range runners { + log.Println(fmt.Sprintf("%s %s", runner.Name, runner.Extension)) + if verbose { + log.Println(" " + runner.Runner) + } + } + + return nil +} + +type cliRequest struct { + application string + action string + module string + script string +} + +type moduleTarget struct { + app Application + module AppModule +} + +type actionResult struct { + label string + output string + err error +} + +func parseRequest(args []string) (cliRequest, error) { + request := cliRequest{application: "everything", action: "check"} + if len(args) == 0 { + return request, nil + } + + if len(args) > 4 { + return request, usageError() + } + + request.application = args[0] + if len(args) > 1 { + request.action = args[1] + } + + if !validAction(request.action) { + return request, fmt.Errorf("unknown action %q; valid actions: %s", request.action, strings.Join(validActions(), ", ")) + } + + if request.action == "run" { + if len(args) < 3 { + return request, errors.New("usage: dtool [application] run [script] [module]") + } + request.script = args[2] + if len(args) > 3 { + request.module = args[3] + } + return request, nil + } + + if len(args) > 2 { + request.module = args[2] + } + + return request, nil +} + +func validAction(action string) bool { + for _, valid := range validActions() { + if action == valid { + return true + } + } + return false +} + +func validActions() []string { + return []string{"check", "start", "stop", "restart", "fix", "run"} +} + +func usageError() error { + return errors.New("usage: dtool [application] [action] [module]\n dtool [application] run [script] [module]") +} + +func resolveTargets(apps []Application, request cliRequest) ([]moduleTarget, error) { + var selected []Application + if request.application == "everything" { + if request.module != "" { + return nil, errors.New(`"everything" runs across all modules; omit the module argument`) + } + selected = apps + } else { + app, ok := findApplication(apps, request.application) + if !ok { + return nil, fmt.Errorf("application %q not found; available applications: %s", request.application, applicationNames(apps)) + } + selected = []Application{app} + } + + var targets []moduleTarget + for _, app := range selected { + if request.module == "" { + for _, module := range app.AppModules { + targets = append(targets, moduleTarget{app: app, module: module}) + } + continue + } + + module, ok := findModule(app, request.module) + if !ok { + return nil, fmt.Errorf("module %q not found in %q; available modules: %s", request.module, app.Name, moduleNames(app)) + } + targets = append(targets, moduleTarget{app: app, module: module}) + } + + return targets, nil +} + +func findApplication(apps []Application, name string) (Application, bool) { + for _, app := range apps { + if sameName(app.Name, name) || sameName(filepath.Base(app.Folder), name) { + return app, true + } + } + return Application{}, false +} + +func findModule(app Application, name string) (AppModule, bool) { + for _, module := range app.AppModules { + if sameName(module.Name, name) || sameName(filepath.Base(module.Folder), name) { + return module, true + } + } + return AppModule{}, false +} + +func sameName(left string, right string) bool { + return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right)) +} + +func applicationNames(apps []Application) string { + names := make([]string, 0, len(apps)) + for _, app := range apps { + names = append(names, displayName(app.Name, filepath.Base(app.Folder))) + } + return strings.Join(names, ", ") +} + +func moduleNames(app Application) string { + names := make([]string, 0, len(app.AppModules)) + for _, module := range app.AppModules { + names = append(names, displayName(module.Name, filepath.Base(module.Folder))) + } + if len(names) == 0 { + return "(none)" + } + return strings.Join(names, ", ") +} + +func displayName(name string, folder string) string { + if sameName(name, folder) { + return name + } + return fmt.Sprintf("%s (%s)", name, folder) +} + +func runModuleAction(app Application, module AppModule, request cliRequest, prefix treeParts) actionResult { + label := fmt.Sprintf("%s/%s %s", app.Name, module.Name, request.action) + moduleLabel := fmt.Sprintf("%s %s", displayName(module.Name, filepath.Base(module.Folder)), request.action) + if request.action == "run" { + label = fmt.Sprintf("%s/%s run %s", app.Name, module.Name, request.script) + moduleLabel = fmt.Sprintf("%s run %s", displayName(module.Name, filepath.Base(module.Folder)), request.script) + } + + if !silent { + log.Println(prefix.branch + "⏳ " + moduleLabel) + } + + var err error + output := "" + switch request.action { + case "check": + _, output, err = module.Check() + case "start": + output, err = module.Start() + case "stop": + output, err = module.Stop() + case "restart": + output, err = module.Restart() + case "fix": + output, err = module.Fix() + case "run": + output, err = module.Run(request.script) + default: + err = fmt.Errorf("unknown action %q", request.action) + } + + if err != nil { + if !silent { + printTreeError(prefix.branch + "✗ " + moduleLabel + ": " + err.Error()) + printScriptOutput(prefix.continuation, output) + } + return actionResult{label: label, output: output, err: err} + } + + if !silent { + log.Println(prefix.branch + "✓ " + moduleLabel) + printScriptOutput(prefix.continuation, output) + } + return actionResult{label: label, output: output} +} + +type treeParts struct { + branch string + continuation string +} + +func treePrefix(index int, targets []moduleTarget) treeParts { + if index == len(targets)-1 || targets[index+1].app.Folder != targets[index].app.Folder { + return treeParts{branch: "└─ ", continuation: " "} + } + return treeParts{branch: "├─ ", continuation: "│ "} +} + +func printScriptOutput(prefix string, output string) { + output = strings.TrimRight(output, "\r\n") + if output == "" { + return + } + + for _, line := range strings.Split(output, "\n") { + log.Println(prefix + " " + strings.TrimRight(line, "\r")) + } +} + +func printTreeError(message string) { + fmt.Fprint(os.Stdout, colorRed) + fmt.Fprintln(os.Stdout, message) + fmt.Fprint(os.Stdout, colorReset) +} + +func showSummary(results []actionResult) { + if silent { + for _, result := range results { + if result.err != nil { + log.Error("FAIL", result.label+":", result.err) + printScriptOutput("", result.output) + } + } + return + } + + failed := countFailed(results) + if failed == 0 { + log.Println(fmt.Sprintf("Done: %d action(s) succeeded", len(results))) + return + } + + for _, result := range results { + if result.err != nil { + log.Debug("FAIL", result.label+":", result.err) + } + } + log.Error(fmt.Sprintf("Done: %d succeeded, %d failed", len(results)-failed, failed)) +} + +func countFailed(results []actionResult) int { + failed := 0 + for _, result := range results { + if result.err != nil { + failed++ + } + } + return failed +} diff --git a/scriptsRunner.go b/scriptsRunner.go new file mode 100644 index 0000000..4266799 --- /dev/null +++ b/scriptsRunner.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +type ScriptRunner struct { + Name string `yaml:"name"` + Extension string `yaml:"extension"` + Runner string `yaml:"runner"` +} + +func GetScriptsRunners() []ScriptRunner { + runners := []ScriptRunner{} + + if folderExists("./runners") { + entries, err := os.ReadDir("./runners/") + if err == nil { + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".yml") { + runner, err := os.ReadFile("./runners/" + e.Name()) + if err == nil { + var rnr ScriptRunner + if err := yaml.Unmarshal(runner, &rnr); err == nil { + runners = append(runners, rnr) + } + } + } + } + } + } + + defaultRunners := []ScriptRunner{ + {Name: "Windows Executable", Extension: ".exe", Runner: "%v"}, + {Name: "PowerShell Script", Extension: ".ps1", Runner: "powershell -NoProfile -ExecutionPolicy Bypass -File %v"}, + {Name: "Python", Extension: ".py", Runner: "python %v"}, + {Name: "CMD Batch File", Extension: ".bat", Runner: "%v"}, + {Name: "NodeJS", Extension: ".js", Runner: "node %v"}, + } + runners = append(runners, defaultRunners...) + + return runners +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..7bbd14e --- /dev/null +++ b/utils.go @@ -0,0 +1,137 @@ +package main + +import ( + "context" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const defaultFetchTimeout = 10 * time.Second + +func folderExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} +func fileExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() +} + +func quoteCommandArg(arg string) string { + if runtime.GOOS == "windows" { + return `"` + strings.ReplaceAll(arg, `"`, `""`) + `"` + } + return `'` + strings.ReplaceAll(arg, `'`, `'\''`) + `'` +} + +func joinOutput(outputs ...string) string { + var parts []string + for _, output := range outputs { + output = strings.TrimRight(output, "\r\n") + if output != "" { + parts = append(parts, output) + } + } + return strings.Join(parts, "\n") +} + +func readAppModule(app, module string) AppModule { + path := filepath.Join(".", "config", app, module) + name := module + check := "SCRIPT" + configPath := filepath.Join(path, "config.yml") + if fileExists(configPath) { + configF, err := os.ReadFile(configPath) + if err == nil { + var cfg AppModuleConfig + if err := yaml.Unmarshal(configF, &cfg); err == nil { + if cfg.Name != "" { + name = cfg.Name + } + if cfg.CheckUrl != nil { + check = "URL " + *cfg.CheckUrl + } + } + } + } + + return AppModule{Name: name, Folder: path, CheckS: check} +} + +func readApplication(folderName string) Application { + name := folderName + appFolder := filepath.Join(".", "config", folderName) + configPath := filepath.Join(appFolder, "config.yml") + if fileExists(configPath) { + configF, err := os.ReadFile(configPath) + if err == nil { + var cfg AppConfig + if err := yaml.Unmarshal(configF, &cfg); err == nil { + if cfg.Name != "" { + name = cfg.Name + } + } + } + } + entries, err := os.ReadDir(appFolder) + if err != nil { + log.Fatalln(err) + } + var i []AppModule + for _, e := range entries { + if e.IsDir() { + i = append(i, readAppModule(folderName, e.Name())) + } + } + return Application{Name: name, Folder: appFolder, AppModules: i} +} + +func listApplications() []Application { + entries, err := os.ReadDir("./config") + if err != nil { + log.Fatalln(err) + } + var i []Application + for _, e := range entries { + if e.IsDir() { + i = append(i, readApplication(e.Name())) + } + } + return i +} + +func fetch(url string, timeout time.Duration) (int, string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return 0, "", err + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return 0, "", err + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, "", err + } + return resp.StatusCode, string(b), nil +}