diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a774c5..5d2d14a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## 0.2.3 2026-01-30
+
+### Removed
+
+- **Service manager integration removed** - The `internal/service` package and all launchd/systemd integration code has been removed
+ - Removed `--service` flag from `devproxy setup`
+ - Removed socket activation support
+ - Removed automatic service installation/uninstallation
+ - Removed `go-launchd` dependency
+
+### Changed
+
+- `devproxy status` no longer shows socket activation state
+- `devproxy setup` no longer offers to install system services
+- `devproxy uninstall` no longer removes system services
+
+### Migration
+
+If you previously installed devproxy as a system service, you must manually remove it:
+
+**macOS:**
+```bash
+sudo launchctl unload /Library/LaunchDaemons/com.devproxy.daemon.plist
+sudo rm /Library/LaunchDaemons/com.devproxy.daemon.plist
+```
+
+**Linux:**
+```bash
+sudo systemctl stop devproxy
+sudo systemctl disable devproxy
+sudo rm /etc/systemd/system/devproxy.service
+sudo systemctl daemon-reload
+```
+
+After removal, use `devproxy start` to run as a daemon or `devproxy run` for foreground mode.
+
## 0.2.2 2026-01-30
### Changed
@@ -19,6 +55,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
sudo devproxy setup --service
```
+- `devproxy status` now correctly shows socket activation state
+ - Shows "ready (socket activation)" when service is loaded but idle
+ - Displays which ports are owned by launchd
+ - Shows routes from last run when socket-activated but not yet running
+
## 0.2.1 2026-01-19
### Changed
diff --git a/README.md b/README.md
index b40a209..b1396c5 100644
--- a/README.md
+++ b/README.md
@@ -55,10 +55,6 @@ brew install munichmade/tap/devproxy
curl -L https://github.com/munichmade/devproxy/releases/latest/download/devproxy-linux-amd64 -o devproxy
chmod +x devproxy
sudo mv devproxy /usr/local/bin/
-
-# Install systemd service
-sudo cp init/devproxy.service /etc/systemd/system/
-sudo systemctl daemon-reload
```
### Build from Source
diff --git a/cmd/devproxy/cmd/run.go b/cmd/devproxy/cmd/run.go
index 218dead..00f2db2 100644
--- a/cmd/devproxy/cmd/run.go
+++ b/cmd/devproxy/cmd/run.go
@@ -21,7 +21,6 @@ import (
"github.com/munichmade/devproxy/internal/paths"
"github.com/munichmade/devproxy/internal/privilege"
"github.com/munichmade/devproxy/internal/proxy"
- "github.com/munichmade/devproxy/internal/service"
)
// chownRecursive changes ownership of a directory and all its contents
@@ -39,8 +38,8 @@ func chownRecursive(path string, uid, gid int) error {
var runCmd = &cobra.Command{
Use: "run",
- Short: "Run daemon in foreground (for systemd/launchd)",
- Long: `Run the devproxy daemon in the foreground. Used by systemd/launchd service managers.`,
+ Short: "Run daemon in foreground",
+ Long: `Run the devproxy daemon in the foreground. Useful for debugging or running under a process supervisor.`,
Hidden: true,
Run: func(cmd *cobra.Command, args []string) {
if err := runDaemon(); err != nil {
@@ -73,43 +72,22 @@ func runDaemon() error {
}
// =========================================================================
- // PRIVILEGED SECTION - Try socket activation, fall back to direct binding
+ // PRIVILEGED SECTION - Bind to privileged ports while running as root
// =========================================================================
httpCfg, _ := cfg.GetEntrypoint("http")
httpsCfg, _ := cfg.GetEntrypoint("https")
- // Try launchd socket activation first (macOS service)
- // When running under launchd, sockets are pre-bound by the system
- httpListener, err := service.ActivatedListener("HTTPListener")
+ // Bind HTTP port
+ httpListener, err := net.Listen("tcp", httpCfg.Listen)
if err != nil {
- logging.Debug("launchd HTTP socket activation failed", "error", err)
- }
- httpsListener, err := service.ActivatedListener("HTTPSListener")
- if err != nil {
- logging.Debug("launchd HTTPS socket activation failed", "error", err)
- }
-
- // Track if we successfully used socket activation
- usedSocketActivation := httpListener != nil && httpsListener != nil
-
- if usedSocketActivation {
- fmt.Fprintf(os.Stderr, "using launchd socket activation for HTTP/HTTPS\n")
- }
-
- // Fall back to direct binding if socket activation not available
- if httpListener == nil {
- httpListener, err = net.Listen("tcp", httpCfg.Listen)
- if err != nil {
- return fmt.Errorf("failed to bind HTTP port %s: %w", httpCfg.Listen, err)
- }
+ return fmt.Errorf("failed to bind HTTP port %s: %w", httpCfg.Listen, err)
}
- if httpsListener == nil {
- httpsListener, err = net.Listen("tcp", httpsCfg.Listen)
- if err != nil {
- httpListener.Close()
- return fmt.Errorf("failed to bind HTTPS port %s: %w", httpsCfg.Listen, err)
- }
+ // Bind HTTPS port
+ httpsListener, err := net.Listen("tcp", httpsCfg.Listen)
+ if err != nil {
+ httpListener.Close()
+ return fmt.Errorf("failed to bind HTTPS port %s: %w", httpsCfg.Listen, err)
}
// Bind DNS port if enabled
@@ -146,10 +124,9 @@ func runDaemon() error {
}
// =========================================================================
- // DROP PRIVILEGES - Only needed when NOT using socket activation
- // When using launchd socket activation, we're already running unprivileged
+ // DROP PRIVILEGES - Run as original user after binding privileged ports
// =========================================================================
- if !usedSocketActivation && originalUser != nil {
+ if originalUser != nil {
// Before dropping privileges, ensure data directories are owned by the original user
// This is needed because directories may have been created by root
dataDir := paths.DataDir()
diff --git a/cmd/devproxy/cmd/setup.go b/cmd/devproxy/cmd/setup.go
index c14b906..9b6ffbc 100644
--- a/cmd/devproxy/cmd/setup.go
+++ b/cmd/devproxy/cmd/setup.go
@@ -10,7 +10,6 @@ import (
"github.com/munichmade/devproxy/internal/config"
"github.com/munichmade/devproxy/internal/privilege"
"github.com/munichmade/devproxy/internal/resolver"
- "github.com/munichmade/devproxy/internal/service"
)
var setupCmd = &cobra.Command{
@@ -21,12 +20,10 @@ var setupCmd = &cobra.Command{
1. Generating a local Certificate Authority (CA) if not present
2. Installing the CA into the system trust store
3. Configuring DNS resolver for *.localhost domains
- 4. Optionally installing devproxy as a system service (--service flag)
Administrator privileges are required to install the CA and configure DNS.`,
Run: func(cmd *cobra.Command, args []string) {
domains, _ := cmd.Flags().GetStringSlice("domain")
- installService, _ := cmd.Flags().GetBool("service")
// Elevate to root if needed
if err := privilege.RequireRoot("installing CA certificate and configuring DNS resolver"); err != nil {
@@ -88,35 +85,10 @@ Administrator privileges are required to install the CA and configure DNS.`,
fmt.Printf(" DNS resolver configured for: %v (port %d)\n", domains, dnsPort)
}
- // Step 4: Install as system service (optional)
- if installService {
- fmt.Print("4. Installing system service... ")
- if service.IsInstalled() {
- fmt.Println("already installed")
- } else {
- if err := service.Install(service.Config{}); err != nil {
- fmt.Fprintf(os.Stderr, "failed: %v\n", err)
- os.Exit(1)
- }
- fmt.Printf(" Service installed (%s)\n", service.ServiceName())
-
- fmt.Print(" Starting service... ")
- if err := service.Start(); err != nil {
- fmt.Fprintf(os.Stderr, "failed: %v\n", err)
- os.Exit(1)
- }
- fmt.Println("started")
- }
- }
-
fmt.Println()
fmt.Println("Setup complete! devproxy is ready to use.")
fmt.Println()
- if installService {
- fmt.Println("The service is running and will start automatically on boot.")
- } else {
- fmt.Println("Start the daemon with: devproxy start")
- }
+ fmt.Println("Start the daemon with: devproxy start")
},
}
@@ -125,9 +97,8 @@ var uninstallCmd = &cobra.Command{
Short: "Remove devproxy system configuration",
Long: `Uninstall removes devproxy system configuration:
- 1. Uninstalls system service (if installed)
- 2. Removes CA from system trust store
- 3. Removes DNS resolver configuration
+ 1. Removes CA from system trust store
+ 2. Removes DNS resolver configuration
Administrator privileges are required.`,
Run: func(cmd *cobra.Command, args []string) {
@@ -142,20 +113,8 @@ Administrator privileges are required.`,
fmt.Println("Removing devproxy configuration...")
fmt.Println()
- // Step 1: Uninstall system service
- fmt.Print("1. Removing system service... ")
- if !service.IsInstalled() {
- fmt.Println("not installed")
- } else {
- if err := service.Uninstall(); err != nil {
- fmt.Fprintf(os.Stderr, "failed: %v\n", err)
- } else {
- fmt.Println("done")
- }
- }
-
- // Step 2: Remove CA trust
- fmt.Print("2. Removing CA from trust store... ")
+ // Step 1: Remove CA trust
+ fmt.Print("1. Removing CA from trust store... ")
if !ca.IsTrusted() {
fmt.Println("not installed")
} else {
@@ -166,8 +125,8 @@ Administrator privileges are required.`,
}
}
- // Step 3: Remove DNS resolver
- fmt.Print("3. Removing DNS resolver... ")
+ // Step 2: Remove DNS resolver
+ fmt.Print("2. Removing DNS resolver... ")
if !resolver.IsConfigured(domains) {
fmt.Println("not configured")
} else {
@@ -209,7 +168,6 @@ func extractPort(addr string, defaultPort int) int {
func init() {
setupCmd.Flags().StringSliceP("domain", "d", []string{"localhost"}, "Domains to configure")
- setupCmd.Flags().BoolP("service", "s", false, "Install as system service (launchd on macOS, systemd on Linux)")
uninstallCmd.Flags().StringSliceP("domain", "d", []string{"localhost"}, "Domains to remove")
rootCmd.AddCommand(setupCmd)
diff --git a/cmd/devproxy/cmd/status.go b/cmd/devproxy/cmd/status.go
index 9c9cdb0..d939ee2 100644
--- a/cmd/devproxy/cmd/status.go
+++ b/cmd/devproxy/cmd/status.go
@@ -73,12 +73,13 @@ func getStatus() Status {
d := daemon.New()
status := Status{
- Running: d.IsRunning(),
Entrypoints: []Entrypoint{},
Projects: make(map[string]*ProjectRoutes),
}
- if status.Running {
+ // Check if daemon is running via PID file
+ if d.IsRunning() {
+ status.Running = true
pid, _ := d.GetPID()
status.PID = pid
}
diff --git a/go.mod b/go.mod
index 21c5c26..3a46b70 100644
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,6 @@ require (
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
- github.com/bored-engineer/go-launchd v0.0.0-20241220214420-b514778f4f80 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
diff --git a/go.sum b/go.sum
index b3743c0..b2689ef 100644
--- a/go.sum
+++ b/go.sum
@@ -2,8 +2,6 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
-github.com/bored-engineer/go-launchd v0.0.0-20241220214420-b514778f4f80 h1:O7c0CDkfGaIa7eNktOIBoPpuaq0zazOqB3iTghXIySw=
-github.com/bored-engineer/go-launchd v0.0.0-20241220214420-b514778f4f80/go.mod h1:fuBezjGxBjQFkJeCIN1eTo9c27q2WxjHK7tRlfBkyQk=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
diff --git a/internal/service/activation_darwin.go b/internal/service/activation_darwin.go
deleted file mode 100644
index 0046b3b..0000000
--- a/internal/service/activation_darwin.go
+++ /dev/null
@@ -1,64 +0,0 @@
-//go:build darwin
-
-package service
-
-import (
- "net"
- "sync"
- "syscall"
-
- launchd "github.com/bored-engineer/go-launchd"
-)
-
-var (
- activatedListeners = make(map[string]net.Listener)
- activationMu sync.Mutex
- activationChecked = make(map[string]bool)
-)
-
-// ActivatedListener returns a listener from launchd socket activation.
-// Returns nil, nil if not running under launchd or socket not available.
-// Results are cached since launchd only allows one activation per socket name.
-func ActivatedListener(name string) (net.Listener, error) {
- activationMu.Lock()
- defer activationMu.Unlock()
-
- // Return cached listener if already activated
- if l, ok := activatedListeners[name]; ok {
- return l, nil
- }
-
- // If we already checked this socket and it wasn't available, don't retry
- if activationChecked[name] {
- return nil, nil
- }
- activationChecked[name] = true
-
- // Try to activate from launchd
- listener, err := launchd.Activate(name)
- if err != nil {
- // ESRCH = not running under launchd
- // ENOENT = socket name not found in plist
- // These are expected when not running as a launchd service
- if err == syscall.ESRCH || err == syscall.ENOENT {
- return nil, nil
- }
- // EALREADY = already activated (shouldn't happen due to caching, but handle it)
- if err == syscall.EALREADY {
- return nil, nil
- }
- // Unexpected error
- return nil, err
- }
-
- activatedListeners[name] = listener
- return listener, nil
-}
-
-// IsSocketActivated returns true if the process was started via launchd
-// socket activation and has activated listeners available.
-func IsSocketActivated() bool {
- activationMu.Lock()
- defer activationMu.Unlock()
- return len(activatedListeners) > 0
-}
diff --git a/internal/service/activation_other.go b/internal/service/activation_other.go
deleted file mode 100644
index 6ff01e2..0000000
--- a/internal/service/activation_other.go
+++ /dev/null
@@ -1,17 +0,0 @@
-//go:build !darwin
-
-package service
-
-import "net"
-
-// ActivatedListener returns nil on non-Darwin platforms.
-// Socket activation via launchd is only supported on macOS.
-// Linux systemd socket activation can be added in the future.
-func ActivatedListener(name string) (net.Listener, error) {
- return nil, nil
-}
-
-// IsSocketActivated returns false on non-Darwin platforms.
-func IsSocketActivated() bool {
- return false
-}
diff --git a/internal/service/activation_other_test.go b/internal/service/activation_other_test.go
deleted file mode 100644
index faac48f..0000000
--- a/internal/service/activation_other_test.go
+++ /dev/null
@@ -1,25 +0,0 @@
-//go:build !darwin
-
-package service
-
-import (
- "testing"
-)
-
-func TestActivatedListener_NonDarwin(t *testing.T) {
- // On non-Darwin platforms, ActivatedListener should always return nil
- listener, err := ActivatedListener("HTTPListener")
- if err != nil {
- t.Errorf("ActivatedListener should not return error on non-Darwin: %v", err)
- }
- if listener != nil {
- listener.Close()
- t.Error("ActivatedListener should return nil on non-Darwin platforms")
- }
-}
-
-func TestIsSocketActivated_NonDarwin(t *testing.T) {
- if IsSocketActivated() {
- t.Error("IsSocketActivated should always return false on non-Darwin platforms")
- }
-}
diff --git a/internal/service/service.go b/internal/service/service.go
deleted file mode 100644
index aa253e4..0000000
--- a/internal/service/service.go
+++ /dev/null
@@ -1,56 +0,0 @@
-// Package service provides system service installation and management for devproxy.
-package service
-
-import (
- "fmt"
- "os"
- "path/filepath"
-)
-
-// Config holds service installation configuration.
-type Config struct {
- // BinaryPath is the path to the devproxy binary.
- // If empty, the current executable path is used.
- BinaryPath string
-}
-
-// IsInstalled checks if devproxy is installed as a system service.
-func IsInstalled() bool {
- return isInstalled()
-}
-
-// Install installs devproxy as a system service.
-func Install(cfg Config) error {
- if cfg.BinaryPath == "" {
- exe, err := os.Executable()
- if err != nil {
- return fmt.Errorf("failed to get executable path: %w", err)
- }
- cfg.BinaryPath, err = filepath.EvalSymlinks(exe)
- if err != nil {
- return fmt.Errorf("failed to resolve executable path: %w", err)
- }
- }
-
- return install(cfg)
-}
-
-// Uninstall removes devproxy from system services.
-func Uninstall() error {
- return uninstall()
-}
-
-// Start starts the system service.
-func Start() error {
- return start()
-}
-
-// Stop stops the system service.
-func Stop() error {
- return stop()
-}
-
-// ServiceName returns the name of the service for the current platform.
-func ServiceName() string {
- return serviceName()
-}
diff --git a/internal/service/service_darwin.go b/internal/service/service_darwin.go
deleted file mode 100644
index ab2f3c0..0000000
--- a/internal/service/service_darwin.go
+++ /dev/null
@@ -1,113 +0,0 @@
-//go:build darwin
-
-package service
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
-)
-
-const (
- plistName = "com.devproxy.daemon.plist"
- plistDir = "/Library/LaunchDaemons"
-)
-
-func serviceName() string {
- return "com.devproxy.daemon"
-}
-
-func plistPath() string {
- return filepath.Join(plistDir, plistName)
-}
-
-func isInstalled() bool {
- _, err := os.Stat(plistPath())
- return err == nil
-}
-
-func install(cfg Config) error {
- plist := generatePlist(cfg.BinaryPath)
-
- // Ensure the directory exists
- if err := os.MkdirAll(plistDir, 0o755); err != nil {
- return fmt.Errorf("failed to create %s: %w", plistDir, err)
- }
-
- // Write the plist file
- if err := os.WriteFile(plistPath(), []byte(plist), 0o644); err != nil {
- return fmt.Errorf("failed to write plist: %w", err)
- }
-
- return nil
-}
-
-func uninstall() error {
- // Stop the service first if running
- _ = stop()
-
- // Remove the plist file
- if err := os.Remove(plistPath()); err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("failed to remove plist: %w", err)
- }
-
- return nil
-}
-
-func start() error {
- cmd := exec.Command("launchctl", "load", plistPath())
- if output, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("failed to load service: %w: %s", err, strings.TrimSpace(string(output)))
- }
- return nil
-}
-
-func stop() error {
- cmd := exec.Command("launchctl", "unload", plistPath())
- if output, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("failed to unload service: %w: %s", err, strings.TrimSpace(string(output)))
- }
- return nil
-}
-
-func generatePlist(binaryPath string) string {
- return fmt.Sprintf(`
-
-
-
- Label
- com.devproxy.daemon
- ProgramArguments
-
- %s
- run
-
- RunAtLoad
-
- Sockets
-
- HTTPListener
-
- SockServiceName
- 80
- SockType
- stream
-
- HTTPSListener
-
- SockServiceName
- 443
- SockType
- stream
-
-
- StandardOutPath
- /var/log/devproxy.log
- StandardErrorPath
- /var/log/devproxy.log
-
-
-`, binaryPath)
-}
diff --git a/internal/service/service_darwin_test.go b/internal/service/service_darwin_test.go
deleted file mode 100644
index 2061c5e..0000000
--- a/internal/service/service_darwin_test.go
+++ /dev/null
@@ -1,126 +0,0 @@
-//go:build darwin
-
-package service
-
-import (
- "net"
- "strings"
- "testing"
-)
-
-func TestGeneratePlist(t *testing.T) {
- binaryPath := "/usr/local/bin/devproxy"
- plist := generatePlist(binaryPath)
-
- t.Run("contains binary path", func(t *testing.T) {
- if !strings.Contains(plist, binaryPath) {
- t.Errorf("plist should contain binary path %s", binaryPath)
- }
- })
-
- t.Run("contains service label", func(t *testing.T) {
- if !strings.Contains(plist, "com.devproxy.daemon") {
- t.Error("plist should contain service label com.devproxy.daemon")
- }
- })
-
- t.Run("contains run argument", func(t *testing.T) {
- if !strings.Contains(plist, "run") {
- t.Error("plist should contain 'run' argument")
- }
- })
-
- t.Run("contains Sockets dictionary", func(t *testing.T) {
- if !strings.Contains(plist, "Sockets") {
- t.Error("plist should contain Sockets key for socket activation")
- }
- })
-
- t.Run("contains HTTPListener socket", func(t *testing.T) {
- if !strings.Contains(plist, "HTTPListener") {
- t.Error("plist should contain HTTPListener socket")
- }
- if !strings.Contains(plist, "80") {
- t.Error("plist should configure port 80 for HTTPListener")
- }
- })
-
- t.Run("contains HTTPSListener socket", func(t *testing.T) {
- if !strings.Contains(plist, "HTTPSListener") {
- t.Error("plist should contain HTTPSListener socket")
- }
- if !strings.Contains(plist, "443") {
- t.Error("plist should configure port 443 for HTTPSListener")
- }
- })
-
- t.Run("does not contain KeepAlive", func(t *testing.T) {
- if strings.Contains(plist, "KeepAlive") {
- t.Error("plist should not contain KeepAlive (using socket activation instead)")
- }
- })
-
- t.Run("contains RunAtLoad", func(t *testing.T) {
- if !strings.Contains(plist, "RunAtLoad") {
- t.Error("plist should contain RunAtLoad")
- }
- })
-
- t.Run("contains log paths", func(t *testing.T) {
- if !strings.Contains(plist, "/var/log/devproxy.log") {
- t.Error("plist should configure log path")
- }
- })
-}
-
-func TestActivatedListener_NotUnderLaunchd(t *testing.T) {
- // When not running under launchd, ActivatedListener should return nil
- // without error (graceful fallback)
- listener, err := ActivatedListener("HTTPListener")
- if err != nil {
- t.Errorf("ActivatedListener should not return error when not under launchd: %v", err)
- }
- if listener != nil {
- listener.Close()
- t.Error("ActivatedListener should return nil when not running under launchd")
- }
-}
-
-func TestActivatedListener_Caching(t *testing.T) {
- // Reset state for test
- activationMu.Lock()
- activatedListeners = make(map[string]net.Listener)
- activationChecked = make(map[string]bool)
- activationMu.Unlock()
-
- // First call should check launchd
- listener1, _ := ActivatedListener("TestSocket")
-
- // Second call should use cached result (nil in this case since not under launchd)
- listener2, _ := ActivatedListener("TestSocket")
-
- // Both should be nil (not under launchd)
- if listener1 != nil || listener2 != nil {
- t.Error("expected nil listeners when not under launchd")
- }
-
- // Verify cache was used
- activationMu.Lock()
- checked := activationChecked["TestSocket"]
- activationMu.Unlock()
-
- if !checked {
- t.Error("expected socket to be marked as checked after first call")
- }
-}
-
-func TestIsSocketActivated(t *testing.T) {
- // Reset state
- activationMu.Lock()
- activatedListeners = make(map[string]net.Listener)
- activationMu.Unlock()
-
- if IsSocketActivated() {
- t.Error("IsSocketActivated should return false when no listeners are activated")
- }
-}
diff --git a/internal/service/service_linux.go b/internal/service/service_linux.go
deleted file mode 100644
index d44640d..0000000
--- a/internal/service/service_linux.go
+++ /dev/null
@@ -1,112 +0,0 @@
-//go:build linux
-
-package service
-
-import (
- "fmt"
- "os"
- "os/exec"
- "strings"
-)
-
-const (
- serviceName_ = "devproxy"
- serviceDir = "/etc/systemd/system"
- serviceFileName = "devproxy.service"
-)
-
-func serviceName() string {
- return serviceName_
-}
-
-func servicePath() string {
- return serviceDir + "/" + serviceFileName
-}
-
-func isInstalled() bool {
- _, err := os.Stat(servicePath())
- return err == nil
-}
-
-func install(cfg Config) error {
- unit := generateUnit(cfg.BinaryPath)
-
- // Ensure the directory exists
- if err := os.MkdirAll(serviceDir, 0755); err != nil {
- return fmt.Errorf("failed to create %s: %w", serviceDir, err)
- }
-
- // Write the service file
- if err := os.WriteFile(servicePath(), []byte(unit), 0644); err != nil {
- return fmt.Errorf("failed to write service file: %w", err)
- }
-
- // Reload systemd daemon
- cmd := exec.Command("systemctl", "daemon-reload")
- if output, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("failed to reload systemd: %w: %s", err, strings.TrimSpace(string(output)))
- }
-
- // Enable the service
- cmd = exec.Command("systemctl", "enable", serviceName_)
- if output, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("failed to enable service: %w: %s", err, strings.TrimSpace(string(output)))
- }
-
- return nil
-}
-
-func uninstall() error {
- // Stop the service first
- _ = stop()
-
- // Disable the service
- cmd := exec.Command("systemctl", "disable", serviceName_)
- _ = cmd.Run() // Ignore errors if not enabled
-
- // Remove the service file
- if err := os.Remove(servicePath()); err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("failed to remove service file: %w", err)
- }
-
- // Reload systemd daemon
- cmd = exec.Command("systemctl", "daemon-reload")
- if output, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("failed to reload systemd: %w: %s", err, strings.TrimSpace(string(output)))
- }
-
- return nil
-}
-
-func start() error {
- cmd := exec.Command("systemctl", "start", serviceName_)
- if output, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("failed to start service: %w: %s", err, strings.TrimSpace(string(output)))
- }
- return nil
-}
-
-func stop() error {
- cmd := exec.Command("systemctl", "stop", serviceName_)
- if output, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("failed to stop service: %w: %s", err, strings.TrimSpace(string(output)))
- }
- return nil
-}
-
-func generateUnit(binaryPath string) string {
- return fmt.Sprintf(`[Unit]
-Description=Development Proxy
-After=network.target docker.service
-Wants=docker.service
-
-[Service]
-Type=simple
-ExecStart=%s run
-Restart=always
-RestartSec=5
-
-[Install]
-WantedBy=multi-user.target
-`, binaryPath)
-}
diff --git a/scripts/cleanup-service-integration.sh b/scripts/cleanup-service-integration.sh
new file mode 100755
index 0000000..4617919
--- /dev/null
+++ b/scripts/cleanup-service-integration.sh
@@ -0,0 +1,113 @@
+#!/bin/bash
+#
+# cleanup-service-integration.sh
+#
+# Removes devproxy system service files that were installed by previous versions.
+# Run this script if you previously used `devproxy setup --service` or manually
+# installed devproxy as a launchd/systemd service.
+#
+# Usage: sudo ./cleanup-service-integration.sh
+#
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+info() {
+ echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+warn() {
+ echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+error() {
+ echo -e "${RED}[ERROR]${NC} $1"
+}
+
+# Check if running as root
+if [[ $EUID -ne 0 ]]; then
+ error "This script must be run as root (use sudo)"
+ exit 1
+fi
+
+echo "Devproxy Service Cleanup Script"
+echo "================================"
+echo ""
+
+# Detect OS
+case "$(uname -s)" in
+ Darwin)
+ info "Detected macOS"
+
+ PLIST_PATH="/Library/LaunchDaemons/com.devproxy.daemon.plist"
+
+ if [[ -f "$PLIST_PATH" ]]; then
+ info "Found launchd plist at $PLIST_PATH"
+
+ # Unload the service if loaded
+ if launchctl list | grep -q "com.devproxy.daemon"; then
+ info "Unloading service..."
+ launchctl unload "$PLIST_PATH" 2>/dev/null || true
+ fi
+
+ # Remove the plist file
+ info "Removing plist file..."
+ rm -f "$PLIST_PATH"
+
+ info "macOS service removed successfully"
+ else
+ info "No launchd plist found at $PLIST_PATH"
+ fi
+ ;;
+
+ Linux)
+ info "Detected Linux"
+
+ SYSTEMD_PATH="/etc/systemd/system/devproxy.service"
+
+ if [[ -f "$SYSTEMD_PATH" ]]; then
+ info "Found systemd unit at $SYSTEMD_PATH"
+
+ # Stop the service if running
+ if systemctl is-active --quiet devproxy 2>/dev/null; then
+ info "Stopping service..."
+ systemctl stop devproxy
+ fi
+
+ # Disable the service
+ if systemctl is-enabled --quiet devproxy 2>/dev/null; then
+ info "Disabling service..."
+ systemctl disable devproxy
+ fi
+
+ # Remove the unit file
+ info "Removing unit file..."
+ rm -f "$SYSTEMD_PATH"
+
+ # Reload systemd
+ info "Reloading systemd daemon..."
+ systemctl daemon-reload
+
+ info "Linux service removed successfully"
+ else
+ info "No systemd unit found at $SYSTEMD_PATH"
+ fi
+ ;;
+
+ *)
+ error "Unsupported operating system: $(uname -s)"
+ exit 1
+ ;;
+esac
+
+echo ""
+info "Cleanup complete!"
+echo ""
+echo "You can now run devproxy using:"
+echo " devproxy start # Run as background daemon"
+echo " devproxy run # Run in foreground"