mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-25 17:36:43 +08:00
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:
1 parent
afd15318b6
commit
9a291eb0c3
12 files changed
+1129
-3
No files matched your search
+38
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 个备份。
|
||||
|
||||
## 会话管理
|
||||
|
||||
每个用户拥有独立的会话和完整的对话上下文。通过斜杠命令管理会话:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})))
|
||||
}
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user