Skip to content

Language-Agnostic Local Dev Process Manager #2697

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ node_modules
/build-local
/.history
/build
/bin
/bin/cli
server/ui-server
server/api
server/ui/assets
Expand Down
11 changes: 11 additions & 0 deletions bin/devctl.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
4 changes: 4 additions & 0 deletions configs/.env.dev
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
VITE_TEMPORAL_PORT="7233"
VITE_API="http://localhost:8081"
VITE_MODE="development"
VITE_TEMPORAL_UI_BUILD_TARGET="local"
3 changes: 3 additions & 0 deletions configs/Procfile.dev
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions configs/healthcheck.dev.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions utilities/devctl/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.cache
.gocache
devctl
91 changes: 91 additions & 0 deletions utilities/devctl/app/app.go
Original file line number Diff line number Diff line change
@@ -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
}
93 changes: 93 additions & 0 deletions utilities/devctl/app/app_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}
24 changes: 24 additions & 0 deletions utilities/devctl/app/colors/colors.go
Original file line number Diff line number Diff line change
@@ -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"
)
44 changes: 44 additions & 0 deletions utilities/devctl/app/colors/colors_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading