diff --git a/.gitignore b/.gitignore index ed09643f03..0aef35ee31 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ node_modules /build-local /.history /build -/bin +/bin/cli server/ui-server server/api server/ui/assets diff --git a/bin/devctl.sh b/bin/devctl.sh new file mode 100755 index 0000000000..50d529cefd --- /dev/null +++ b/bin/devctl.sh @@ -0,0 +1,11 @@ +#! /usr/bin/env bash + +# get the scripts directory +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# build devctl +pushd $DIR/../utilities/devctl +go build . +popd + +$DIR/../utilities/devctl/devctl --config-dir ./configs "$@" diff --git a/configs/.env.dev b/configs/.env.dev new file mode 100644 index 0000000000..cd349b6871 --- /dev/null +++ b/configs/.env.dev @@ -0,0 +1,4 @@ +VITE_TEMPORAL_PORT="7233" +VITE_API="http://localhost:8081" +VITE_MODE="development" +VITE_TEMPORAL_UI_BUILD_TARGET="local" diff --git a/configs/Procfile.dev b/configs/Procfile.dev new file mode 100644 index 0000000000..d163cfae96 --- /dev/null +++ b/configs/Procfile.dev @@ -0,0 +1,3 @@ +ui: pnpm vite dev --open +temporal: temporal server start-dev +ui-server: cd server && go run ./cmd/server/main.go --env development start diff --git a/configs/healthcheck.dev.yaml b/configs/healthcheck.dev.yaml new file mode 100644 index 0000000000..fa9f62b621 --- /dev/null +++ b/configs/healthcheck.dev.yaml @@ -0,0 +1,17 @@ +ui-server: + url: http://localhost:8233/health + codes: [200, 302] + interval_seconds: 5 + timeout_seconds: 5 + +temporal: + url: http://localhost:8081/healthz + codes: [200, 302] + interval_seconds: 5 + timeout_seconds: 5 + +ui: + url: http://localhost:3000 + codes: [200, 302] + interval_seconds: 5 + timeout_seconds: 5 diff --git a/package.json b/package.json index c66d263d26..f024ea849c 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "prepare": "svelte-kit sync && esno scripts/download-temporal.ts && husky install", "eslint": "eslint --ignore-path .gitignore .", "eslint:fix": "eslint --ignore-path .gitignore --fix .", - "dev": "pnpm dev:ui-server -- --open", + "dev": "./bin/devctl.sh", "dev:ui-server": ". ./.env.ui-server && vite dev --mode ui-server", "dev:local-temporal": ". ./.env.local-temporal && vite dev --mode local-temporal", "dev:temporal-cli": "vite dev --mode temporal-server", diff --git a/utilities/devctl/.gitignore b/utilities/devctl/.gitignore new file mode 100644 index 0000000000..d087d676e3 --- /dev/null +++ b/utilities/devctl/.gitignore @@ -0,0 +1,3 @@ +.cache +.gocache +devctl diff --git a/utilities/devctl/app/app.go b/utilities/devctl/app/app.go new file mode 100644 index 0000000000..39cbbdaca5 --- /dev/null +++ b/utilities/devctl/app/app.go @@ -0,0 +1,91 @@ +package app + +import ( + "context" + "devctl/app/contexts" + "devctl/app/runner" + "devctl/app/tui" + "fmt" + "net/http" + "os" + + "github.com/urfave/cli/v2" +) + +func New() *Handler { + return &Handler{} +} + +type Handler struct { + // ConfigDir is the directory containing config files. + configDir string + // Mode is the mode to run (e.g., dev, prod). + mode string + // Focus is the service to focus on. + focus string + // Mute is the service to mute. + mute string + // tui is the flag to enable the TUI. + noTUI bool +} + +func (h *Handler) SetConfigDir(configDir string) *Handler { + h.configDir = configDir + + return h +} + +func (h *Handler) SetMode(mode string) *Handler { + h.mode = mode + + return h +} + +func (h *Handler) SetFocus(focus string) *Handler { + h.focus = focus + + return h +} + +func (h *Handler) SetMute(mute string) *Handler { + h.mute = mute + + return h +} + +func (h *Handler) SetTUI(tui bool) *Handler { + h.noTUI = tui + + return h +} + +func (h *Handler) Run() error { + if h.noTUI { + return h.runServices() + } + + return tui.Run(h.configDir, h.mode, h.focus, h.mute) +} + +// runServices initializes and runs the service runner. +func (h *Handler) runServices() error { + opts := runner.Options{ + ConfigDir: h.configDir, + Mode: h.mode, + Focus: h.focus, + Mute: h.mute, + HTTPClient: http.DefaultClient, + } + + r := runner.New(opts) + + ctx, cancel := contexts.WithSignalCancel(context.Background()) + defer cancel() + + if err := r.Run(ctx); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + return cli.Exit("", 1) + } + + return nil +} diff --git a/utilities/devctl/app/app_test.go b/utilities/devctl/app/app_test.go new file mode 100644 index 0000000000..4ed994d0f9 --- /dev/null +++ b/utilities/devctl/app/app_test.go @@ -0,0 +1,93 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "testing" + + cli "github.com/urfave/cli/v2" +) + +// TestHandlerSetters ensures fluent setters set internal fields correctly. +func TestHandlerSetters(t *testing.T) { + h := New(). + SetConfigDir("cfg"). + SetMode("m"). + SetFocus("f"). + SetMute("u"). + SetTUI(true) + if h.configDir != "cfg" { + t.Errorf("configDir: expected %q, got %q", "cfg", h.configDir) + } + if h.mode != "m" { + t.Errorf("mode: expected %q, got %q", "m", h.mode) + } + if h.focus != "f" { + t.Errorf("focus: expected %q, got %q", "f", h.focus) + } + if h.mute != "u" { + t.Errorf("mute: expected %q, got %q", "u", h.mute) + } + // SetTUI sets the noTUI flag to disable the TUI when true + if !h.noTUI { + t.Error("noTUI: expected true, got false") + } +} + +// helper to suppress stdout and stderr during test +func suppressOutput(f func()) { + origOut, origErr := os.Stdout, os.Stderr + null, _ := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + os.Stdout, os.Stderr = null, null + defer func() { + null.Close() + os.Stdout, os.Stderr = origOut, origErr + }() + f() +} + +// TestRun_Success verifies Run sets environment and returns nil on valid config. +func TestRun_Success(t *testing.T) { + dir := t.TempDir() + // write .env.test + key := "__TEST_APP_RUN__" + val := "VALUE" + envF := filepath.Join(dir, ".env.test") + if err := os.WriteFile(envF, []byte(key+"="+val+"\n"), 0644); err != nil { + t.Fatalf("writing env file: %v", err) + } + // write Procfile.test + procF := filepath.Join(dir, "Procfile.test") + if err := os.WriteFile(procF, []byte("svc: echo ok\n"), 0644); err != nil { + t.Fatalf("writing Procfile: %v", err) + } + defer os.Unsetenv(key) + // disable the TUI to exercise the services-runner path + h := New().SetConfigDir(dir).SetMode("test").SetTUI(true) + suppressOutput(func() { + if err := h.Run(); err != nil { + t.Fatalf("expected nil error, got %v", err) + } + }) + if got := os.Getenv(key); got != val { + t.Errorf("env var %s: expected %q, got %q", key, val, got) + } +} + +// TestRun_ProcfileMissing verifies Run returns ExitError when Procfile is missing. +func TestRun_ProcfileMissing(t *testing.T) { + dir := t.TempDir() + // disable the TUI to exercise the services-runner path + h := New().SetConfigDir(dir).SetMode("noexistent").SetTUI(true) + var exitCoder cli.ExitCoder + suppressOutput(func() { + err := h.Run() + if !errors.As(err, &exitCoder) { + t.Fatalf("expected cli.ExitCoder, got %v", err) + } + if exitCoder.ExitCode() != 1 { + t.Errorf("expected exit code 1, got %d", exitCoder.ExitCode()) + } + }) +} \ No newline at end of file diff --git a/utilities/devctl/app/colors/colors.go b/utilities/devctl/app/colors/colors.go new file mode 100644 index 0000000000..cb0f9c5eaa --- /dev/null +++ b/utilities/devctl/app/colors/colors.go @@ -0,0 +1,24 @@ +package colors + +// ServiceColors is the list of colors for Runner output (hex values). +var ServiceColors = []string{ + "#dc322f", // Solarized red + "#859900", // Solarized green + "#b58900", // Solarized yellow + "#268bd2", // Solarized blue + "#d33682", // Solarized magenta + "#2aa198", // Solarized cyan +} + +// TUI color constants (hex values). +const ( + Header = "#61AFEF" + SelectedBackground = "#3E4451" + SelectedForeground = "#FFFFFF" + Pending = "#A0A1A7" + Running = "#98C379" + Crashed = "#E06C75" + Exited = "#61AFEF" + Healthy = "#98C379" + Unhealthy = "#E06C75" +) \ No newline at end of file diff --git a/utilities/devctl/app/colors/colors_test.go b/utilities/devctl/app/colors/colors_test.go new file mode 100644 index 0000000000..d2619655cf --- /dev/null +++ b/utilities/devctl/app/colors/colors_test.go @@ -0,0 +1,44 @@ +package colors + +import ( + "reflect" + "testing" +) + +// TestServiceColors verifies the default service color palette. +func TestServiceColors(t *testing.T) { + expected := []string{ + "#dc322f", // Solarized red + "#859900", // Solarized green + "#b58900", // Solarized yellow + "#268bd2", // Solarized blue + "#d33682", // Solarized magenta + "#2aa198", // Solarized cyan + } + if !reflect.DeepEqual(ServiceColors, expected) { + t.Errorf("ServiceColors mismatch: expected %v, got %v", expected, ServiceColors) + } +} + +// TestColorConstants verifies the TUI color constants have the correct values. +func TestColorConstants(t *testing.T) { + tests := []struct { + name string + got, want string + }{ + {"Header", Header, "#61AFEF"}, + {"SelectedBackground", SelectedBackground, "#3E4451"}, + {"SelectedForeground", SelectedForeground, "#FFFFFF"}, + {"Pending", Pending, "#A0A1A7"}, + {"Running", Running, "#98C379"}, + {"Crashed", Crashed, "#E06C75"}, + {"Exited", Exited, "#61AFEF"}, + {"Healthy", Healthy, "#98C379"}, + {"Unhealthy", Unhealthy, "#E06C75"}, + } + for _, tt := range tests { + if tt.got != tt.want { + t.Errorf("%s: expected %s, got %s", tt.name, tt.want, tt.got) + } + } +} \ No newline at end of file diff --git a/utilities/devctl/app/config/config.go b/utilities/devctl/app/config/config.go new file mode 100644 index 0000000000..b3ed16d7c1 --- /dev/null +++ b/utilities/devctl/app/config/config.go @@ -0,0 +1,163 @@ +package config + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +// ServiceConfig holds the name and command for a service. +type ServiceConfig struct { + Name string + Cmd string +} + +// HealthEntry defines a health check configuration for a service. +type HealthEntry struct { + URL string + Codes []int + IntervalSeconds int + TimeoutSeconds int +} + +// ParseEnv parses key=value lines from r. Lines starting with # or empty are skipped. +func ParseEnv(r io.Reader) (map[string]string, error) { + m := make(map[string]string) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid env line: %s", line) + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + m[key] = val + } + if err := scanner.Err(); err != nil { + return nil, err + } + return m, nil +} + +// LoadEnvFile opens configs/.env. and parses it. +func LoadEnvFile(configDir, mode string) (map[string]string, error) { + path := filepath.Join(configDir, fmt.Sprintf(".env.%s", mode)) + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return ParseEnv(f) +} + +// ParseProcfile parses lines of the form name: command from r. +func ParseProcfile(r io.Reader) ([]ServiceConfig, error) { + var svcs []ServiceConfig + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid procfile line: %s", line) + } + name := strings.TrimSpace(parts[0]) + cmd := strings.TrimSpace(parts[1]) + svcs = append(svcs, ServiceConfig{Name: name, Cmd: cmd}) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return svcs, nil +} + +// LoadProcfileFile opens configs/Procfile. and parses it. +func LoadProcfileFile(configDir, mode string) ([]ServiceConfig, error) { + path := filepath.Join(configDir, fmt.Sprintf("Procfile.%s", mode)) + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return ParseProcfile(f) +} + +// ParseHealth parses a simple YAML-like healthcheck: service: then indented key: value lines. +func ParseHealth(r io.Reader) (map[string]HealthEntry, error) { + hc := make(map[string]HealthEntry) + scanner := bufio.NewScanner(r) + var current string + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + // New service block + if !strings.HasPrefix(line, " ") && strings.HasSuffix(trimmed, ":") { + current = strings.TrimSuffix(trimmed, ":") + hc[current] = HealthEntry{} + continue + } + if current == "" { + continue + } + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + entry := hc[current] + switch key { + case "url": + entry.URL = val + case "codes": + vals := strings.Trim(val, "[]") + for _, p := range strings.Split(vals, ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if c, err := strconv.Atoi(p); err == nil { + entry.Codes = append(entry.Codes, c) + } + } + case "interval_seconds": + if n, err := strconv.Atoi(val); err == nil { + entry.IntervalSeconds = n + } + case "timeout_seconds": + if n, err := strconv.Atoi(val); err == nil { + entry.TimeoutSeconds = n + } + } + hc[current] = entry + } + if err := scanner.Err(); err != nil { + return nil, err + } + return hc, nil +} + +// LoadHealthFile opens configs/healthcheck..yaml and parses it. +func LoadHealthFile(configDir, mode string) (map[string]HealthEntry, error) { + path := filepath.Join(configDir, fmt.Sprintf("healthcheck.%s.yaml", mode)) + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return ParseHealth(f) +} + diff --git a/utilities/devctl/app/config/config_test.go b/utilities/devctl/app/config/config_test.go new file mode 100644 index 0000000000..b70621ed6b --- /dev/null +++ b/utilities/devctl/app/config/config_test.go @@ -0,0 +1,178 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseEnv(t *testing.T) { + input := `# comment +KEY1=val1 +KEY2 = val2 + +INVALID +KEY3=val3` + r := strings.NewReader(input) + env, err := ParseEnv(r) + if err == nil { + t.Fatal("expected error on invalid line, got nil") + } + // Remove invalid line and test success + input = `# comment +KEY1=val1 +KEY2 = val2 +KEY3=val3` + r = strings.NewReader(input) + env, err = ParseEnv(r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if env["KEY1"] != "val1" || env["KEY2"] != "val2" || env["KEY3"] != "val3" { + t.Errorf("unexpected env map: %v", env) + } +} + +// Test loading environment file from disk +func TestLoadEnvFile(t *testing.T) { + dir := t.TempDir() + content := "A=1\nB=2\n" + fname := filepath.Join(dir, ".env.test") + if err := os.WriteFile(fname, []byte(content), 0644); err != nil { + t.Fatalf("writing env file: %v", err) + } + env, err := LoadEnvFile(dir, "test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if env["A"] != "1" || env["B"] != "2" { + t.Errorf("unexpected env map: %v", env) + } +} + +func TestLoadEnvFile_NotExist(t *testing.T) { + dir := t.TempDir() + _, err := LoadEnvFile(dir, "nope") + if !os.IsNotExist(err) { + t.Errorf("expected IsNotExist error, got: %v", err) + } +} + +// Test loading Procfile from disk +func TestLoadProcfileFile(t *testing.T) { + dir := t.TempDir() + content := "web: run-web\nworker: run-worker\n" + fname := filepath.Join(dir, "Procfile.test") + if err := os.WriteFile(fname, []byte(content), 0644); err != nil { + t.Fatalf("writing procfile: %v", err) + } + svcs, err := LoadProcfileFile(dir, "test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(svcs) != 2 { + t.Fatalf("expected 2 services, got %d", len(svcs)) + } + if svcs[0].Name != "web" || svcs[0].Cmd != "run-web" { + t.Errorf("unexpected svc[0]: %v", svcs[0]) + } +} + +func TestLoadProcfileFile_NotExist(t *testing.T) { + dir := t.TempDir() + _, err := LoadProcfileFile(dir, "nope") + if !os.IsNotExist(err) { + t.Errorf("expected IsNotExist error, got: %v", err) + } +} + +// Test loading health configuration from disk +func TestLoadHealthFile(t *testing.T) { + dir := t.TempDir() + content := "svc1:\n url: http://x\n codes: [200]\n" + fname := filepath.Join(dir, "healthcheck.test.yaml") + if err := os.WriteFile(fname, []byte(content), 0644); err != nil { + t.Fatalf("writing health file: %v", err) + } + hc, err := LoadHealthFile(dir, "test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + entry, ok := hc["svc1"] + if !ok { + t.Fatalf("missing entry for svc1") + } + if entry.URL != "http://x" || len(entry.Codes) != 1 || entry.Codes[0] != 200 { + t.Errorf("unexpected health entry: %+v", entry) + } +} + +func TestLoadHealthFile_NotExist(t *testing.T) { + dir := t.TempDir() + _, err := LoadHealthFile(dir, "nope") + if !os.IsNotExist(err) { + t.Errorf("expected IsNotExist error, got: %v", err) + } +} + +func TestParseProcfile(t *testing.T) { + input := `web: run-web +worker:run-worker +invalid line +` + r := strings.NewReader(input) + svcs, err := ParseProcfile(r) + if err == nil { + t.Fatal("expected error on invalid line, got nil") + } + // Valid input + input = `web: run-web +worker: run-worker +` + r = strings.NewReader(input) + svcs, err = ParseProcfile(r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(svcs) != 2 { + t.Fatalf("expected 2 services, got %d", len(svcs)) + } + if svcs[0].Name != "web" || svcs[0].Cmd != "run-web" { + t.Errorf("unexpected svc[0]: %v", svcs[0]) + } +} + +func TestParseHealth(t *testing.T) { + input := `svc1: + url: http://localhost + codes: [200,201] + interval_seconds: 5 +svc2: + url: http://example.com + codes: [500] +` + r := strings.NewReader(input) + hc, err := ParseHealth(r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(hc) != 2 { + t.Fatalf("expected 2 entries, got %d", len(hc)) + } + e1 := hc["svc1"] + if e1.URL != "http://localhost" { + t.Errorf("svc1 URL: %s", e1.URL) + } + if len(e1.Codes) != 2 || e1.Codes[0] != 200 || e1.Codes[1] != 201 { + t.Errorf("svc1 Codes: %v", e1.Codes) + } + if e1.IntervalSeconds != 5 { + t.Errorf("svc1 Interval: %d", e1.IntervalSeconds) + } + e2 := hc["svc2"] + if e2.URL != "http://example.com" || len(e2.Codes) != 1 || e2.Codes[0] != 500 { + t.Errorf("svc2 entry: %+v", e2) + } +} + diff --git a/utilities/devctl/app/contexts/contexts.go b/utilities/devctl/app/contexts/contexts.go new file mode 100644 index 0000000000..a149271a80 --- /dev/null +++ b/utilities/devctl/app/contexts/contexts.go @@ -0,0 +1,23 @@ +package contexts + +import ( + "context" + "os" + "os/signal" + "syscall" +) + +// WithSignalCancel returns a context that is canceled on SIGINT or SIGTERM. +func WithSignalCancel(parent context.Context) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(parent) + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, os.Interrupt, syscall.SIGINT) + go func() { + select { + case <-sigs: + cancel() + case <-ctx.Done(): + } + }() + return ctx, cancel +} diff --git a/utilities/devctl/app/contexts/contexts_test.go b/utilities/devctl/app/contexts/contexts_test.go new file mode 100644 index 0000000000..88063dcb5e --- /dev/null +++ b/utilities/devctl/app/contexts/contexts_test.go @@ -0,0 +1,19 @@ +package contexts + +import ( + "context" + "testing" +) + +// Test that the returned cancel function cancels the context +func TestWithSignalCancel_Cancel(t *testing.T) { + parent := context.Background() + ctx, cancel := WithSignalCancel(parent) + cancel() + select { + case <-ctx.Done(): + // expected + default: + t.Error("expected context to be cancelled after cancel()") + } +} \ No newline at end of file diff --git a/utilities/devctl/app/health/health.go b/utilities/devctl/app/health/health.go new file mode 100644 index 0000000000..5e12a2cb89 --- /dev/null +++ b/utilities/devctl/app/health/health.go @@ -0,0 +1,35 @@ +package health + +import ( + "net/http" +) + +// HTTPClient defines the interface for making HTTP GET requests. +type HTTPClient interface { + Get(url string) (*http.Response, error) +} + +// CheckStatus performs a GET request to the given URL and checks if the response status code +// is in the provided list of acceptable codes. It returns a boolean indicating success, +// the actual status code, and any error encountered. +func CheckStatus(client HTTPClient, url string, codes []int) (bool, int, error) { + resp, err := client.Get(url) + if err != nil { + return false, 0, err + } + defer resp.Body.Close() + code := resp.StatusCode + for _, c := range codes { + if c == code { + return true, code, nil + } + } + return false, code, nil +} + +// DefaultHealthInterval is the default interval (in seconds) between health check attempts. +const DefaultHealthInterval = 2 + +// DefaultHealthTimeout is the default timeout (in seconds) for health checks. +const DefaultHealthTimeout = 30 + diff --git a/utilities/devctl/app/health/health_test.go b/utilities/devctl/app/health/health_test.go new file mode 100644 index 0000000000..2c26ff8b84 --- /dev/null +++ b/utilities/devctl/app/health/health_test.go @@ -0,0 +1,64 @@ +package health + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" +) + +// fakeClient implements HTTPClient for testing. +type fakeClient struct { + resp *http.Response + err error +} + +// Get returns a preset response or error. +func (f *fakeClient) Get(url string) (*http.Response, error) { + return f.resp, f.err +} + +func TestCheckStatus_Success(t *testing.T) { + // Create a fake response with status 200 + resp := &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader("")), + } + client := &fakeClient{resp: resp, err: nil} + ok, code, err := CheckStatus(client, "http://example.com", []int{200, 201}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok || code != 200 { + t.Errorf("expected ok=true and code=200, got ok=%v code=%d", ok, code) + } +} + +func TestCheckStatus_NonMatchingCode(t *testing.T) { + resp := &http.Response{ + StatusCode: 404, + Body: io.NopCloser(strings.NewReader("")), + } + client := &fakeClient{resp: resp, err: nil} + ok, code, err := CheckStatus(client, "http://example.com", []int{200, 201}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || code != 404 { + t.Errorf("expected ok=false and code=404, got ok=%v code=%d", ok, code) + } +} + +func TestCheckStatus_Error(t *testing.T) { + clientErr := errors.New("network error") + client := &fakeClient{resp: nil, err: clientErr} + ok, code, err := CheckStatus(client, "http://example.com", []int{200}) + if err == nil { + t.Fatal("expected error, got nil") + } + if ok || code != 0 { + t.Errorf("expected ok=false and code=0 on error, got ok=%v code=%d", ok, code) + } +} + diff --git a/utilities/devctl/app/runner/runner.go b/utilities/devctl/app/runner/runner.go new file mode 100644 index 0000000000..bc7333e5a8 --- /dev/null +++ b/utilities/devctl/app/runner/runner.go @@ -0,0 +1,204 @@ +package runner + +import ( + "context" + "fmt" + "net/http" + "os" + "sync" + "time" + + "devctl/app/colors" + "devctl/app/config" + "devctl/app/health" + "devctl/app/service" + + "github.com/charmbracelet/lipgloss" +) + +// default health check settings (seconds) +const ( + defaultHealthInterval = 2 + defaultHealthTimeout = 30 +) + +// Options configures a Runner. +type Options struct { + ConfigDir string + Mode string + Focus, Mute string + Colors []string + DefaultHealthInterval int + DefaultHealthTimeout int + HTTPClient health.HTTPClient +} + +// Runner orchestrates services and health checks. +type Runner struct { + opts Options +} + +// NewRunner creates a Runner with provided options, filling defaults. +func New(opts Options) *Runner { + if len(opts.Colors) == 0 { + opts.Colors = colors.ServiceColors + } + if opts.DefaultHealthInterval <= 0 { + opts.DefaultHealthInterval = defaultHealthInterval + } + if opts.DefaultHealthTimeout <= 0 { + opts.DefaultHealthTimeout = defaultHealthTimeout + } + if opts.HTTPClient == nil { + opts.HTTPClient = http.DefaultClient + } + return &Runner{opts: opts} +} + +// Run executes the environment setup, starts services, performs health checks, and waits. +func (r *Runner) Run(ctx context.Context) error { + // 1. Load environment variables from file and set in process environment + // - Attempt to read env file at ConfigDir for the given Mode + // - If file not found, warn and continue; on other errors, abort + // - On success, set each key/value in OS environment + if envMap, err := config.LoadEnvFile(r.opts.ConfigDir, r.opts.Mode); err != nil { + if os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Warning: env file not found, continuing without it\n") + } else { + return fmt.Errorf("loading env file: %w", err) + } + } else { + for k, v := range envMap { + os.Setenv(k, v) + } + } + + // 2. Load health check configuration + // - Read health entries from file under ConfigDir for Mode + // - If missing, warn and disable health checks; on other errors, abort + hcMap, err := config.LoadHealthFile(r.opts.ConfigDir, r.opts.Mode) + doHealth := true + if err != nil { + if os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Warning: health file not found, skipping health checks\n") + doHealth = false + } else { + return fmt.Errorf("loading health file: %w", err) + } + } + + // 3. Load service definitions from Procfile + // - Parse service commands and names; abort on error + svcs, err := config.LoadProcfileFile(r.opts.ConfigDir, r.opts.Mode) + if err != nil { + return fmt.Errorf("loading procfile: %w", err) + } + + // 4. Start all services concurrently + // - Launch each service as a subprocess + // - Stream stdout/stderr with coloring and filtering (focus/mute) + var svcWg sync.WaitGroup + svcWg.Add(len(svcs)) + for i, svc := range svcs { + color := r.opts.Colors[i%len(r.opts.Colors)] + go func(s config.ServiceConfig, color string) { + defer svcWg.Done() + r.startService(ctx, s, color) + }(svc, color) + } + + // 5. Perform health checks for services with entries + // - For each service with a health config, poll its URL until healthy or timeout + // - Wait for all health check goroutines to finish + if doHealth { + var hcWg sync.WaitGroup + for i, svc := range svcs { + if entry, ok := hcMap[svc.Name]; ok { + color := r.opts.Colors[i%len(r.opts.Colors)] + hcWg.Add(1) + go func(name string, entry config.HealthEntry, color string) { + defer hcWg.Done() + r.startHealth(ctx, name, entry, color) + }(svc.Name, entry, color) + } + } + hcWg.Wait() + } + + // 6. Wait for all service processes to exit before returning + svcWg.Wait() + return nil +} + +func serviceTextHandler(svc config.ServiceConfig, color string) func(string) { + // Prepare a lipgloss style for this service + style := lipgloss.NewStyle().Foreground(lipgloss.Color(color)) + // Prefix each line with styled [service] + return func(text string) { + fmt.Println(style.Render("["+svc.Name+"]") + " " + text) + } +} + +// startService starts a service process and streams its output. +// - Launches the command as a subprocess tied to the provided context (cancellable). +// - Attaches to both stdout and stderr pipes. +// - Processes each output line with service.ProcessStream (applying focus/mute filters). +// - Prints each line prefixed with the service name and colored output. +// - Waits for all output to be drained and the process to exit. +func (r *Runner) startService(ctx context.Context, svc config.ServiceConfig, color string) { + textHandler := serviceTextHandler(svc, color) + + err := service. + New(). + SetConfig(svc). + SetStdOutCallback(textHandler). + SetStdErrCallback(textHandler). + SetFocus(r.opts.Focus). + SetMute(r.opts.Mute). + Start(ctx) + + if err != nil { + fmt.Fprintf(os.Stderr, "Error for %s: %v\n", svc.Name, err) + } +} + +// startHealth performs health checks for a service until success, timeout, or context done. +// - Reads or defaults the polling interval and timeout duration. +// - Repeatedly invokes health.CheckStatus against the configured URL and expected codes. +// - On first successful status, prints a success message and returns. +// - If the timeout duration elapses, prints a failure message and returns. +// - If the context is canceled, prints an aborted message and returns immediately. +func (r *Runner) startHealth(ctx context.Context, svcName string, entry config.HealthEntry, color string) { + interval := entry.IntervalSeconds + if interval <= 0 { + interval = r.opts.DefaultHealthInterval + } + timeout := entry.TimeoutSeconds + if timeout <= 0 { + timeout = r.opts.DefaultHealthTimeout + } + start := time.Now() + // Prepare a lipgloss style for health messages + style := lipgloss.NewStyle().Foreground(lipgloss.Color(color)) + for { + select { + case <-ctx.Done(): + // Context canceled: abort health check + fmt.Printf("%s aborted\n", style.Render(fmt.Sprintf("[health][%s]", svcName))) + return + default: + } + ok, code, err := health.CheckStatus(r.opts.HTTPClient, entry.URL, entry.Codes) + if err == nil && ok { + // Success + fmt.Printf("%s success (%d)\n", style.Render(fmt.Sprintf("[health][%s]", svcName)), code) + return + } + if time.Since(start) > time.Duration(timeout)*time.Second { + // Timeout + fmt.Printf("%s failure (timeout)\n", style.Render(fmt.Sprintf("[health][%s]", svcName))) + return + } + time.Sleep(time.Duration(interval) * time.Second) + } +} diff --git a/utilities/devctl/app/runner/runner_test.go b/utilities/devctl/app/runner/runner_test.go new file mode 100644 index 0000000000..0a5cf165a8 --- /dev/null +++ b/utilities/devctl/app/runner/runner_test.go @@ -0,0 +1,107 @@ +package runner + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// captureStderr redirects os.Stderr for the duration of f and returns the captured output. +func captureStderr(f func()) string { + // Redirect stderr to a pipe + orig := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + // Run the function + f() + // Restore stderr and close writer to unblock reader + w.Close() + os.Stderr = orig + // Read any output + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() +} + +// TestRun_Warnings verifies that missing env and health files emit warnings but do not abort. +func TestRun_Warnings(t *testing.T) { + dir := t.TempDir() + // Create a minimal Procfile.test so Run will proceed + proc := "svc: true\n" + f := filepath.Join(dir, "Procfile.test") + if err := os.WriteFile(f, []byte(proc), 0644); err != nil { + t.Fatalf("writing Procfile: %v", err) + } + r := New(Options{ConfigDir: dir, Mode: "test"}) + stderr := captureStderr(func() { + if err := r.Run(context.Background()); err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + if !strings.Contains(stderr, "Warning: env file not found") { + t.Errorf("expected env warning, got %q", stderr) + } + if !strings.Contains(stderr, "Warning: health file not found") { + t.Errorf("expected health warning, got %q", stderr) + } +} + +// TestRun_SetsEnvVars verifies that a valid .env. file sets environment variables. +func TestRun_SetsEnvVars(t *testing.T) { + dir := t.TempDir() + // Write env file + envContent := "FOO=bar\nBAZ=qux\n" + if err := os.WriteFile(filepath.Join(dir, ".env.test"), []byte(envContent), 0644); err != nil { + t.Fatalf("writing env file: %v", err) + } + // Write Procfile.test + proc := "svc: true\n" + if err := os.WriteFile(filepath.Join(dir, "Procfile.test"), []byte(proc), 0644); err != nil { + t.Fatalf("writing Procfile: %v", err) + } + // Ensure vars are unset before Run + os.Unsetenv("FOO") + os.Unsetenv("BAZ") + r := New(Options{ConfigDir: dir, Mode: "test"}) + if err := r.Run(context.Background()); err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got := os.Getenv("FOO"); got != "bar" { + t.Errorf("FOO: expected 'bar', got %q", got) + } + if got := os.Getenv("BAZ"); got != "qux" { + t.Errorf("BAZ: expected 'qux', got %q", got) + } +} + +// TestRun_ErrorOnBadEnvFile verifies that a malformed env file causes Run to abort. +func TestRun_ErrorOnBadEnvFile(t *testing.T) { + dir := t.TempDir() + // Write bad env file + if err := os.WriteFile(filepath.Join(dir, ".env.test"), []byte("BADLINE"), 0644); err != nil { + t.Fatalf("writing bad env file: %v", err) + } + // Minimal Procfile + if err := os.WriteFile(filepath.Join(dir, "Procfile.test"), []byte("svc: true"), 0644); err != nil { + t.Fatalf("writing Procfile: %v", err) + } + r := New(Options{ConfigDir: dir, Mode: "test"}) + err := r.Run(context.Background()) + if err == nil || !strings.Contains(err.Error(), "loading env file") { + t.Errorf("expected loading env file error, got %v", err) + } +} + +// TestRun_ErrorOnMissingProcfile verifies that missing Procfile aborts Run with an error. +func TestRun_ErrorOnMissingProcfile(t *testing.T) { + dir := t.TempDir() + r := New(Options{ConfigDir: dir, Mode: "test"}) + err := r.Run(context.Background()) + if err == nil || !strings.Contains(err.Error(), "loading procfile") { + t.Errorf("expected loading procfile error, got %v", err) + } +} \ No newline at end of file diff --git a/utilities/devctl/app/service/handler_test.go b/utilities/devctl/app/service/handler_test.go new file mode 100644 index 0000000000..89d31415eb --- /dev/null +++ b/utilities/devctl/app/service/handler_test.go @@ -0,0 +1,69 @@ +package service + +import ( + "context" + "testing" + "devctl/app/config" +) + +// TestStart_Success verifies that stdout/stderr callbacks receive lines and statuses sequence on success. +func TestStart_Success(t *testing.T) { + ctx := context.Background() + var statuses []string + var outLines []string + var errLines []string + // Command: print to stdout and stderr + cmd := "printf 'out1\nout2\n'; printf 'err1\n' >&2" + h := New(). + SetConfig(config.ServiceConfig{Name: "svc", Cmd: cmd}). + SetStdOutCallback(func(line string) { outLines = append(outLines, line) }). + SetStdErrCallback(func(line string) { errLines = append(errLines, line) }). + SetStatusCallback(func(s string) { statuses = append(statuses, s) }) + err := h.Start(ctx) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + // Verify stdout lines + if len(outLines) != 2 || outLines[0] != "out1" || outLines[1] != "out2" { + t.Errorf("unexpected stdout lines: %v", outLines) + } + // Verify stderr lines + if len(errLines) != 1 || errLines[0] != "err1" { + t.Errorf("unexpected stderr lines: %v", errLines) + } + // Verify status callbacks + want := []string{"Starting", "Running", "Exited"} + if len(statuses) != len(want) { + t.Fatalf("expected statuses %v, got %v", want, statuses) + } + for i, s := range want { + if statuses[i] != s { + t.Errorf("status[%d]: expected %q, got %q", i, s, statuses[i]) + } + } +} + +// TestStart_Failure verifies that a non-zero exit code triggers a Crashed status and returns error. +func TestStart_Failure(t *testing.T) { + ctx := context.Background() + var statuses []string + // Command exits with code 1 + cmd := "exit 1" + h := New(). + SetConfig(config.ServiceConfig{Name: "svc", Cmd: cmd}). + SetStatusCallback(func(s string) { statuses = append(statuses, s) }) + err := h.Start(ctx) + if err == nil { + t.Fatal("expected error on non-zero exit, got nil") + } + // Expect Starting -> Running -> Crashed + want := []string{"Starting", "Running", "Crashed"} + if len(statuses) != len(want) { + t.Fatalf("expected statuses %v, got %v", want, statuses) + } + for i, s := range want { + if statuses[i] != s { + t.Errorf("status[%d]: expected %q, got %q", i, s, statuses[i]) + } + } +} \ No newline at end of file diff --git a/utilities/devctl/app/service/service.go b/utilities/devctl/app/service/service.go new file mode 100644 index 0000000000..646130c049 --- /dev/null +++ b/utilities/devctl/app/service/service.go @@ -0,0 +1,166 @@ +package service + +import ( + "bufio" + "context" + "devctl/app/config" + "io" + "os/exec" + "sync" + "syscall" + + "github.com/pkg/errors" +) + +var Statuses = map[string]string{ + "Pending": "Pending", + "Starting": "Starting", + "Restarting": "Restarting", + "Running": "Running", + "Stopping": "Stopping", + "Error": "Error", + "Crashed": "Crashed", + "Exited": "Exited", + "Healthy": "Healthy", + "Unhealthy": "Unhealthy", +} + +func New() *Handler { + return &Handler{} +} + +type Handler struct { + svc config.ServiceConfig + + focus string + mute string + + stdoutCB func(string) + stderrCB func(string) + statusCB func(string) +} + +func (h *Handler) SetConfig(svc config.ServiceConfig) *Handler { + h.svc = svc + return h +} + +func (h *Handler) SetStdOutCallback(cb func(string)) *Handler { + h.stdoutCB = cb + return h +} + +func (h *Handler) SetStdErrCallback(cb func(string)) *Handler { + h.stderrCB = cb + return h +} + +func (h *Handler) SetFocus(focus string) *Handler { + h.focus = focus + return h +} + +func (h *Handler) SetMute(mute string) *Handler { + h.mute = mute + return h +} + +func (h *Handler) SetStatusCallback(cb func(string)) *Handler { + h.statusCB = cb + return h +} + +func (h *Handler) sendStatus(status string) { + if h.statusCB == nil { + return + } + + h.statusCB(status) +} + +func (h *Handler) Start(ctx context.Context) error { + h.sendStatus("Starting") + cmd := exec.CommandContext(ctx, "sh", "-c", h.svc.Cmd) + // set process group ID so we can kill the entire process group on cancel + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + stdout, err := cmd.StdoutPipe() + if err != nil { + h.sendStatus("Error") + return errors.Wrap(err, "failed to attach stdout") + } + + stderr, err := cmd.StderrPipe() + if err != nil { + h.sendStatus("Error") + return errors.Wrap(err, "failed to attach stderr") + } + + if err := cmd.Start(); err != nil { + h.sendStatus("Crashed") + return errors.Wrap(err, "failed to start command") + } + + h.sendStatus("Running") + // kill the process group on context cancellation + go func() { + <-ctx.Done() + if cmd.Process != nil { + if pgid, err := syscall.Getpgid(cmd.Process.Pid); err == nil { + syscall.Kill(-pgid, syscall.SIGKILL) + } else { + _ = cmd.Process.Kill() + } + } + }() + + if err := h.processStreams(stdout, stderr); err != nil { + return errors.Wrap(err, "failed to process streams") + } + + if err := cmd.Wait(); err != nil { + h.sendStatus("Crashed") + return errors.Wrap(err, "command exited with error") + } + + h.sendStatus("Exited") + + return nil +} + +func (h *Handler) processStreams(stdout, stderr io.Reader) error { + var outWg sync.WaitGroup + outWg.Add(2) + + go func() { + defer outWg.Done() + h.processStream(stdout, h.stdoutCB) + }() + + go func() { + defer outWg.Done() + h.processStream(stderr, h.stderrCB) + }() + + outWg.Wait() + + // Implement stop logic if needed + return nil +} + +// processStream reads lines from the provided reader and applies filtering based on focus and mute. +// For each line that passes the filters, it calls handleLine with the line text. +func (h *Handler) processStream(r io.Reader, handleLine func(string)) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + text := scanner.Text() + if h.focus != "" && h.focus != h.svc.Name { + continue + } + if h.mute != "" && h.mute == h.svc.Name { + continue + } + + handleLine(text) + } +} diff --git a/utilities/devctl/app/service/service_test.go b/utilities/devctl/app/service/service_test.go new file mode 100644 index 0000000000..243634c500 --- /dev/null +++ b/utilities/devctl/app/service/service_test.go @@ -0,0 +1,53 @@ +package service + +import ( + "io" + "strings" + "testing" + "devctl/app/config" +) + +// ProcessStream is a helper that wraps Handler.processStream to apply focus and mute filters. +func ProcessStream(r io.Reader, svcName, focus, mute string, cb func(string)) { + h := New().SetConfig(config.ServiceConfig{Name: svcName}) + h.SetFocus(focus) + h.SetMute(mute) + h.processStream(r, cb) +} + +func TestProcessStream_AllLines(t *testing.T) { + data := "line1\nline2\nline3\n" + r := strings.NewReader(data) + var lines []string + ProcessStream(r, "svc", "", "", func(line string) { + lines = append(lines, line) + }) + if len(lines) != 3 { + t.Errorf("expected 3 lines, got %d", len(lines)) + } +} + +func TestProcessStream_FocusFiltering(t *testing.T) { + data := "a\nb\nc\n" + r := strings.NewReader(data) + var lines []string + ProcessStream(r, "svc", "other", "", func(line string) { + lines = append(lines, line) + }) + if len(lines) != 0 { + t.Errorf("expected 0 lines for focus mismatch, got %d", len(lines)) + } +} + +func TestProcessStream_MuteFiltering(t *testing.T) { + data := "x\ny\nz\n" + r := strings.NewReader(data) + var lines []string + ProcessStream(r, "svc", "", "svc", func(line string) { + lines = append(lines, line) + }) + if len(lines) != 0 { + t.Errorf("expected 0 lines for mute match, got %d", len(lines)) + } +} + diff --git a/utilities/devctl/app/tui/keys.go b/utilities/devctl/app/tui/keys.go new file mode 100644 index 0000000000..d52e46a822 --- /dev/null +++ b/utilities/devctl/app/tui/keys.go @@ -0,0 +1,61 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +// keyMap defines a set of keybindings. To work for help it must satisfy +// key.Map. It could also very easily be a map[string]key.Binding. +type keyMap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Tab key.Binding + Help key.Binding + Quit key.Binding +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Left, k.Right, k.Tab, k.Help, k.Quit} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Left, k.Right}, + {k.Tab, k.Help, k.Quit}, + } +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "move up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "move down"), + ), + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "scroll left"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "scroll right"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch view"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "toggle help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "esc", "ctrl+c"), + key.WithHelp("q", "quit"), + ), +} diff --git a/utilities/devctl/app/tui/model.go b/utilities/devctl/app/tui/model.go new file mode 100644 index 0000000000..1c0b5f9b8d --- /dev/null +++ b/utilities/devctl/app/tui/model.go @@ -0,0 +1,291 @@ +package tui + +import ( + "fmt" + "strings" + + "devctl/app/colors" + "devctl/app/config" + "devctl/app/service" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + sidebarWidth = 24 + + sidebarStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + Width(sidebarWidth).Align(lipgloss.Top) + + sidebarFocusedStyle = sidebarStyle. + BorderForeground(lipgloss.Color("62")) // blue border when focused + + contentStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")).Align(lipgloss.Top) + + contentFocusedStyle = contentStyle. + BorderForeground(lipgloss.Color("62")) // blue border when focused + + selectedStyle = lipgloss.NewStyle(). + Bold(true). + Background(lipgloss.Color(colors.SelectedBackground)). + Foreground(lipgloss.Color(colors.SelectedForeground)) + + pendingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colors.Pending)) + runningStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colors.Running)) + crashedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colors.Crashed)) + exitedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colors.Exited)) + healthyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colors.Healthy)) + unhealthyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(colors.Unhealthy)) +) + +type model struct { + keys keyMap + help help.Model + + services []config.ServiceConfig + logs map[string][]string + statuses map[string]string + + selected int + sidebar viewport.Model + content viewport.Model + width int + height int + + viewFocus string // "sidebar" or "content" + svcFocus string + svcMute string +} + +func NewModel( + services []config.ServiceConfig, + hcMap map[string]config.HealthEntry, + focus, mute string, +) *model { + side := viewport.New(0, 0) + main := viewport.New(0, 0) + main.SetHorizontalStep(1) + + logs := make(map[string][]string) + statuses := make(map[string]string) + + for _, svc := range services { + statuses[svc.Name] = "Pending" + logs[svc.Name] = []string{} + } + + m := &model{ + keys: keys, + help: help.New(), + + services: services, + logs: logs, + statuses: statuses, + + sidebar: side, + content: main, + svcFocus: focus, + svcMute: mute, + viewFocus: "sidebar", + } + + side.SetContent(m.sidebarContent()) + main.SetContent("") + + return m +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case LogMsg: + // filters + if m.svcFocus != "" && msg.Service != m.svcFocus { + return m, nil + } + if m.svcMute != "" && msg.Service == m.svcMute { + return m, nil + } + // append log and keep last N + lines := m.logs[msg.Service] + lines = append(lines, msg.Line) + m.logs[msg.Service] = lines + // if for selected service, update viewport + if msg.Service == m.services[m.selected].Name { + m.content.SetContent(strings.Join(lines, "\n")) + m.content.GotoBottom() + } + return m, nil + + case StatusMsg: + if m.svcFocus != "" && msg.Service != m.svcFocus { + return m, nil + } + if m.svcMute != "" && msg.Service == m.svcMute { + return m, nil + } + m.statuses[msg.Service] = msg.Status + m.sidebar.SetContent(m.sidebarContent()) + return m, nil + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + m.sidebar.Width = sidebarWidth + m.sidebar.Height = m.height - 3 + + m.content.Width = m.width - lipgloss.Width(m.sidebar.View()) - 4 + m.content.Height = m.height - 3 + + case tea.KeyMsg: + switch { + + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + + case key.Matches(msg, m.keys.Up): + if m.viewFocus == "sidebar" { + if m.selected > 0 { + m.selected-- + } + // load new logs + svc := m.services[m.selected].Name + m.content.SetContent(strings.Join(m.logs[svc], "\n")) + m.content.GotoBottom() + m.sidebar.SetContent(m.sidebarContent()) + return m, nil + } + + case key.Matches(msg, m.keys.Down): + if m.viewFocus == "sidebar" { + if m.selected < len(m.services)-1 { + m.selected++ + } + svc := m.services[m.selected].Name + m.content.SetContent(strings.Join(m.logs[svc], "\n")) + m.content.GotoBottom() + m.sidebar.SetContent(m.sidebarContent()) + return m, nil + } + + case key.Matches(msg, m.keys.Left): + m.content.ScrollLeft(m.content.Width) + + case key.Matches(msg, m.keys.Right): + m.content.ScrollRight(m.content.Width) + + case key.Matches(msg, m.keys.Tab): + if m.viewFocus == "sidebar" { + m.viewFocus = "content" + } else { + m.viewFocus = "sidebar" + } + } + } + + // Update focused viewport + if m.viewFocus == "sidebar" { + var cmd tea.Cmd + m.sidebar, cmd = m.sidebar.Update(msg) + cmds = append(cmds, cmd) + } else { + var cmd tea.Cmd + m.content, cmd = m.content.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m model) View() string { + side := sidebarStyle.Render(m.sidebar.View()) + content := contentStyle.Render(m.content.View()) + + // Highlight focused section + if m.viewFocus == "sidebar" { + side = sidebarFocusedStyle.Render(m.sidebar.View()) + } else { + content = contentFocusedStyle.Render(m.content.View()) + } + + page := lipgloss.JoinHorizontal( + lipgloss.Top, + side, + content, + ) + page += "\n" + m.help.View(m.keys) + + return page +} + +func (m *model) sidebarContent() string { + content := "" + + for i, svc := range m.services { + prefix := " " + if i == m.selected { + prefix = "> " + } + status := styleStatus(m.statuses[svc.Name]) + text := fmt.Sprintf("%s%s [%s]", prefix, svc.Name, status) + if i == m.selected { + text = selectedStyle.Render(text) + } + + content += text + "\n" + } + + return content +} + +// Get current "selected" line based on sidebar scroll position +func currentSidebarLine(m *model) string { + lines := strings.Split(m.sidebarContent(), "\n") + m.selected = m.selected + 1 + + if (len(lines) - 1) < m.selected { + m.selected = 0 + return "Home" + } + + return lines[m.selected] +} + +// styleStatus colors a service status string. +func styleStatus(status string) string { + var s lipgloss.Style + switch status { + case service.Statuses["Pending"]: + s = pendingStyle + case service.Statuses["Running"], service.Statuses["Starting"]: + s = runningStyle + case service.Statuses["Healthy"]: + s = healthyStyle + case service.Statuses["Unhealthy"], service.Statuses["Restarting"], service.Statuses["Stopping"]: + s = crashedStyle + case service.Statuses["Exited"]: + s = exitedStyle + default: + s = pendingStyle + } + return s.Render(status) +} diff --git a/utilities/devctl/app/tui/model_test.go b/utilities/devctl/app/tui/model_test.go new file mode 100644 index 0000000000..30e2b91207 --- /dev/null +++ b/utilities/devctl/app/tui/model_test.go @@ -0,0 +1,167 @@ +package tui + +import ( + "reflect" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "devctl/app/config" + "devctl/app/service" +) + +// TestStyleStatus ensures styleStatus renders the status text. +func TestStyleStatus(t *testing.T) { + for _, status := range service.Statuses { + out := styleStatus(status) + if !strings.Contains(out, status) { + t.Errorf("styled status %q missing in output %q", status, out) + } + } +} + +// TestNewModelWithFocus sets initial selection based on focus flag. +func TestNewModelWithFocus(t *testing.T) { + services := []config.ServiceConfig{{Name: "a"}, {Name: "b"}, {Name: "c"}} + // NewModel returns *model; svcFocus should be set, selection remains default 0 + mod := NewModel(services, nil, "b", "") + if mod.svcFocus != "b" { + t.Errorf("svcFocus: expected %q, got %q", "b", mod.svcFocus) + } + if mod.selected != 0 { + t.Errorf("selected: expected default 0, got %d", mod.selected) + } +} + +// TestUpdateFocusFiltering ignores logs for services outside focus. +func TestUpdateFocusFiltering(t *testing.T) { + services := []config.ServiceConfig{{Name: "a"}, {Name: "b"}} + // focus on "a" + var m tea.Model = NewModel(services, nil, "a", "") + // Log for b should be ignored + updated, _ := m.Update(LogMsg{Service: "b", Line: "ignored"}) + mod := updated.(*model) + if len(mod.logs["b"]) != 0 { + t.Errorf("focus filter: expected 0 logs for b, got %d", len(mod.logs["b"])) + } + // Log for a should be recorded + updated, _ = updated.Update(LogMsg{Service: "a", Line: "ok"}) + mod = updated.(*model) + if len(mod.logs["a"]) != 1 { + t.Errorf("focus filter: expected 1 log for a, got %d", len(mod.logs["a"])) + } +} + +// TestUpdateMuteFiltering ignores logs and status updates for muted service. +func TestUpdateMuteFiltering(t *testing.T) { + services := []config.ServiceConfig{{Name: "a"}, {Name: "b"}} + // mute "b" + var m tea.Model = NewModel(services, nil, "", "b") + // Log for b should be ignored + updated, _ := m.Update(LogMsg{Service: "b", Line: "ignored"}) + mod := updated.(*model) + if len(mod.logs["b"]) != 0 { + t.Errorf("mute filter: expected 0 logs for b, got %d", len(mod.logs["b"])) + } + // Status for b should be ignored + initial := mod.statuses["b"] + updated, _ = updated.Update(StatusMsg{Service: "b", Status: service.Statuses["Running"]}) + mod = updated.(*model) + if mod.statuses["b"] != initial { + t.Errorf("mute filter: expected status unchanged %q, got %q", initial, mod.statuses["b"]) + } +} + +// TestNewModel initializes model and checks default state. +func TestNewModel(t *testing.T) { + services := []config.ServiceConfig{{Name: "svc1"}, {Name: "svc2"}} + hcMap := map[string]config.HealthEntry{"svc1": {URL: "u", Codes: []int{200}}} + mod := NewModel(services, hcMap, "", "") + // services list should match + if !reflect.DeepEqual(mod.services, services) { + t.Errorf("services mismatch: expected %v, got %v", services, mod.services) + } + // statuses should initialize to Pending + for _, svc := range services { + if mod.statuses[svc.Name] != service.Statuses["Pending"] { + t.Errorf("expected status Pending for %s, got %s", svc.Name, mod.statuses[svc.Name]) + } + } + // logs should start empty + for _, svc := range services { + if len(mod.logs[svc.Name]) != 0 { + t.Errorf("expected no logs for %s, got %d", svc.Name, len(mod.logs[svc.Name])) + } + } +} + +// TestUpdate_LogMsg appends log lines and records them all. +func TestUpdate_LogMsg(t *testing.T) { + services := []config.ServiceConfig{{Name: "s"}} + // start with fresh model + var m tea.Model = NewModel(services, nil, "", "") + const total = 10 + // append several log lines + for i := 0; i < total; i++ { + m, _ = m.Update(LogMsg{Service: "s", Line: strings.Repeat("x", 1)}) + } + mod := m.(*model) + logs := mod.logs["s"] + if len(logs) != total { + t.Errorf("expected %d logs, got %d", total, len(logs)) + } +} + +// TestUpdate_StatusMsg updates service status. +func TestUpdate_StatusMsg(t *testing.T) { + services := []config.ServiceConfig{{Name: "s"}} + var m tea.Model = NewModel(services, nil, "", "") + updated, _ := m.Update(StatusMsg{Service: "s", Status: service.Statuses["Running"]}) + mod := updated.(*model) + if mod.statuses["s"] != service.Statuses["Running"] { + t.Errorf("expected status Running, got %s", mod.statuses["s"]) + } +} + +// TestUpdate_KeyMsg navigates selection and quits. +func TestUpdate_KeyMsg(t *testing.T) { + services := []config.ServiceConfig{{Name: "a"}, {Name: "b"}} + var m tea.Model = NewModel(services, nil, "", "") + // move down (j) + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + mod := updated.(*model) + if mod.selected != 1 { + t.Errorf("expected selected 1 after down key, got %d", mod.selected) + } + // move up (k) + updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + mod = updated.(*model) + if mod.selected != 0 { + t.Errorf("expected selected 0 after up key, got %d", mod.selected) + } + // quit (q) + _, cmd = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + if cmd == nil { + t.Fatal("expected non-nil cmd for quit") + } + // executing cmd should return tea.Quit() + msg := cmd() + if msg != tea.Quit() { + t.Errorf("expected Quit() message, got %v", msg) + } +} + +// TestView includes service names and logs for selected. +func TestView(t *testing.T) { + services := []config.ServiceConfig{{Name: "x"}} + // ensure View() returns help text without error + var m tea.Model = NewModel(services, nil, "", "") + v := m.View() + if v == "" { + t.Error("expected non-empty view output") + } + if !strings.Contains(v, "quit") { + t.Errorf("expected help text in view, got %q", v) + } +} \ No newline at end of file diff --git a/utilities/devctl/app/tui/run.go b/utilities/devctl/app/tui/run.go new file mode 100644 index 0000000000..5e3af5b4e9 --- /dev/null +++ b/utilities/devctl/app/tui/run.go @@ -0,0 +1,137 @@ +package tui + +import ( + "context" + "devctl/app/config" + "devctl/app/health" + "devctl/app/service" + "fmt" + "net/http" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// LogMsg carries a single log line from a service. +type LogMsg struct { + Service string + Line string +} + +// StatusMsg updates the status of a service. +type StatusMsg struct { + Service string + Status string +} + +func serviceTextHandler(svcName string, p *tea.Program) func(string) { + return func(line string) { + p.Send(LogMsg{Service: svcName, Line: line}) + } +} + +func statusCallbackHandler(svcName string, p *tea.Program) func(string) { + return func(status string) { + p.Send(StatusMsg{Service: svcName, Status: status}) + } +} + +// Run initializes and runs the interactive TUI, orchestrating service processes and health checks. +// +// 1. Load services and health entries +// 2. Initialize Bubble Tea model and program +// 3. Setup cancellation context for subprocesses +// 4. Launch each service in its own goroutine: +// - Send status updates (starting, running, crashed, exited) +// - Stream stdout/stderr as log messages +// +// 5. Launch health check goroutines: +// - Poll URLs until healthy or timeout, sending status updates +// +// 6. Start the TUI event loop (blocking) +func Run(configDir, mode, focus, mute string) error { + // 1. Load service definitions from Procfile + services, err := config.LoadProcfileFile(configDir, mode) + if err != nil { + return fmt.Errorf("error loading Procfile: %w", err) + } + // Load health check entries (ignore errors, missing file means no health checks) + hcMap, _ := config.LoadHealthFile(configDir, mode) + + // 2. Initialize the TUI model and Bubble Tea program + // Initialize TUI model and program; use alternate screen for full redraw + model := NewModel(services, hcMap, focus, mute) + p := tea.NewProgram(model, tea.WithAltScreen()) + + // 3. Setup cancellation context for subprocesses + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 4. Launch each service process and stream its output to the TUI + var svcWg sync.WaitGroup + svcWg.Add(len(services)) + + for _, svc := range services { + textHandler := serviceTextHandler(svc.Name, p) + statusHandler := statusCallbackHandler(svc.Name, p) + + svc := svc // Capture the loop variable + + go func() { + defer svcWg.Done() + + err := service. + New(). + SetConfig(svc). + SetStdOutCallback(textHandler). + SetStdErrCallback(textHandler). + SetStatusCallback(statusHandler). + Start(ctx) + + if err != nil { + p.Send(StatusMsg{Service: svc.Name, Status: "Error"}) + } + }() + } + + // 5. Launch health check goroutines that send status updates to the TUI + for name, entry := range hcMap { + name := name + entry := entry + go func() { + interval := entry.IntervalSeconds + if interval <= 0 { + interval = health.DefaultHealthInterval + } + timeout := entry.TimeoutSeconds + if timeout <= 0 { + timeout = health.DefaultHealthTimeout + } + start := time.Now() + for { + ok, _, err := health.CheckStatus(http.DefaultClient, entry.URL, entry.Codes) + if err == nil && ok { + p.Send(StatusMsg{Service: name, Status: "Healthy"}) + return + } + if time.Since(start) > time.Duration(timeout)*time.Second { + p.Send(StatusMsg{Service: name, Status: "Unhealthy"}) + return + } + time.Sleep(time.Duration(interval) * time.Second) + } + }() + } + + // 6. Run the Bubble Tea event loop (blocks until the user exits) + if _, err := p.Run(); err != nil { + return fmt.Errorf("error starting TUI: %w", err) + } + + cancel() // Cancel the context to stop all subprocesses + + svcWg.Wait() + + return nil +} diff --git a/utilities/devctl/app/tui/run_test.go b/utilities/devctl/app/tui/run_test.go new file mode 100644 index 0000000000..99596c6357 --- /dev/null +++ b/utilities/devctl/app/tui/run_test.go @@ -0,0 +1,45 @@ +package tui + +import ( + "os" + "strings" + "testing" +) + +// TestRun_ProcfileMissing ensures Run returns an error when the Procfile is absent. +func TestRun_ProcfileMissing(t *testing.T) { + dir := t.TempDir() + // No Procfile.test present + err := Run(dir, "test", "", "") + if err == nil { + t.Fatal("expected error when Procfile is missing, got nil") + } + if !os.IsNotExist(errorsUnwrapped(err)) && !contains(err.Error(), "error loading Procfile") { + t.Errorf("expected a procfile loading error, got %v", err) + } +} + +// errorsUnwrapped attempts to unwrap the error to the underlying cause. +func errorsUnwrapped(err error) error { + for { + unwrapped := unwrap(err) + if unwrapped == nil { + return err + } + err = unwrapped + } +} + +// unwrap tries standard unwrapping via interface. +func unwrap(err error) error { + type unwrapper interface{ Unwrap() error } + if u, ok := err.(unwrapper); ok { + return u.Unwrap() + } + return nil +} + +// contains is a simple substring check. +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} \ No newline at end of file diff --git a/utilities/devctl/cmd/cli.go b/utilities/devctl/cmd/cli.go new file mode 100644 index 0000000000..e8888f37cf --- /dev/null +++ b/utilities/devctl/cmd/cli.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + "os" + + "devctl/app" + + "github.com/urfave/cli/v2" +) + +// Execute initializes and runs the CLI application. +func Execute() { + app := &cli.App{ + Name: "devctl", + Usage: "Development control tool", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "config-dir", Aliases: []string{"c"}, Value: "configs", Usage: "Directory containing config files (env, Procfile, health)"}, + &cli.StringFlag{Name: "mode", Aliases: []string{"m"}, Value: "dev", Usage: "Mode to run (e.g., dev, prod)"}, + &cli.StringFlag{Name: "focus", Aliases: []string{"f"}, Value: "", Usage: "Service to focus on"}, + &cli.StringFlag{Name: "mute", Value: "", Usage: "Service to mute"}, + &cli.BoolFlag{Name: "no-tui", Usage: "Disable interactive TUI"}, + }, + Action: action, + } + + if err := app.Run(os.Args); err != nil { + os.Exit(1) + } +} + +// action is the main action for the CLI application. +func action(c *cli.Context) error { + err := app.New(). + SetConfigDir(c.String("config-dir")). + SetMode(c.String("mode")). + SetFocus(c.String("focus")). + SetMute(c.String("mute")). + SetTUI(c.Bool("no-tui")). + Run() + + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + return cli.Exit("", 1) + } + + return nil +} diff --git a/utilities/devctl/cmd/cli_test.go b/utilities/devctl/cmd/cli_test.go new file mode 100644 index 0000000000..4e3a3843f8 --- /dev/null +++ b/utilities/devctl/cmd/cli_test.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "errors" + "flag" + "os" + "path/filepath" + "testing" + + cli "github.com/urfave/cli/v2" +) + +// makeContext builds a cli.Context with provided flag values. +// makeContext builds a cli.Context with provided flag values, including --no-tui. +func makeContext(configDir, mode, focus, mute string, noTUI bool) *cli.Context { + app := &cli.App{} + set := flag.NewFlagSet("test", flag.ContinueOnError) + set.String("config-dir", "", "") + set.String("mode", "", "") + set.String("focus", "", "") + set.String("mute", "", "") + set.Bool("no-tui", false, "") + // Build args + args := []string{"--config-dir", configDir, "--mode", mode} + if focus != "" { + args = append(args, "--focus", focus) + } + if mute != "" { + args = append(args, "--mute", mute) + } + if noTUI { + args = append(args, "--no-tui") + } + set.Parse(args) + return cli.NewContext(app, set, nil) +} + +// suppressOutput silences stdout and stderr during f. +func suppressOutput(f func()) { + origOut, origErr := os.Stdout, os.Stderr + null, _ := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + os.Stdout, os.Stderr = null, null + defer func() { + null.Close() + os.Stdout, os.Stderr = origOut, origErr + }() + f() +} + +// TestAction_Success ensures action returns nil when config and Procfile exist. +func TestAction_Success(t *testing.T) { + dir := t.TempDir() + // write env file + if err := os.WriteFile(filepath.Join(dir, ".env.test"), []byte("X=1\n"), 0644); err != nil { + t.Fatalf("writing env file: %v", err) + } + // write Procfile + if err := os.WriteFile(filepath.Join(dir, "Procfile.test"), []byte("svc: echo ok\n"), 0644); err != nil { + t.Fatalf("writing Procfile: %v", err) + } + // disable TUI to run services directly + c := makeContext(dir, "test", "", "", true) + suppressOutput(func() { + if err := action(c); err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) +} + +// TestAction_ProcfileMissing ensures action returns ExitCoder when Procfile is absent. +func TestAction_ProcfileMissing(t *testing.T) { + dir := t.TempDir() + // disable TUI to run services directly + c := makeContext(dir, "test", "", "", true) + var exitCoder cli.ExitCoder + suppressOutput(func() { + err := action(c) + if !errors.As(err, &exitCoder) { + t.Fatalf("expected ExitCoder, got %v", err) + } + if exitCoder.ExitCode() != 1 { + t.Errorf("expected exit code 1, got %d", exitCoder.ExitCode()) + } + }) +} \ No newline at end of file diff --git a/utilities/devctl/go.mod b/utilities/devctl/go.mod new file mode 100644 index 0000000000..59ac9050a0 --- /dev/null +++ b/utilities/devctl/go.mod @@ -0,0 +1,37 @@ +module devctl + +go 1.23.0 + +toolchain go1.23.8 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.4 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/pkg/errors v0.9.1 + github.com/urfave/cli/v2 v2.27.6 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/utilities/devctl/go.sum b/utilities/devctl/go.sum new file mode 100644 index 0000000000..2c7968248a --- /dev/null +++ b/utilities/devctl/go.sum @@ -0,0 +1,57 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= +github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= +github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= +github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/utilities/devctl/main.go b/utilities/devctl/main.go new file mode 100644 index 0000000000..fdfb8f38b4 --- /dev/null +++ b/utilities/devctl/main.go @@ -0,0 +1,7 @@ +package main + +import "devctl/cmd" + +func main() { + cmd.Execute() +}