初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
//go:build darwin
package daemon
import (
"encoding/xml"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
launchdLabel = "com.cc-connect.service"
)
var runLaunchctl = func(args ...string) (string, error) {
cmd := exec.Command("launchctl", args...)
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
type launchdManager struct{}
// CheckLinger always returns true on macOS: launchd user agents persist
// independently of login sessions, so no "linger" warning is needed.
func CheckLinger() (enabled bool, user string) {
return true, ""
}
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) so we do not leave a stale
// job behind when switching between GUI and headless sessions.
bootoutLaunchdTargets()
plist := buildPlist(cfg)
if err := os.WriteFile(plistPath, []byte(plist), 0644); err != nil {
return fmt.Errorf("write plist: %w", err)
}
domain := preferredLaunchdDomain()
if out, err := runLaunchctl("bootstrap", domain, plistPath); err != nil {
return fmt.Errorf("launchctl bootstrap: %s (%w)", out, err)
}
if _, err := runLaunchctl("kickstart", "-kp", launchdTarget(domain)); err != nil {
return fmt.Errorf("launchctl kickstart: %w", err)
}
return nil
}
func (m *launchdManager) Uninstall() error {
bootoutLaunchdTargets()
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 {
if _, target, _, ok := loadedLaunchdTarget(); ok {
out, err := runLaunchctl("kickstart", "-kp", target)
if err != nil {
return fmt.Errorf("start: %s (%w)", out, err)
}
return nil
}
domain := preferredLaunchdDomain()
plistPath := launchdPlistPath()
var out string
if _, err := runLaunchctl("bootstrap", domain, plistPath); err != nil {
// already bootstrapped — try kickstart
out, err = runLaunchctl("kickstart", "-kp", launchdTarget(domain))
if err != nil {
return fmt.Errorf("start: %s (%w)", out, err)
}
}
return nil
}
func (*launchdManager) Stop() error {
var lastOut string
var lastErr error
for _, target := range launchdTargets() {
out, err := runLaunchctl("bootout", target)
if err == nil {
return nil
}
lastOut = out
lastErr = err
}
if lastErr != nil {
return fmt.Errorf("stop: %s (%w)", lastOut, lastErr)
}
return nil
}
func (*launchdManager) Restart() error {
domain := preferredLaunchdDomain()
if loadedDomain, _, _, ok := loadedLaunchdTarget(); ok && domain != launchdGUIDomain() {
domain = loadedDomain
}
target := launchdTarget(domain)
bootoutLaunchdTargets()
plistPath := launchdPlistPath()
// launchd bootout is asynchronous; retry bootstrap with backoff
// to avoid "Bootstrap failed: 5" race condition.
var out string
var err error
for i := 0; i < 3; i++ {
if i > 0 {
time.Sleep(500 * time.Millisecond)
}
out, err = runLaunchctl("bootstrap", domain, plistPath)
if err == nil {
break
}
}
if err != nil {
return fmt.Errorf("restart: %s (%w)", out, err)
}
if _, err := runLaunchctl("kickstart", "-kp", target); err != nil {
return fmt.Errorf("restart kickstart: %w", err)
}
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
_, _, out, ok := loadedLaunchdTarget()
if !ok {
return st, nil
}
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 launchdUserDomain() string {
return fmt.Sprintf("user/%d", os.Getuid())
}
func launchdGUIDomain() string {
return fmt.Sprintf("gui/%d", os.Getuid())
}
func preferredLaunchdDomain() string {
guiDomain := launchdGUIDomain()
if _, err := runLaunchctl("print", guiDomain); err == nil {
return guiDomain
}
return launchdUserDomain()
}
func launchdDomains() []string {
preferred := preferredLaunchdDomain()
guiDomain := launchdGUIDomain()
userDomain := launchdUserDomain()
if preferred == guiDomain {
return []string{guiDomain, userDomain}
}
return []string{userDomain, guiDomain}
}
func launchdTarget(domain string) string {
return fmt.Sprintf("%s/%s", domain, launchdLabel)
}
func launchdTargets() []string {
domains := launchdDomains()
targets := make([]string, 0, len(domains))
for _, domain := range domains {
targets = append(targets, launchdTarget(domain))
}
return targets
}
func loadedLaunchdTarget() (string, string, string, bool) {
for _, domain := range launchdDomains() {
target := launchdTarget(domain)
out, err := runLaunchctl("print", target)
if err == nil {
return domain, target, out, true
}
}
return "", "", "", false
}
func bootoutLaunchdTargets() {
for _, target := range launchdTargets() {
_, _ = runLaunchctl("bootout", target)
}
}
func buildPlist(cfg Config) string {
envPATH := cfg.EnvPATH
if envPATH == "" {
envPATH = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin"
}
// User-supplied paths can legitimately contain XML-special characters
// ('&', '<', '>', '"', '\''). Without escaping, `launchctl bootstrap`
// rejects the plist with a parse error and daemon install fails. The
// label is a hard-coded constant so it does not need escaping; LogMaxSize
// is an int.
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>LimitLoadToSessionType</key>
<array>
<string>Aqua</string>
<string>Background</string>
</array>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<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, xmlEscape(cfg.BinaryPath), xmlEscape(cfg.WorkDir), xmlEscape(cfg.LogFile), cfg.LogMaxSize, xmlEscape(envPATH))
}
// xmlEscape escapes the five XML-reserved characters in a string so it can
// be safely embedded inside a plist <string> element.
func xmlEscape(s string) string {
var b strings.Builder
if err := xml.EscapeText(&b, []byte(s)); err != nil {
// xml.EscapeText only fails when the underlying writer fails; a
// strings.Builder never returns a write error. Fall back to the
// raw value defensively rather than panicking.
return s
}
return b.String()
}
+336
View File
@@ -0,0 +1,336 @@
//go:build darwin
package daemon
import (
"encoding/xml"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuildPlist_KeepAliveDoesNotRestartOnCleanExit(t *testing.T) {
cfg := Config{
BinaryPath: "/opt/cc-connect/cc-connect",
WorkDir: "/tmp/wd",
LogFile: "/tmp/log",
LogMaxSize: 10485760,
EnvPATH: "/usr/bin",
}
xml := buildPlist(cfg)
if !strings.Contains(xml, "<key>SuccessfulExit</key>") {
t.Fatal("plist should use KeepAlive dict with SuccessfulExit so exit 0 does not respawn")
}
// Boolean KeepAlive causes launchd to restart after every exit, including SIGTERM shutdown.
if strings.Contains(xml, "<key>KeepAlive</key>\n\t<true/>") {
t.Fatal("plist must not use boolean KeepAlive true")
}
// launchd.plist(5): SuccessfulExit=true means restart ONLY after a successful
// (exit 0) exit; false means restart ONLY after an unsuccessful exit. cc-connect
// returns 0 on graceful SIGTERM shutdown but a non-zero status on crash, so the
// daemon's "restart on failure but not on graceful stop" intent maps to
// SuccessfulExit=false. The previous wiring used <true/>, which was the inverse:
// it respawned after every clean SIGTERM shutdown and did NOT recover from
// crashes. Pin the correct value here so a future edit can't silently re-invert
// it.
if !strings.Contains(xml, "<key>SuccessfulExit</key>\n\t\t<false/>") {
t.Fatalf("plist must set SuccessfulExit=false so crashes restart and clean SIGTERM does not respawn; got:\n%s", xml)
}
if !strings.Contains(xml, "<key>LimitLoadToSessionType</key>") ||
!strings.Contains(xml, "<string>Aqua</string>") ||
!strings.Contains(xml, "<string>Background</string>") {
t.Fatal("plist should allow both Aqua and Background sessions")
}
}
func TestPreferredLaunchdDomainFallsBackToUserWhenGUIDomainUnavailable(t *testing.T) {
orig := runLaunchctl
t.Cleanup(func() { runLaunchctl = orig })
guiDomain := launchdGUIDomain()
userDomain := launchdUserDomain()
runLaunchctl = func(args ...string) (string, error) {
if len(args) >= 2 && args[0] == "print" && args[1] == guiDomain {
return "Bootstrap failed: 125: Domain does not support specified action", fmt.Errorf("exit status 125")
}
if len(args) >= 2 && args[0] == "print" && args[1] == userDomain {
return "subsystem", nil
}
return "", nil
}
if got := preferredLaunchdDomain(); got != userDomain {
t.Fatalf("preferredLaunchdDomain() = %q, want %q", got, userDomain)
}
}
func TestLaunchdStatusUsesUserDomainWhenGUIDomainUnavailable(t *testing.T) {
orig := runLaunchctl
t.Cleanup(func() { runLaunchctl = orig })
guiDomain := launchdGUIDomain()
userDomain := launchdUserDomain()
guiTarget := launchdTarget(guiDomain)
userTarget := launchdTarget(userDomain)
runLaunchctl = func(args ...string) (string, error) {
if len(args) < 2 || args[0] != "print" {
return "", nil
}
switch args[1] {
case guiDomain, guiTarget:
return "Bootstrap failed: 125: Domain does not support specified action", fmt.Errorf("exit status 125")
case userDomain:
return "subsystem", nil
case userTarget:
return "pid = 4321\nstate = running", nil
default:
return "", fmt.Errorf("unexpected target %q", args[1])
}
}
mgr := &launchdManager{}
st, err := mgr.Status()
if err != nil {
t.Fatalf("Status() error = %v", err)
}
if !st.Running {
t.Fatal("Status().Running = false, want true")
}
if st.PID != 4321 {
t.Fatalf("Status().PID = %d, want 4321", st.PID)
}
}
func TestRestartPrefersGUIDomainWhenAvailable(t *testing.T) {
orig := runLaunchctl
t.Cleanup(func() { runLaunchctl = orig })
dir := t.TempDir()
origHome := os.Getenv("HOME")
t.Setenv("HOME", dir)
if origHome != "" {
t.Cleanup(func() { _ = os.Setenv("HOME", origHome) })
}
plistPath := launchdPlistPath()
if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(plistPath, []byte("plist"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
guiDomain := launchdGUIDomain()
userDomain := launchdUserDomain()
guiTarget := launchdTarget(guiDomain)
userTarget := launchdTarget(userDomain)
var calls []string
runLaunchctl = func(args ...string) (string, error) {
calls = append(calls, strings.Join(args, " "))
if len(args) < 2 {
return "", nil
}
switch args[0] {
case "print":
switch args[1] {
case guiDomain:
return "subsystem", nil
case guiTarget:
return "Bootstrap failed: 113: Could not find service", fmt.Errorf("exit status 113")
case userTarget:
return "pid = 4321\nstate = running", nil
default:
return "", fmt.Errorf("unexpected print target %q", args[1])
}
case "bootout":
return "", nil
case "bootstrap":
if args[1] != guiDomain {
t.Fatalf("bootstrap domain = %q, want %q", args[1], guiDomain)
}
return "", nil
case "kickstart":
if args[len(args)-1] != guiTarget {
t.Fatalf("kickstart target = %q, want %q", args[len(args)-1], guiTarget)
}
return "", nil
default:
return "", nil
}
}
mgr := &launchdManager{}
if err := mgr.Restart(); err != nil {
t.Fatalf("Restart() error = %v", err)
}
if !containsCall(calls, "bootstrap "+guiDomain+" "+plistPath) {
t.Fatalf("expected bootstrap to gui domain, calls = %#v", calls)
}
if !containsCall(calls, "kickstart -kp "+guiTarget) {
t.Fatalf("expected kickstart to gui target, calls = %#v", calls)
}
}
func TestRestartKeepsUserDomainWhenGUIDomainUnavailable(t *testing.T) {
orig := runLaunchctl
t.Cleanup(func() { runLaunchctl = orig })
dir := t.TempDir()
origHome := os.Getenv("HOME")
t.Setenv("HOME", dir)
if origHome != "" {
t.Cleanup(func() { _ = os.Setenv("HOME", origHome) })
}
plistPath := launchdPlistPath()
if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(plistPath, []byte("plist"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
guiDomain := launchdGUIDomain()
userDomain := launchdUserDomain()
userTarget := launchdTarget(userDomain)
var calls []string
runLaunchctl = func(args ...string) (string, error) {
calls = append(calls, strings.Join(args, " "))
if len(args) < 2 {
return "", nil
}
switch args[0] {
case "print":
switch args[1] {
case guiDomain:
return "Bootstrap failed: 125: Domain does not support specified action", fmt.Errorf("exit status 125")
case userDomain:
return "subsystem", nil
case userTarget:
return "pid = 4321\nstate = running", nil
default:
return "", fmt.Errorf("unexpected print target %q", args[1])
}
case "bootout":
return "", nil
case "bootstrap":
if args[1] != userDomain {
t.Fatalf("bootstrap domain = %q, want %q", args[1], userDomain)
}
return "", nil
case "kickstart":
if args[len(args)-1] != userTarget {
t.Fatalf("kickstart target = %q, want %q", args[len(args)-1], userTarget)
}
return "", nil
default:
return "", nil
}
}
mgr := &launchdManager{}
if err := mgr.Restart(); err != nil {
t.Fatalf("Restart() error = %v", err)
}
if !containsCall(calls, "bootstrap "+userDomain+" "+plistPath) {
t.Fatalf("expected bootstrap to user domain, calls = %#v", calls)
}
if !containsCall(calls, "kickstart -kp "+userTarget) {
t.Fatalf("expected kickstart to user target, calls = %#v", calls)
}
}
// collectXMLText walks the XML stream and returns every chardata text node.
// Used by the plist-escape test to verify path values round-trip through
// xml.Decoder regardless of how deeply they are nested.
func collectXMLText(t *testing.T, data []byte) []string {
t.Helper()
dec := xml.NewDecoder(strings.NewReader(string(data)))
var out []string
for {
tok, err := dec.Token()
if tok == nil {
break
}
if err != nil {
t.Fatalf("xml decode token: %v", err)
}
if cd, ok := tok.(xml.CharData); ok {
s := strings.TrimSpace(string(cd))
if s != "" {
out = append(out, s)
}
}
}
return out
}
func containsCall(calls []string, want string) bool {
for _, call := range calls {
if call == want {
return true
}
}
return false
}
// TestBuildPlist_EscapesXMLSpecialCharsInPaths pins the bug where unescaped
// '&', '<', '>', '"', and '\” in cfg paths produced malformed XML that
// `launchctl bootstrap` rejected.
func TestBuildPlist_EscapesXMLSpecialCharsInPaths(t *testing.T) {
cfg := Config{
BinaryPath: "/opt/cc-connect/bin & <tools>/cc-connect",
WorkDir: "/Users/jane/Projects/dev & test/cc-connect",
LogFile: "/Users/jane/Library/Logs/cc \"connect\".log",
LogMaxSize: 10485760,
EnvPATH: "/usr/bin:/path/with'apostrophe/bin",
}
out := buildPlist(cfg)
// 1) Result must parse as well-formed XML — without escaping, bare '&'
// or unbalanced '<' inside <string> elements break the parser.
if err := xml.Unmarshal([]byte(out), new(struct{ XMLName xml.Name })); err != nil {
t.Fatalf("buildPlist output is not valid XML: %v\n%s", err, out)
}
// 2) Round-trip the values through the XML parser and make sure the
// original characters survive the encode/decode cycle. Walk every
// text node rather than relying on a positional path, since the
// plist nests <array>/<dict>/<string> at multiple depths.
values := collectXMLText(t, []byte(out))
mustContain := []string{cfg.BinaryPath, cfg.WorkDir, cfg.LogFile, cfg.EnvPATH}
for _, want := range mustContain {
found := false
for _, got := range values {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("decoded plist does not contain expected value %q\nvalues = %#v", want, values)
}
}
// 3) The raw XML output must not contain a bare '&' followed by anything
// other than a recognized entity reference — that's what would crash
// launchctl.
for i := 0; i < len(out); i++ {
if out[i] != '&' {
continue
}
rest := out[i:]
if !(strings.HasPrefix(rest, "&amp;") ||
strings.HasPrefix(rest, "&lt;") ||
strings.HasPrefix(rest, "&gt;") ||
strings.HasPrefix(rest, "&quot;") ||
strings.HasPrefix(rest, "&apos;") ||
strings.HasPrefix(rest, "&#")) {
t.Fatalf("bare '&' at offset %d (not a valid entity ref): %q", i, rest[:min(len(rest), 30)])
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package daemon
import (
"log/slog"
"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()
if w.file == nil {
return 0, os.ErrClosed
}
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)
if err := os.Rename(w.path, backup); err != nil {
slog.Warn("logrotate: rename failed", "error", err, "path", w.path, "backup", backup)
}
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
// If we cannot open the new log file, w.file will be nil.
// Write() checks for nil and returns os.ErrClosed instead of panicking.
w.file = nil
w.curSize = 0
return
}
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)
}
}
+151
View File
@@ -0,0 +1,151 @@
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
EnvExtra map[string]string // selected environment variables needed by the service runtime
}
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")
}
if len(cfg.EnvExtra) == 0 {
cfg.EnvExtra = captureDaemonEnv()
}
return nil
}
func captureDaemonEnv() map[string]string {
keys := []string{
"http_proxy", "https_proxy", "no_proxy",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
"all_proxy", "ALL_PROXY",
}
env := make(map[string]string, len(keys))
for _, key := range keys {
if value := os.Getenv(key); value != "" {
env[key] = value
}
}
return env
}
+308
View File
@@ -0,0 +1,308 @@
//go:build linux
package daemon
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
)
const (
systemdServiceName = ServiceName + ".service"
)
type systemdManager struct {
system bool // true = system-level (/etc/systemd/system), false = user-level (~/.config/systemd/user)
}
func newPlatformManager() (Manager, error) {
if _, err := exec.LookPath("systemctl"); err != nil {
return nil, fmt.Errorf("systemctl not found: systemd is required on Linux; if running in a container without systemd, use nohup, tmux, or screen instead")
}
isRoot := os.Getuid() == 0
if isRoot {
if err := checkSystemdRunning(true); err != nil {
return nil, err
}
return &systemdManager{system: true}, nil
}
if err := checkSystemdRunning(false); err != nil {
return nil, err
}
return &systemdManager{system: false}, nil
}
func (m *systemdManager) Platform() string {
if m.system {
return "systemd (system)"
}
return "systemd (user)"
}
func (m *systemdManager) Install(cfg Config) error {
unitPath := m.unitPath()
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 := m.buildUnit(cfg)
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
return fmt.Errorf("write unit file: %w", err)
}
for _, cmdArgs := range [][]string{
m.sysArgs("daemon-reload"),
m.sysArgs("enable", systemdServiceName),
m.sysArgs("restart", systemdServiceName),
} {
if out, err := runSystemctl(cmdArgs...); err != nil {
return fmt.Errorf("systemctl %s: %s (%w)", strings.Join(cmdArgs, " "), out, err)
}
}
return nil
}
func (m *systemdManager) Uninstall() error {
if _, err := runSystemctl(m.sysArgs("disable", "--now", systemdServiceName)...); err != nil {
slog.Warn("systemd: disable failed", "error", err)
}
unitPath := m.unitPath()
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove unit: %w", err)
}
if _, err := runSystemctl(m.sysArgs("daemon-reload")...); err != nil {
slog.Warn("systemd: daemon-reload failed", "error", err)
}
return nil
}
func (m *systemdManager) Start() error {
out, err := runSystemctl(m.sysArgs("start", systemdServiceName)...)
if err != nil {
return fmt.Errorf("start: %s (%w)", out, err)
}
return nil
}
func (m *systemdManager) Stop() error {
out, err := runSystemctl(m.sysArgs("stop", systemdServiceName)...)
if err != nil {
return fmt.Errorf("stop: %s (%w)", out, err)
}
return nil
}
func (m *systemdManager) Restart() error {
out, err := runSystemctl(m.sysArgs("restart", systemdServiceName)...)
if err != nil {
return fmt.Errorf("restart: %s (%w)", out, err)
}
return nil
}
func (m *systemdManager) Status() (*Status, error) {
st := &Status{Platform: m.Platform()}
unitPath := m.unitPath()
if _, err := os.Stat(unitPath); err != nil {
return st, nil
}
st.Installed = true
out, err := runSystemctl(m.sysArgs("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 ─────────────────────────────────────────────────
// sysArgs prepends --user flag for user-level managers.
func (m *systemdManager) sysArgs(args ...string) []string {
if m.system {
return args
}
return append([]string{"--user"}, args...)
}
func (m *systemdManager) unitPath() string {
if m.system {
return filepath.Join("/etc/systemd/system", systemdServiceName)
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "systemd", "user", systemdServiceName)
}
func (m *systemdManager) buildUnit(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)
}
if len(cfg.EnvExtra) > 0 {
keys := make([]string, 0, len(cfg.EnvExtra))
for key := range cfg.EnvExtra {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
fmt.Fprintf(&sb, "Environment=\"%s=%s\"\n", key, cfg.EnvExtra[key])
}
}
sb.WriteString("\n[Install]\n")
if m.system {
sb.WriteString("WantedBy=multi-user.target\n")
} else {
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 checkSystemdRunning(system bool) error {
var args []string
if system {
args = []string{"is-system-running"}
} else {
args = []string{"--user", "is-system-running"}
}
out, _ := runSystemctl(args...)
state := strings.TrimSpace(strings.ToLower(out))
// These states all mean systemd is usable for managing services
switch state {
case "running", "degraded", "starting", "initializing":
return nil
}
// "offline" = systemd exists but is not PID 1 (WSL2 without systemd, some containers)
// "not been booted" / empty = no systemd at all
wsl := isWSL2()
if system {
if wsl {
return fmt.Errorf("systemd is not active in this WSL2 instance.\n" +
" Add the following to /etc/wsl.conf and restart WSL (wsl --shutdown):\n" +
" [boot]\n" +
" systemd=true\n" +
" Or use: nohup cc-connect > cc-connect.log 2>&1 &")
}
if state == "offline" || strings.Contains(state, "not been booted") {
return fmt.Errorf("systemd is not active (state: %s).\n"+
" If running in a container, systemd is typically not available.\n"+
" Use nohup, tmux, or screen instead:\n"+
" nohup cc-connect > cc-connect.log 2>&1 &", state)
}
return fmt.Errorf("systemd check failed (state: %s).\n"+
" Use nohup as alternative: nohup cc-connect > cc-connect.log 2>&1 &", state)
}
// User-level failures
if wsl {
return fmt.Errorf("systemd user session not available in WSL2.\n" +
" Add the following to /etc/wsl.conf and restart WSL (wsl --shutdown):\n" +
" [boot]\n" +
" systemd=true\n" +
" Or use: nohup cc-connect > cc-connect.log 2>&1 &")
}
user := os.Getenv("USER")
return fmt.Errorf("systemd user session not available.\n"+
" This often happens when connecting via SSH without a systemd login session.\n"+
" Try one of:\n"+
" 1. Run as root: sudo cc-connect daemon install (uses system-level systemd)\n"+
" 2. loginctl enable-linger %s && export XDG_RUNTIME_DIR=/run/user/$(id -u)\n"+
" 3. Use nohup/tmux instead: nohup cc-connect > cc-connect.log 2>&1 &", user)
}
func isWSL2() bool {
data, err := os.ReadFile("/proc/version")
if err != nil {
return false
}
lower := strings.ToLower(string(data))
return strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl")
}
func parseKeyValue(text string) map[string]string {
m := make(map[string]string)
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || !strings.Contains(line, "=") {
continue
}
parts := strings.SplitN(line, "=", 2)
m[parts[0]] = parts[1]
}
return m
}
// CheckLinger returns true if linger is enabled for the user, false otherwise.
// If linger is not enabled, user-level systemd services will stop when
// the user's last login session ends (e.g., SSH disconnect).
func CheckLinger() (enabled bool, user string) {
user = os.Getenv("USER")
if user == "" {
user = "unknown"
}
// Check if we're in system mode (root)
if os.Getuid() == 0 {
return true, user // Linger check not relevant for system mode
}
// Check linger status via loginctl
out, err := exec.Command("loginctl", "show-user", user, "-p", "Linger").Output()
if err != nil {
// loginctl not available or error - assume linger is disabled
slog.Debug("linger check failed", "error", err)
return false, user
}
linger := strings.TrimSpace(string(out))
return linger == "Linger=yes", user
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !linux && !darwin && !windows
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)
}
// CheckLinger is a no-op on unsupported platforms (always returns false).
func CheckLinger() (enabled bool, user string) {
return false, ""
}
+256
View File
@@ -0,0 +1,256 @@
//go:build windows
package daemon
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
)
const (
windowsTaskName = ServiceName
windowsScriptName = "cc-connect-daemon.ps1"
)
var runPowerShell = func(script string) (string, error) {
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", strictPowerShell(script))
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
func strictPowerShell(script string) string {
return "$ErrorActionPreference = 'Stop'\n" + script
}
type schtasksManager struct{}
func newPlatformManager() (Manager, error) {
if _, err := exec.LookPath("powershell.exe"); err != nil {
return nil, fmt.Errorf("powershell.exe not found: Windows Task Scheduler management requires PowerShell")
}
return &schtasksManager{}, nil
}
func (*schtasksManager) Platform() string { return "schtasks" }
func (m *schtasksManager) Install(cfg Config) error {
if err := os.MkdirAll(DefaultDataDir(), 0755); err != nil {
return fmt.Errorf("create data dir: %w", err)
}
if err := os.MkdirAll(filepath.Dir(cfg.LogFile), 0755); err != nil {
return fmt.Errorf("create log dir: %w", err)
}
scriptPath := windowsTaskScriptPath()
if err := os.WriteFile(scriptPath, []byte(buildWindowsTaskScript(cfg)), 0644); err != nil {
return fmt.Errorf("write task script: %w", err)
}
if err := stopWindowsTask(); err != nil {
slog.Warn("schtasks: stop existing task failed", "error", err)
}
if err := deleteWindowsTask(); err != nil {
if windowsTaskMatchesAction(scriptPath) {
if err := m.Start(); err != nil {
return fmt.Errorf("start existing task: %w", err)
}
return nil
}
return err
}
if err := createWindowsTask(scriptPath); err != nil {
return err
}
if err := m.Start(); err != nil {
return fmt.Errorf("start task: %w", err)
}
return nil
}
func (*schtasksManager) Uninstall() error {
if err := stopWindowsTask(); err != nil {
slog.Warn("schtasks: stop task failed", "error", err)
}
if err := deleteWindowsTask(); err != nil {
return err
}
if err := os.Remove(windowsTaskScriptPath()); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove task script: %w", err)
}
return nil
}
func (*schtasksManager) Start() error {
return startWindowsTask()
}
func (*schtasksManager) Stop() error {
if err := stopWindowsTask(); err != nil {
return err
}
return nil
}
func (*schtasksManager) Restart() error {
if err := stopWindowsTask(); err != nil {
slog.Warn("schtasks: stop before restart failed", "error", err)
}
return startWindowsTask()
}
func (*schtasksManager) Status() (*Status, error) {
st := &Status{Platform: "schtasks"}
out, err := runPowerShell(fmt.Sprintf(`
$task = Get-ScheduledTask -TaskName %s -ErrorAction SilentlyContinue
if ($null -eq $task) { exit 1 }
Write-Output $task.State
`, powerShellLiteral(windowsTaskName)))
if err != nil {
return st, nil
}
st.Installed = true
taskStatus := strings.TrimSpace(out)
if strings.EqualFold(taskStatus, "Running") {
st.Running = true
}
return st, nil
}
func windowsTaskScriptPath() string {
return filepath.Join(DefaultDataDir(), windowsScriptName)
}
func windowsTaskAction(scriptPath string) string {
return fmt.Sprintf(`powershell.exe %s`, windowsTaskActionArgs(scriptPath))
}
func windowsTaskActionArgs(scriptPath string) string {
return fmt.Sprintf(`-WindowStyle Hidden -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%s"`, scriptPath)
}
func createWindowsTask(scriptPath string) error {
out, err := runPowerShell(fmt.Sprintf(`
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument %s
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited
Register-ScheduledTask -TaskName %s -Action $action -Trigger $trigger -Principal $principal -Force | Out-Null
`, powerShellLiteral(windowsTaskActionArgs(scriptPath)), powerShellLiteral(windowsTaskName)))
if err != nil {
return fmt.Errorf("register scheduled task: %s (%w)", out, err)
}
return nil
}
func windowsTaskMatchesAction(scriptPath string) bool {
out, err := runPowerShell(fmt.Sprintf(`
$task = Get-ScheduledTask -TaskName %s -ErrorAction SilentlyContinue
if ($null -eq $task) { exit 1 }
$expectedArgs = %s
foreach ($action in $task.Actions) {
if (($action.Execute -ieq 'powershell.exe') -and ($action.Arguments -eq $expectedArgs)) {
Write-Output 'true'
exit 0
}
}
exit 1
`, powerShellLiteral(windowsTaskName), powerShellLiteral(windowsTaskActionArgs(scriptPath))))
return err == nil && strings.EqualFold(strings.TrimSpace(out), "true")
}
func buildWindowsTaskScript(cfg Config) string {
var sb strings.Builder
sb.WriteString("$ErrorActionPreference = 'Stop'\r\n")
writePowerShellEnv(&sb, "CC_LOG_FILE", cfg.LogFile)
writePowerShellEnv(&sb, "CC_LOG_MAX_SIZE", strconv.FormatInt(cfg.LogMaxSize, 10))
if cfg.EnvPATH != "" {
writePowerShellEnv(&sb, "PATH", cfg.EnvPATH)
}
if len(cfg.EnvExtra) > 0 {
keys := make([]string, 0, len(cfg.EnvExtra))
for key := range cfg.EnvExtra {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
writePowerShellEnv(&sb, key, cfg.EnvExtra[key])
}
}
fmt.Fprintf(&sb, "Set-Location -LiteralPath %s\r\n", powerShellLiteral(cfg.WorkDir))
sb.WriteString("while ($true) {\r\n")
fmt.Fprintf(&sb, " & %s\r\n", powerShellLiteral(cfg.BinaryPath))
sb.WriteString(" $exitCode = $LASTEXITCODE\r\n")
sb.WriteString(" if ($exitCode -eq 0) { exit 0 }\r\n")
sb.WriteString(" Start-Sleep -Seconds 10\r\n")
sb.WriteString("}\r\n")
return sb.String()
}
func writePowerShellEnv(sb *strings.Builder, key, value string) {
fmt.Fprintf(sb, "$env:%s = %s\r\n", key, powerShellLiteral(value))
}
func powerShellLiteral(value string) string {
value = strings.ReplaceAll(value, "\r", " ")
value = strings.ReplaceAll(value, "\n", " ")
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func stopWindowsTask() error {
out, err := runPowerShell(fmt.Sprintf(`
$task = Get-ScheduledTask -TaskName %s -ErrorAction SilentlyContinue
if ($null -eq $task) { exit 0 }
if ($task.State -eq 'Running') {
Stop-ScheduledTask -TaskName %s
}
for ($i = 0; $i -lt 20; $i++) {
$task = Get-ScheduledTask -TaskName %s -ErrorAction SilentlyContinue
if ($null -eq $task -or $task.State -ne 'Running') { exit 0 }
Start-Sleep -Milliseconds 500
}
Write-Error 'scheduled task did not stop within timeout'
exit 1
`, powerShellLiteral(windowsTaskName), powerShellLiteral(windowsTaskName), powerShellLiteral(windowsTaskName)))
if err != nil {
return fmt.Errorf("stop scheduled task: %s (%w)", out, err)
}
return nil
}
func startWindowsTask() error {
out, err := runPowerShell(fmt.Sprintf(`
$task = Get-ScheduledTask -TaskName %s -ErrorAction SilentlyContinue
if ($null -eq $task) { Write-Error 'scheduled task not found'; exit 1 }
if ($task.State -ne 'Running') { Start-ScheduledTask -TaskName %s }
`, powerShellLiteral(windowsTaskName), powerShellLiteral(windowsTaskName)))
if err != nil {
return fmt.Errorf("start scheduled task: %s (%w)", out, err)
}
return nil
}
func deleteWindowsTask() error {
out, err := runPowerShell(fmt.Sprintf(`
$task = Get-ScheduledTask -TaskName %s -ErrorAction SilentlyContinue
if ($null -eq $task) { exit 0 }
Unregister-ScheduledTask -TaskName %s -Confirm:$false
`, powerShellLiteral(windowsTaskName), powerShellLiteral(windowsTaskName)))
if err != nil {
return fmt.Errorf("delete scheduled task: %s (%w)", out, err)
}
return nil
}
// CheckLinger is a no-op on Windows (always returns false).
func CheckLinger() (enabled bool, user string) {
return false, ""
}
+125
View File
@@ -0,0 +1,125 @@
//go:build windows
package daemon
import (
"strings"
"testing"
)
func TestStrictPowerShellStopsOnCmdletErrors(t *testing.T) {
script := strictPowerShell("Write-Output 'ok'")
if !strings.HasPrefix(script, "$ErrorActionPreference = 'Stop'\n") {
t.Fatalf("strictPowerShell() missing stop prelude:\n%s", script)
}
if !strings.Contains(script, "Write-Output 'ok'") {
t.Fatalf("strictPowerShell() missing original script:\n%s", script)
}
}
func TestBuildWindowsTaskScript(t *testing.T) {
cfg := Config{
BinaryPath: `C:\Program Files\cc-connect\cc-connect.exe`,
WorkDir: `C:\Users\me\.cc-connect`,
LogFile: `C:\Users\me\.cc-connect\logs\cc-connect.log`,
LogMaxSize: 10 * 1024 * 1024,
EnvPATH: `C:\Program Files\nodejs;C:\Users\me\AppData\Local\Programs`,
EnvExtra: map[string]string{
"HTTPS_PROXY": "http://127.0.0.1:7890",
"http_proxy": "http://127.0.0.1:7890",
},
}
script := buildWindowsTaskScript(cfg)
for _, want := range []string{
`$env:CC_LOG_FILE = 'C:\Users\me\.cc-connect\logs\cc-connect.log'`,
`$env:CC_LOG_MAX_SIZE = '10485760'`,
`$env:PATH = 'C:\Program Files\nodejs;C:\Users\me\AppData\Local\Programs'`,
`$env:HTTPS_PROXY = 'http://127.0.0.1:7890'`,
`$env:http_proxy = 'http://127.0.0.1:7890'`,
`Set-Location -LiteralPath 'C:\Users\me\.cc-connect'`,
`while ($true) {`,
`& 'C:\Program Files\cc-connect\cc-connect.exe'`,
`if ($exitCode -eq 0) { exit 0 }`,
`Start-Sleep -Seconds 10`,
} {
if !strings.Contains(script, want) {
t.Fatalf("script missing %q:\n%s", want, script)
}
}
}
func TestWindowsTaskActionRunsHidden(t *testing.T) {
got := windowsTaskAction(`C:\Users\me\.cc-connect\cc-connect-daemon.ps1`)
for _, want := range []string{
`powershell.exe`,
`-WindowStyle Hidden`,
`-NoProfile`,
`-NonInteractive`,
`-ExecutionPolicy Bypass`,
`-File "C:\Users\me\.cc-connect\cc-connect-daemon.ps1"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("windowsTaskAction() missing %q: %q", want, got)
}
}
}
func TestWindowsTaskCreateUsesLimitedInteractivePrincipal(t *testing.T) {
orig := runPowerShell
t.Cleanup(func() { runPowerShell = orig })
var script string
runPowerShell = func(s string) (string, error) {
script = s
return "", nil
}
if err := createWindowsTask(`C:\Users\me\.cc-connect\cc-connect-daemon.ps1`); err != nil {
t.Fatalf("createWindowsTask() error = %v", err)
}
for _, want := range []string{
`New-ScheduledTaskAction`,
`Register-ScheduledTask`,
`-LogonType Interactive`,
`-RunLevel Limited`,
`-WindowStyle Hidden`,
`C:\Users\me\.cc-connect\cc-connect-daemon.ps1`,
} {
if !strings.Contains(script, want) {
t.Fatalf("create script missing %q:\n%s", want, script)
}
}
}
func TestWindowsTaskMatchesActionRequiresExactAction(t *testing.T) {
orig := runPowerShell
t.Cleanup(func() { runPowerShell = orig })
var script string
runPowerShell = func(s string) (string, error) {
script = s
return "true", nil
}
if !windowsTaskMatchesAction(`C:\Users\me\.cc-connect\cc-connect-daemon.ps1`) {
t.Fatal("windowsTaskMatchesAction() = false, want true")
}
for _, want := range []string{
`$expectedArgs = '-WindowStyle Hidden -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\Users\me\.cc-connect\cc-connect-daemon.ps1"'`,
`$action.Execute -ieq 'powershell.exe'`,
`$action.Arguments -eq $expectedArgs`,
} {
if !strings.Contains(script, want) {
t.Fatalf("reuse check script missing %q:\n%s", want, script)
}
}
}
func TestPowerShellLiteralEscapesSingleQuotes(t *testing.T) {
got := powerShellLiteral(`C:\Users\O'Brien\.cc-connect`)
want := `'C:\Users\O''Brien\.cc-connect'`
if got != want {
t.Fatalf("powerShellLiteral() = %q, want %q", got, want)
}
}