feat: add daemon mode for running as background service

Add support for running cc-connect as a daemon managed by OS init system:
- Linux: systemd user service
- macOS: launchd LaunchAgent

New CLI commands:
- cc-connect daemon install/uninstall
- cc-connect daemon start/stop/restart/status
- cc-connect daemon logs [-f] [-n N] [--log-file PATH]

Features:
- Auto log rotation with configurable max size
- Cross-platform support (systemd/launchd)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chenhg5andClaude Opus 4.6 committed 2026-03-04 00:18:55 +08:00
1 parent afd15318b6
commit 9a291eb0c3
12 files changed
+1129 -3

No files matched your search

+38
View File
@@ -579,6 +579,44 @@ make build
After upgrading, restart the running cc-connect process.
## Step 8: Run as Background Service (Optional)
You can run cc-connect as a daemon managed by the OS init system (Linux systemd user service, macOS launchd LaunchAgent).
### Install the daemon
```bash
cc-connect daemon install --config ~/.cc-connect/config.toml
```
Optional flags: `--log-file PATH`, `--log-max-size N` (MB), `--work-dir DIR`, `--force` (overwrite existing unit).
### Control the service
```bash
cc-connect daemon start
cc-connect daemon stop
cc-connect daemon restart
cc-connect daemon status
```
### View logs
```bash
cc-connect daemon logs # tail current log
cc-connect daemon logs -f # follow (like tail -f)
cc-connect daemon logs -n 100 # last 100 lines
cc-connect daemon logs --log-file /path/to/log # custom log file
```
Logs auto-rotate at the configured max size and keep one backup.
### Uninstall
```bash
cc-connect daemon uninstall
```
## Additional Features
The following additional features are available:
+16
View File
@@ -510,6 +510,22 @@ For short single-line messages:
cc-connect send -m "short message"
```
## Daemon Mode
Run cc-connect as a background service managed by the OS init system (Linux systemd user service, macOS launchd LaunchAgent).
```bash
cc-connect daemon install --config ~/.cc-connect/config.toml # install service
cc-connect daemon start
cc-connect daemon stop
cc-connect daemon restart
cc-connect daemon status
cc-connect daemon logs [-f] [-n N] [--log-file PATH]
cc-connect daemon uninstall
```
**Install flags:** `--log-file PATH`, `--log-max-size N` (MB), `--work-dir DIR`, `--force`. Logs auto-rotate at the size limit and keep one backup.
## Session Management
Each user gets an independent session with full conversation context. Manage sessions via slash commands:
+16
View File
@@ -514,6 +514,22 @@ For short single-line messages:
cc-connect send -m "short message"
```
## 守护进程模式
将 cc-connect 作为后台服务运行,由系统 init 管理(Linux systemd 用户服务,macOS launchd LaunchAgent)。
```bash
cc-connect daemon install --config ~/.cc-connect/config.toml # 安装服务
cc-connect daemon start
cc-connect daemon stop
cc-connect daemon restart
cc-connect daemon status
cc-connect daemon logs [-f] [-n N] [--log-file PATH]
cc-connect daemon uninstall
```
**install 参数:** `--log-file PATH`、`--log-max-size N`(MB)、`--work-dir DIR`、`--force`。日志在达到大小限制时自动轮转,保留 1 个备份。
## 会话管理
每个用户拥有独立的会话和完整的对话上下文。通过斜杠命令管理会话:
+354
View File
@@ -0,0 +1,354 @@
package main
import (
"bufio"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/chenhg5/cc-connect/daemon"
)
func runDaemon(args []string) {
if len(args) == 0 {
printDaemonUsage()
os.Exit(1)
}
switch args[0] {
case "install":
daemonInstall(args[1:])
case "uninstall":
daemonUninstall()
case "start":
daemonStart()
case "stop":
daemonStop()
case "restart":
daemonRestart()
case "status":
daemonStatus()
case "logs":
daemonLogs(args[1:])
default:
fmt.Fprintf(os.Stderr, "Unknown daemon command: %s\n\n", args[0])
printDaemonUsage()
os.Exit(1)
}
}
// ── install ─────────────────────────────────────────────────
func daemonInstall(args []string) {
var cfg daemon.Config
var force bool
for i := 0; i < len(args); i++ {
switch args[i] {
case "--log-file":
i++
if i < len(args) {
cfg.LogFile = args[i]
}
case "--log-max-size":
i++
if i < len(args) {
if mb, err := strconv.Atoi(args[i]); err == nil {
cfg.LogMaxSize = int64(mb) * 1024 * 1024
}
}
case "--work-dir":
i++
if i < len(args) {
cfg.WorkDir = args[i]
}
case "--force":
force = true
default:
fmt.Fprintf(os.Stderr, "Unknown flag: %s\n", args[i])
os.Exit(1)
}
}
if err := daemon.Resolve(&cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
configPath := cfg.WorkDir + "/config.toml"
if _, err := os.Stat(configPath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: config.toml not found in %s\n", cfg.WorkDir)
fmt.Fprintf(os.Stderr, " Use --work-dir to specify the directory containing config.toml\n")
os.Exit(1)
}
mgr, err := daemon.NewManager()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
st, _ := mgr.Status()
if st != nil && st.Installed && !force {
fmt.Fprintf(os.Stderr, "Service already installed. Use --force to reinstall.\n")
os.Exit(1)
}
if err := mgr.Install(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Install failed: %v\n", err)
os.Exit(1)
}
if err := daemon.SaveMeta(&daemon.Meta{
LogFile: cfg.LogFile,
LogMaxSize: cfg.LogMaxSize,
WorkDir: cfg.WorkDir,
BinaryPath: cfg.BinaryPath,
InstalledAt: daemon.NowISO(),
}); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to save metadata: %v\n", err)
}
fmt.Println("cc-connect daemon installed and started.")
fmt.Println()
fmt.Printf(" Platform: %s\n", mgr.Platform())
fmt.Printf(" Binary: %s\n", cfg.BinaryPath)
fmt.Printf(" WorkDir: %s\n", cfg.WorkDir)
fmt.Printf(" Log: %s\n", cfg.LogFile)
fmt.Printf(" LogMax: %d MB\n", cfg.LogMaxSize/1024/1024)
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" cc-connect daemon status - Check status")
fmt.Println(" cc-connect daemon logs -f - Follow logs")
fmt.Println(" cc-connect daemon restart - Restart")
fmt.Println(" cc-connect daemon stop - Stop")
fmt.Println(" cc-connect daemon uninstall - Remove")
}
// ── uninstall ───────────────────────────────────────────────
func daemonUninstall() {
mgr, err := daemon.NewManager()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
st, _ := mgr.Status()
if st != nil && !st.Installed {
fmt.Println("Service is not installed.")
return
}
if err := mgr.Uninstall(); err != nil {
fmt.Fprintf(os.Stderr, "Uninstall failed: %v\n", err)
os.Exit(1)
}
daemon.RemoveMeta()
fmt.Println("cc-connect daemon uninstalled.")
}
// ── start / stop / restart ──────────────────────────────────
func daemonStart() {
mgr := mustManager()
if err := mgr.Start(); err != nil {
fmt.Fprintf(os.Stderr, "Start failed: %v\n", err)
os.Exit(1)
}
fmt.Println("cc-connect daemon started.")
}
func daemonStop() {
mgr := mustManager()
if err := mgr.Stop(); err != nil {
fmt.Fprintf(os.Stderr, "Stop failed: %v\n", err)
os.Exit(1)
}
fmt.Println("cc-connect daemon stopped.")
}
func daemonRestart() {
mgr := mustManager()
if err := mgr.Restart(); err != nil {
fmt.Fprintf(os.Stderr, "Restart failed: %v\n", err)
os.Exit(1)
}
fmt.Println("cc-connect daemon restarted.")
}
// ── status ──────────────────────────────────────────────────
func daemonStatus() {
mgr := mustManager()
st, err := mgr.Status()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("cc-connect daemon status")
fmt.Println()
if !st.Installed {
fmt.Println(" Status: Not installed")
fmt.Printf(" Platform: %s\n", st.Platform)
fmt.Println()
fmt.Println(" Run: cc-connect daemon install")
return
}
statusStr := "Stopped"
if st.Running {
statusStr = "Running"
}
fmt.Printf(" Status: %s\n", statusStr)
fmt.Printf(" Platform: %s\n", st.Platform)
if st.PID > 0 {
fmt.Printf(" PID: %d\n", st.PID)
}
if meta, err := daemon.LoadMeta(); err == nil {
fmt.Printf(" Log: %s\n", meta.LogFile)
fmt.Printf(" WorkDir: %s\n", meta.WorkDir)
if t, err := time.Parse(time.RFC3339, meta.InstalledAt); err == nil {
fmt.Printf(" Installed: %s\n", t.Format("2006-01-02 15:04:05"))
}
}
}
// ── logs ────────────────────────────────────────────────────
func daemonLogs(args []string) {
follow := false
lines := 100
logFile := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "-f", "--follow":
follow = true
case "-n":
i++
if i < len(args) {
if n, err := strconv.Atoi(args[i]); err == nil && n > 0 {
lines = n
}
}
case "--log-file":
i++
if i < len(args) {
logFile = args[i]
}
}
}
if logFile == "" {
if meta, err := daemon.LoadMeta(); err == nil {
logFile = meta.LogFile
} else {
logFile = daemon.DefaultLogFile()
}
}
if _, err := os.Stat(logFile); err != nil {
fmt.Fprintf(os.Stderr, "Log file not found: %s\n", logFile)
os.Exit(1)
}
if !follow {
printLastLines(logFile, lines)
return
}
printLastLines(logFile, lines)
followFile(logFile)
}
func printLastLines(path string, n int) {
data, err := os.ReadFile(path)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading log: %v\n", err)
return
}
allLines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
start := 0
if len(allLines) > n {
start = len(allLines) - n
}
for _, line := range allLines[start:] {
fmt.Println(line)
}
}
func followFile(path string) {
f, err := os.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer f.Close()
f.Seek(0, io.SeekEnd)
reader := bufio.NewReader(f)
for {
line, err := reader.ReadString('\n')
if len(line) > 0 {
fmt.Print(line)
}
if err == io.EOF {
time.Sleep(300 * time.Millisecond)
reader.Reset(f)
continue
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return
}
}
}
// ── helpers ─────────────────────────────────────────────────
func mustManager() daemon.Manager {
mgr, err := daemon.NewManager()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return mgr
}
func printDaemonUsage() {
fmt.Println(`Usage: cc-connect daemon <command> [flags]
Commands:
install Install and start as system service
uninstall Remove system service
start Start the service
stop Stop the service
restart Restart the service
status Show service status
logs View log output
Install flags:
--log-file PATH Log file path (default: ~/.cc-connect/logs/cc-connect.log)
--log-max-size N Max log file size in MB (default: 10)
--work-dir DIR Directory containing config.toml (default: current dir)
--force Overwrite existing installation
Logs flags:
-f, --follow Follow log output (like tail -f)
-n N Number of lines to show (default: 100)
--log-file PATH Custom log file path
Supported platforms:
Linux - systemd user service
macOS - launchd LaunchAgent`)
}
+35 -3
View File
@@ -5,15 +5,18 @@ import (
"encoding/hex"
"flag"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/chenhg5/cc-connect/config"
"github.com/chenhg5/cc-connect/core"
"github.com/chenhg5/cc-connect/daemon"
_ "github.com/chenhg5/cc-connect/agent/claudecode"
_ "github.com/chenhg5/cc-connect/agent/codex"
@@ -56,9 +59,32 @@ func main() {
case "cron":
runCron(os.Args[2:])
return
case "daemon":
runDaemon(os.Args[2:])
return
}
}
// When started as a daemon (CC_LOG_FILE set), redirect logs to a rotating file.
var logWriter io.Writer
var logCloser io.Closer
if logFile := os.Getenv("CC_LOG_FILE"); logFile != "" {
maxSize := int64(daemon.DefaultLogMaxSize)
if v := os.Getenv("CC_LOG_MAX_SIZE"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
maxSize = n
}
}
w, err := daemon.NewRotatingWriter(logFile, maxSize)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to open log file %s: %v\n", logFile, err)
os.Exit(1)
}
logWriter = w
logCloser = w
slog.SetDefault(slog.New(slog.NewTextHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo})))
}
configFlag := flag.String("config", "", "path to config file (default: ./config.toml or ~/.cc-connect/config.toml)")
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
@@ -91,7 +117,7 @@ func main() {
config.ConfigPath = configPath
slog.Info("config loaded", "path", configPath)
setupLogger(cfg.Log.Level)
setupLogger(cfg.Log.Level, logWriter)
engines := make([]*core.Engine, 0, len(cfg.Projects))
@@ -285,6 +311,9 @@ func main() {
slog.Error("shutdown error", "error", err)
}
}
if logCloser != nil {
logCloser.Close()
}
slog.Info("bye")
}
@@ -372,7 +401,7 @@ app_secret = "your-feishu-app-secret"
return os.WriteFile(path, []byte(tmpl), 0o644)
}
func setupLogger(level string) {
func setupLogger(level string, w io.Writer) {
var logLevel slog.Level
switch level {
case "debug":
@@ -384,7 +413,10 @@ func setupLogger(level string) {
default:
logLevel = slog.LevelInfo
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
if w == nil {
w = os.Stdout
}
slog.SetDefault(slog.New(slog.NewTextHandler(w, &slog.HandlerOptions{
Level: logLevel,
})))
}
+11
View File
@@ -65,6 +65,17 @@ level = "info" # debug, info, warn, error
# api_key = "gsk_xxx"
# model = "whisper-large-v3-turbo"
# =============================================================================
# Daemon Mode / 守护进程模式
# =============================================================================
# Run cc-connect as a background service (systemd user / launchd LaunchAgent).
# 将 cc-connect 作为后台服务运行(systemd 用户服务 / launchd LaunchAgent)。
#
# CLI: cc-connect daemon install|uninstall|start|stop|restart|status|logs
# Install: --log-file PATH, --log-max-size N (MB), --work-dir DIR, --force
# Logs: -f/--follow, -n N, --log-file PATH
# 配置均通过 CLI 参数,无需在此文件中设置。
# =============================================================================
# Project 1 / 项目 1
# =============================================================================
+179
View File
@@ -0,0 +1,179 @@
//go:build darwin
package daemon
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
const (
launchdLabel = "com.cc-connect.service"
)
type launchdManager struct{}
func newPlatformManager() (Manager, error) {
return &launchdManager{}, nil
}
func (*launchdManager) Platform() string { return "launchd" }
func (m *launchdManager) Install(cfg Config) error {
plistPath := launchdPlistPath()
if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil {
return fmt.Errorf("create LaunchAgents dir: %w", err)
}
if err := os.MkdirAll(filepath.Dir(cfg.LogFile), 0755); err != nil {
return fmt.Errorf("create log dir: %w", err)
}
// Unload existing service first (ignore errors)
exec.Command("launchctl", "bootout", fmt.Sprintf("gui/%d/%s", os.Getuid(), launchdLabel)).Run()
plist := buildPlist(cfg)
if err := os.WriteFile(plistPath, []byte(plist), 0644); err != nil {
return fmt.Errorf("write plist: %w", err)
}
domain := fmt.Sprintf("gui/%d", os.Getuid())
if out, err := runLaunchctl("bootstrap", domain, plistPath); err != nil {
return fmt.Errorf("launchctl bootstrap: %s (%w)", out, err)
}
runLaunchctl("kickstart", "-kp", fmt.Sprintf("%s/%s", domain, launchdLabel))
return nil
}
func (m *launchdManager) Uninstall() error {
domain := fmt.Sprintf("gui/%d", os.Getuid())
runLaunchctl("bootout", fmt.Sprintf("%s/%s", domain, launchdLabel))
plistPath := launchdPlistPath()
if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove plist: %w", err)
}
return nil
}
func (*launchdManager) Start() error {
domain := fmt.Sprintf("gui/%d", os.Getuid())
plistPath := launchdPlistPath()
out, err := runLaunchctl("bootstrap", domain, plistPath)
if err != nil {
// already bootstrapped — try kickstart
out, err = runLaunchctl("kickstart", "-kp", fmt.Sprintf("%s/%s", domain, launchdLabel))
if err != nil {
return fmt.Errorf("start: %s (%w)", out, err)
}
}
return nil
}
func (*launchdManager) Stop() error {
domain := fmt.Sprintf("gui/%d", os.Getuid())
out, err := runLaunchctl("bootout", fmt.Sprintf("%s/%s", domain, launchdLabel))
if err != nil {
return fmt.Errorf("stop: %s (%w)", out, err)
}
return nil
}
func (*launchdManager) Restart() error {
domain := fmt.Sprintf("gui/%d", os.Getuid())
target := fmt.Sprintf("%s/%s", domain, launchdLabel)
runLaunchctl("bootout", target)
plistPath := launchdPlistPath()
out, err := runLaunchctl("bootstrap", domain, plistPath)
if err != nil {
return fmt.Errorf("restart: %s (%w)", out, err)
}
runLaunchctl("kickstart", "-kp", target)
return nil
}
func (*launchdManager) Status() (*Status, error) {
st := &Status{Platform: "launchd"}
plistPath := launchdPlistPath()
if _, err := os.Stat(plistPath); err != nil {
return st, nil
}
st.Installed = true
domain := fmt.Sprintf("gui/%d", os.Getuid())
target := fmt.Sprintf("%s/%s", domain, launchdLabel)
out, _ := runLaunchctl("print", target)
for _, line := range strings.Split(out, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "pid = ") {
if pid, err := strconv.Atoi(strings.TrimPrefix(trimmed, "pid = ")); err == nil && pid > 0 {
st.PID = pid
st.Running = true
}
}
if strings.Contains(trimmed, "state = running") {
st.Running = true
}
}
return st, nil
}
// ── helpers ─────────────────────────────────────────────────
func launchdPlistPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "LaunchAgents", launchdLabel+".plist")
}
func buildPlist(cfg Config) string {
envPATH := cfg.EnvPATH
if envPATH == "" {
envPATH = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin"
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
</array>
<key>WorkingDirectory</key>
<string>%s</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>CC_LOG_FILE</key>
<string>%s</string>
<key>CC_LOG_MAX_SIZE</key>
<string>%d</string>
<key>PATH</key>
<string>%s</string>
</dict>
<key>StandardOutPath</key>
<string>/dev/null</string>
<key>StandardErrorPath</key>
<string>/dev/null</string>
</dict>
</plist>
`, launchdLabel, cfg.BinaryPath, cfg.WorkDir, cfg.LogFile, cfg.LogMaxSize, envPATH)
}
func runLaunchctl(args ...string) (string, error) {
cmd := exec.Command("launchctl", args...)
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
+76
View File
@@ -0,0 +1,76 @@
package daemon
import (
"os"
"path/filepath"
"sync"
)
// RotatingWriter is a thread-safe io.Writer that appends to a log file
// and rotates it when the file exceeds maxSize. One backup (.1) is kept,
// so the maximum disk usage is ≈ 2 × maxSize.
type RotatingWriter struct {
mu sync.Mutex
file *os.File
path string
maxSize int64
curSize int64
}
func NewRotatingWriter(path string, maxSize int64) (*RotatingWriter, error) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, err
}
return &RotatingWriter{
file: f,
path: path,
maxSize: maxSize,
curSize: info.Size(),
}, nil
}
func (w *RotatingWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
n, err := w.file.Write(p)
w.curSize += int64(n)
if w.curSize > w.maxSize {
w.rotate()
}
return n, err
}
func (w *RotatingWriter) rotate() {
w.file.Close()
backup := w.path + ".1"
os.Remove(backup)
os.Rename(w.path, backup)
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
f, _ = os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
}
w.file = f
w.curSize = 0
}
func (w *RotatingWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file != nil {
return w.file.Close()
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package daemon
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRotatingWriter(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "test.log")
maxSize := int64(500) // 500 bytes
w, err := NewRotatingWriter(logPath, maxSize)
if err != nil {
t.Fatalf("NewRotatingWriter: %v", err)
}
defer w.Close()
line := strings.Repeat("A", 100) + "\n" // 101 bytes
for i := 0; i < 10; i++ {
if _, err := w.Write([]byte(line)); err != nil {
t.Fatalf("Write #%d: %v", i, err)
}
}
// After 10 writes of 101 bytes = 1010 bytes, rotation should have occurred.
info, err := os.Stat(logPath)
if err != nil {
t.Fatalf("Stat main: %v", err)
}
if info.Size() > maxSize+200 {
t.Errorf("main log too large: %d bytes (max %d)", info.Size(), maxSize)
}
backupPath := logPath + ".1"
if _, err := os.Stat(backupPath); err != nil {
t.Fatalf("backup file should exist: %v", err)
}
t.Logf("main: %d bytes, backup exists", info.Size())
}
func TestMetaSaveLoad(t *testing.T) {
origHome := os.Getenv("HOME")
dir := t.TempDir()
os.Setenv("HOME", dir)
defer os.Setenv("HOME", origHome)
m := &Meta{
LogFile: "/tmp/test.log",
LogMaxSize: 1024,
WorkDir: "/tmp",
BinaryPath: "/usr/local/bin/cc-connect",
InstalledAt: NowISO(),
}
if err := SaveMeta(m); err != nil {
t.Fatalf("SaveMeta: %v", err)
}
loaded, err := LoadMeta()
if err != nil {
t.Fatalf("LoadMeta: %v", err)
}
if loaded.LogFile != m.LogFile {
t.Errorf("LogFile mismatch: %s != %s", loaded.LogFile, m.LogFile)
}
if loaded.WorkDir != m.WorkDir {
t.Errorf("WorkDir mismatch: %s != %s", loaded.WorkDir, m.WorkDir)
}
}
+132
View File
@@ -0,0 +1,132 @@
package daemon
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
const (
DefaultLogMaxSize = 10 * 1024 * 1024 // 10 MB
ServiceName = "cc-connect"
)
type Config struct {
BinaryPath string
WorkDir string
LogFile string
LogMaxSize int64
EnvPATH string // capture user's PATH so agents are accessible
}
type Status struct {
Installed bool
Running bool
PID int
Platform string // "systemd", "launchd", "schtasks"
}
type Manager interface {
Install(cfg Config) error
Uninstall() error
Start() error
Stop() error
Restart() error
Status() (*Status, error)
Platform() string
}
// NewManager returns a platform-specific daemon manager.
func NewManager() (Manager, error) {
return newPlatformManager()
}
func DefaultLogFile() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cc-connect", "logs", "cc-connect.log")
}
func DefaultDataDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cc-connect")
}
// ── Metadata ────────────────────────────────────────────────
// Stored at ~/.cc-connect/daemon.json so that `logs`, `status`,
// etc. can locate the log file without parsing service definitions.
type Meta struct {
LogFile string `json:"log_file"`
LogMaxSize int64 `json:"log_max_size"`
WorkDir string `json:"work_dir"`
BinaryPath string `json:"binary_path"`
InstalledAt string `json:"installed_at"`
}
func metaPath() string {
return filepath.Join(DefaultDataDir(), "daemon.json")
}
func SaveMeta(m *Meta) error {
if err := os.MkdirAll(filepath.Dir(metaPath()), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(metaPath(), data, 0644)
}
func LoadMeta() (*Meta, error) {
data, err := os.ReadFile(metaPath())
if err != nil {
return nil, err
}
var m Meta
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return &m, nil
}
func RemoveMeta() {
os.Remove(metaPath())
}
func NowISO() string {
return time.Now().Format(time.RFC3339)
}
func Resolve(cfg *Config) error {
if cfg.BinaryPath == "" {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot detect binary path: %w", err)
}
real, err := filepath.EvalSymlinks(exe)
if err == nil {
exe = real
}
cfg.BinaryPath = exe
}
if cfg.WorkDir == "" {
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("cannot detect working directory: %w", err)
}
cfg.WorkDir = wd
}
if cfg.LogFile == "" {
cfg.LogFile = DefaultLogFile()
}
if cfg.LogMaxSize <= 0 {
cfg.LogMaxSize = DefaultLogMaxSize
}
if cfg.EnvPATH == "" {
cfg.EnvPATH = os.Getenv("PATH")
}
return nil
}
+185
View File
@@ -0,0 +1,185 @@
//go:build linux
package daemon
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
const (
systemdServiceName = ServiceName + ".service"
)
type systemdManager struct{}
func newPlatformManager() (Manager, error) {
if err := checkSystemctlAvailable(); err != nil {
return nil, err
}
return &systemdManager{}, nil
}
func (*systemdManager) Platform() string { return "systemd (user)" }
func (m *systemdManager) Install(cfg Config) error {
unitPath := systemdUnitPath()
if err := os.MkdirAll(filepath.Dir(unitPath), 0755); err != nil {
return fmt.Errorf("create systemd dir: %w", err)
}
if err := os.MkdirAll(filepath.Dir(cfg.LogFile), 0755); err != nil {
return fmt.Errorf("create log dir: %w", err)
}
unit := buildSystemdUnit(cfg)
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
return fmt.Errorf("write unit file: %w", err)
}
for _, args := range [][]string{
{"--user", "daemon-reload"},
{"--user", "enable", systemdServiceName},
{"--user", "restart", systemdServiceName},
} {
if out, err := runSystemctl(args...); err != nil {
return fmt.Errorf("systemctl %s: %s (%w)", strings.Join(args, " "), out, err)
}
}
return nil
}
func (m *systemdManager) Uninstall() error {
runSystemctl("--user", "disable", "--now", systemdServiceName)
unitPath := systemdUnitPath()
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove unit: %w", err)
}
runSystemctl("--user", "daemon-reload")
return nil
}
func (*systemdManager) Start() error {
out, err := runSystemctl("--user", "start", systemdServiceName)
if err != nil {
return fmt.Errorf("start: %s (%w)", out, err)
}
return nil
}
func (*systemdManager) Stop() error {
out, err := runSystemctl("--user", "stop", systemdServiceName)
if err != nil {
return fmt.Errorf("stop: %s (%w)", out, err)
}
return nil
}
func (*systemdManager) Restart() error {
out, err := runSystemctl("--user", "restart", systemdServiceName)
if err != nil {
return fmt.Errorf("restart: %s (%w)", out, err)
}
return nil
}
func (*systemdManager) Status() (*Status, error) {
st := &Status{Platform: "systemd (user)"}
unitPath := systemdUnitPath()
if _, err := os.Stat(unitPath); err != nil {
return st, nil
}
st.Installed = true
out, err := runSystemctl("--user", "show", systemdServiceName,
"--no-page", "--property", "ActiveState,MainPID")
if err != nil {
return st, nil
}
props := parseKeyValue(out)
if strings.EqualFold(props["ActiveState"], "active") {
st.Running = true
}
if pid, err := strconv.Atoi(props["MainPID"]); err == nil && pid > 0 {
st.PID = pid
}
return st, nil
}
// ── helpers ─────────────────────────────────────────────────
func systemdUnitPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "systemd", "user", systemdServiceName)
}
func buildSystemdUnit(cfg Config) string {
var sb strings.Builder
sb.WriteString("[Unit]\n")
sb.WriteString("Description=cc-connect - AI Agent Chat Bridge\n")
sb.WriteString("After=network-online.target\n")
sb.WriteString("Wants=network-online.target\n\n")
sb.WriteString("[Service]\n")
sb.WriteString("Type=simple\n")
fmt.Fprintf(&sb, "ExecStart=%s\n", cfg.BinaryPath)
fmt.Fprintf(&sb, "WorkingDirectory=%s\n", cfg.WorkDir)
sb.WriteString("Restart=on-failure\n")
sb.WriteString("RestartSec=10\n")
fmt.Fprintf(&sb, "Environment=CC_LOG_FILE=%s\n", cfg.LogFile)
fmt.Fprintf(&sb, "Environment=CC_LOG_MAX_SIZE=%d\n", cfg.LogMaxSize)
if cfg.EnvPATH != "" {
fmt.Fprintf(&sb, "Environment=PATH=%s\n", cfg.EnvPATH)
}
sb.WriteString("\n[Install]\n")
sb.WriteString("WantedBy=default.target\n")
return sb.String()
}
func runSystemctl(args ...string) (string, error) {
cmd := exec.Command("systemctl", args...)
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
func checkSystemctlAvailable() error {
if _, err := exec.LookPath("systemctl"); err != nil {
return fmt.Errorf("systemctl not found; systemd user services are required on Linux")
}
out, err := runSystemctl("--user", "status")
if err != nil {
detail := strings.ToLower(out)
if strings.Contains(detail, "failed to connect") ||
strings.Contains(detail, "no such file or directory") {
return fmt.Errorf("systemd user session not available.\n" +
" If running in WSL2, add [boot]\\nsystemd=true to /etc/wsl.conf and restart WSL.\n" +
" If running via SSH, try: loginctl enable-linger $USER")
}
if strings.Contains(detail, "not been booted") ||
strings.Contains(detail, "not supported") {
return fmt.Errorf("systemd not running in this environment.\n" +
" If running in a container, systemd is typically not available.\n" +
" Consider using nohup, tmux, or screen instead.")
}
}
return nil
}
func parseKeyValue(text string) map[string]string {
m := make(map[string]string)
for _, line := range strings.Split(text, "\n") {
if k, v, ok := strings.Cut(line, "="); ok {
m[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
return m
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux && !darwin
package daemon
import (
"fmt"
"runtime"
)
func newPlatformManager() (Manager, error) {
return nil, fmt.Errorf("daemon management is not supported on %s; use a process manager (e.g. nssm, pm2) instead", runtime.GOOS)
}