mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
* feat(daemon): add CC_LOG_MAX_BACKUPS env var support (#1222) PR #1243 only addressed CC_LOG_MAX_SIZE while leaving the backup count hard-wired to one (.log.1). That still loses any post-mortem context older than one rotation, which is the same class of failure users reported on #1222. This change adds the matching knob so the post-mortem trail is configurable, with the same flag > env > default priority used for size. - daemon: add ParseLogBackups(s) (>=1, no unit suffix, error echoes input) and DefaultLogMaxBackups = 3. - daemon: extend RotatingWriter with maxBackups; rotateLocked walks the chain (delete .N, shift .(N-1) -> .N .. .1 -> .2, rename active -> .1, reopen) and a public Rotate() hook for tests/SIGHUP. - daemon: Config/Meta gain LogMaxBackups; Resolve() defaults to 3. - cmd/cc-connect: resolveLogMaxBackups + preScanLogMaxBackupsFlag + --log-max-backups flag; startup log now reports max_backups and its source. The rotating-writer setup happens before flag.Parse so the pre-scan keeps the flag effective there too. - daemon/launchd.go, daemon/systemd.go, daemon/windows.go: thread CC_LOG_MAX_BACKUPS through the service templates so a fresh install picks it up. - tests: TestParseLogBackups (19 subtests + error-echo), three new RotatingWriter tests (chain, disabled, fallback), four resolver tests + pre-scan tests in cmd/cc-connect. TestIssue1222_BackupRetention pins the new env-var behaviour as the regression test for the follow-up to #1222. * fix(daemon): silence errcheck on logrotate_test.go defer Close QA review (run 27109765660) flagged defer w.Close() in the 4 backup-related tests added by #1260. Wrap each in defer func() { _ = w.Close() }() so errcheck is satisfied without changing test semantics (temp-dir cleanup is best-effort). Verified locally: - golangci-lint --new-from-rev origin/main ./daemon/... -> 0 issues - go test -count=1 -tags no_web ./daemon/ ./cmd/cc-connect/ -> ok --------- Co-authored-by: cc-connect dev-claudecode <dev-claudecode@cc-connect.local> Co-authored-by: Claude <noreply@anthropic.com>
276 lines
8.3 KiB
Go
276 lines
8.3 KiB
Go
//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()
|
|
// 0644 has weak semantics on Windows; the file ACL is what matters.
|
|
// We still write 0600 so the file's POSIX bits do not advertise read
|
|
// access, and rely on the user's own profile ACLs for primary defense
|
|
// (the script lives under %USERPROFILE%\.cc-connect by default).
|
|
// WriteFile only applies perm on create, so Chmod the existing file
|
|
// after writing to harden reinstalls of pre-existing 0644 scripts.
|
|
if err := os.WriteFile(scriptPath, []byte(buildWindowsTaskScript(cfg)), 0600); err != nil {
|
|
return fmt.Errorf("write task script: %w", err)
|
|
}
|
|
if err := os.Chmod(scriptPath, 0600); err != nil {
|
|
return fmt.Errorf("chmod 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))
|
|
writePowerShellEnv(&sb, "CC_LOG_MAX_BACKUPS", strconv.Itoa(cfg.LogMaxBackups))
|
|
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 {
|
|
if !isValidEnvName(key) {
|
|
slog.Warn("daemon: windows: dropping invalid env name from EnvExtra",
|
|
"key", key)
|
|
continue
|
|
}
|
|
value := cfg.EnvExtra[key]
|
|
if value == "" {
|
|
continue
|
|
}
|
|
writePowerShellEnv(&sb, key, value)
|
|
}
|
|
}
|
|
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, ""
|
|
}
|