Add native Windows support (#91)

- Native Windows daemon using TCP loopback endpoints
- Direct-mode fallback for CLI/daemon compatibility
- Comment operations over RPC
- PowerShell installer script
- Go 1.24 requirement
- Cross-OS testing documented

Co-authored-by: danshapiro <danshapiro@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-c6230265-055f-4af1-9712-4481061886db
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-20 21:08:49 -07:00
parent 94a23cae39
commit a86f3e139e
58 changed files with 1707 additions and 729 deletions

View File

@@ -6,6 +6,7 @@ import (
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
@@ -17,6 +18,23 @@ import (
"github.com/steveyegge/beads/internal/types"
)
func makeSocketTempDir(t testing.TB) string {
t.Helper()
base := "/tmp"
if runtime.GOOS == "windows" {
base = os.TempDir()
} else if _, err := os.Stat(base); err != nil {
base = os.TempDir()
}
tmpDir, err := os.MkdirTemp(base, "bd-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
return tmpDir
}
func TestGetPIDFilePath(t *testing.T) {
tmpDir := t.TempDir()
oldDBPath := dbPath
@@ -32,7 +50,7 @@ func TestGetPIDFilePath(t *testing.T) {
if pidFile != expected {
t.Errorf("Expected PID file %s, got %s", expected, pidFile)
}
if _, err := os.Stat(filepath.Dir(pidFile)); os.IsNotExist(err) {
t.Error("Expected beads directory to be created")
}
@@ -40,37 +58,43 @@ func TestGetPIDFilePath(t *testing.T) {
func TestGetLogFilePath(t *testing.T) {
tests := []struct {
name string
userPath string
dbPath string
expected string
name string
set func(t *testing.T) (userPath, dbFile, expected string)
}{
{
name: "user specified path",
userPath: "/var/log/bd.log",
dbPath: "/tmp/.beads/test.db",
expected: "/var/log/bd.log",
name: "user specified path",
set: func(t *testing.T) (string, string, string) {
userDir := t.TempDir()
dbDir := t.TempDir()
userPath := filepath.Join(userDir, "bd.log")
dbFile := filepath.Join(dbDir, ".beads", "test.db")
return userPath, dbFile, userPath
},
},
{
name: "default with dbPath",
userPath: "",
dbPath: "/tmp/.beads/test.db",
expected: "/tmp/.beads/daemon.log",
name: "default with dbPath",
set: func(t *testing.T) (string, string, string) {
dbDir := t.TempDir()
dbFile := filepath.Join(dbDir, ".beads", "test.db")
return "", dbFile, filepath.Join(dbDir, ".beads", "daemon.log")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
userPath, dbFile, expected := tt.set(t)
oldDBPath := dbPath
defer func() { dbPath = oldDBPath }()
dbPath = tt.dbPath
dbPath = dbFile
result, err := getLogFilePath(tt.userPath, false) // test local daemon
result, err := getLogFilePath(userPath, false) // test local daemon
if err != nil {
t.Fatalf("getLogFilePath failed: %v", err)
}
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
if result != expected {
t.Errorf("Expected %s, got %s", expected, result)
}
})
}
@@ -318,7 +342,7 @@ func TestDaemonConcurrentOperations(t *testing.T) {
defer testStore.Close()
ctx := context.Background()
numGoroutines := 10
errChan := make(chan error, numGoroutines)
var wg sync.WaitGroup
@@ -327,7 +351,7 @@ func TestDaemonConcurrentOperations(t *testing.T) {
wg.Add(1)
go func(n int) {
defer wg.Done()
issue := &types.Issue{
Title: fmt.Sprintf("Concurrent issue %d", n),
Description: "Test concurrent operations",
@@ -335,12 +359,12 @@ func TestDaemonConcurrentOperations(t *testing.T) {
Priority: 1,
IssueType: types.TypeTask,
}
if err := testStore.CreateIssue(ctx, issue, "test"); err != nil {
errChan <- fmt.Errorf("goroutine %d create failed: %w", n, err)
return
}
updates := map[string]interface{}{
"status": types.StatusInProgress,
}
@@ -373,13 +397,9 @@ func TestDaemonSocketCleanupOnShutdown(t *testing.T) {
t.Skip("Skipping integration test in short mode")
}
// Use /tmp directly to avoid macOS socket path length limits (104 chars)
tmpDir, err := os.MkdirTemp("/tmp", "bd-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
tmpDir := makeSocketTempDir(t)
defer os.RemoveAll(tmpDir)
socketPath := filepath.Join(tmpDir, "test.sock")
testDBPath := filepath.Join(tmpDir, "test.db")
@@ -391,7 +411,7 @@ func TestDaemonSocketCleanupOnShutdown(t *testing.T) {
server := newMockDaemonServer(socketPath, testStore)
ctx, cancel := context.WithCancel(context.Background())
serverDone := make(chan error, 1)
go func() {
serverDone <- server.Start(ctx)
@@ -401,7 +421,7 @@ func TestDaemonSocketCleanupOnShutdown(t *testing.T) {
if err := server.WaitReady(2 * time.Second); err != nil {
t.Fatal(err)
}
// Verify socket exists (with retry for filesystem sync)
var socketFound bool
var lastErr error
@@ -419,7 +439,7 @@ func TestDaemonSocketCleanupOnShutdown(t *testing.T) {
}
cancel()
select {
case <-serverDone:
case <-time.After(2 * time.Second):
@@ -440,13 +460,9 @@ func TestDaemonServerStartFailureSocketExists(t *testing.T) {
t.Skip("Skipping integration test in short mode")
}
// Use /tmp directly to avoid macOS socket path length limits (104 chars)
tmpDir, err := os.MkdirTemp("/tmp", "bd-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
tmpDir := makeSocketTempDir(t)
defer os.RemoveAll(tmpDir)
socketPath := filepath.Join(tmpDir, "test.sock")
testDBPath := filepath.Join(tmpDir, "test.db")
@@ -462,12 +478,12 @@ func TestDaemonServerStartFailureSocketExists(t *testing.T) {
defer cancel1()
go server1.Start(ctx1)
// Wait for server to be ready
if err := server1.WaitReady(2 * time.Second); err != nil {
t.Fatal(err)
}
// Verify socket exists (with retry for filesystem sync)
var socketFound bool
for i := 0; i < 10; i++ {
@@ -514,13 +530,9 @@ func TestDaemonGracefulShutdown(t *testing.T) {
t.Skip("Skipping integration test in short mode")
}
// Use /tmp directly to avoid macOS socket path length limits (104 chars)
tmpDir, err := os.MkdirTemp("/tmp", "bd-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
tmpDir := makeSocketTempDir(t)
defer os.RemoveAll(tmpDir)
socketPath := filepath.Join(tmpDir, "test.sock")
testDBPath := filepath.Join(tmpDir, "test.db")
@@ -532,10 +544,10 @@ func TestDaemonGracefulShutdown(t *testing.T) {
server := newMockDaemonServer(socketPath, testStore)
ctx, cancel := context.WithCancel(context.Background())
serverDone := make(chan error, 1)
startTime := time.Now()
go func() {
serverDone <- server.Start(ctx)
}()
@@ -547,21 +559,21 @@ func TestDaemonGracefulShutdown(t *testing.T) {
select {
case err := <-serverDone:
shutdownDuration := time.Since(startTime)
if err != nil && err != context.Canceled {
t.Errorf("Server returned unexpected error: %v", err)
}
if shutdownDuration > 3*time.Second {
t.Errorf("Shutdown took too long: %v", shutdownDuration)
}
testStore.Close()
if _, err := os.Stat(socketPath); !os.IsNotExist(err) {
t.Error("Socket should be cleaned up after graceful shutdown")
}
case <-time.After(5 * time.Second):
t.Fatal("Server did not shut down gracefully within timeout")
}
@@ -619,10 +631,10 @@ func (s *mockDaemonServer) Start(ctx context.Context) error {
s.ready <- startErr
return startErr
}
// Signal that server is ready
s.ready <- nil
// Set up cleanup before accepting connections
defer func() {
s.listener.Close()