diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 866c8c74d2..7283cc405e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,13 +38,30 @@ jobs: include: - os: ubuntu-latest platform: linux-x64 + goos: linux + goarch: amd64 + - os: ubuntu-latest + platform: linux-arm64 + goos: linux + goarch: arm64 + cc: aarch64-linux-gnu-gcc - os: macos-13 platform: macos-x64 + goos: darwin + goarch: amd64 - os: macos-14 platform: macos-arm64 + goos: darwin + goarch: arm64 - os: windows-latest platform: windows-x64 + goos: windows + goarch: amd64 runs-on: ${{ matrix.os }} + env: + CGO_ENABLED: 1 + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v6 @@ -56,8 +73,16 @@ jobs: name: web-bundle path: dist/web + - name: Install cross-compilation tools (linux-arm64) + if: matrix.platform == 'linux-arm64' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + - name: Build backend binaries (unix) if: runner.os != 'Windows' + env: + CC: ${{ matrix.cc || '' }} run: | cd apps/backend mkdir -p bin @@ -68,6 +93,9 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | + # Ensure MSYS2 MinGW gcc is available for CGO + $env:Path = "C:\msys64\mingw64\bin;" + $env:Path + gcc --version Set-Location apps/backend New-Item -ItemType Directory -Force -Path bin | Out-Null go build -o bin/kandev.exe ./cmd/kandev diff --git a/Makefile b/Makefile index 6b66eda241..95e2a38f10 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,15 @@ APPS_DIR := apps PNPM := pnpm MAKE := make +# Cross-platform commands +ifeq ($(OS),Windows_NT) + RM = cmd /c del /s /q + RMDIR = cmd /c rmdir /s /q +else + RM = rm -f + RMDIR = rm -rf +endif + # Colors for terminal output RESET := \033[0m BOLD := \033[1m @@ -251,11 +260,11 @@ clean-backend: .PHONY: clean-web clean-web: @printf "$(CYAN)Cleaning web artifacts...$(RESET)\n" - @rm -rf $(WEB_DIR)/.next $(APPS_DIR)/node_modules - @rm -rf $(APPS_DIR)/packages/*/node_modules + @$(RMDIR) $(WEB_DIR)/.next $(APPS_DIR)/node_modules + @$(RMDIR) $(APPS_DIR)/packages/*/node_modules .PHONY: clean-db clean-db: @printf "$(CYAN)Removing local SQLite database...$(RESET)\n" - @rm -f kandev.db kandev.db-wal kandev.db-shm \ + @$(RM) kandev.db kandev.db-wal kandev.db-shm \ $(BACKEND_DIR)/kandev.db $(BACKEND_DIR)/kandev.db-wal $(BACKEND_DIR)/kandev.db-shm diff --git a/apps/backend/Makefile b/apps/backend/Makefile index c44374f2fb..0865d45fb4 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -7,10 +7,21 @@ GO := go GOFLAGS := -v LDFLAGS := -s -w +# Cross-platform commands +ifeq ($(OS),Windows_NT) + RM = cmd /c del /s /q + RMDIR = cmd /c rmdir /s /q + MKDIR = cmd /c mkdir +else + RM = rm -f + RMDIR = rm -rf + MKDIR = mkdir -p +endif + # Build info VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") -BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') +BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || powershell -c "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'" 2>/dev/null || echo "unknown") LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) @@ -22,13 +33,13 @@ all: build ## Build the mock-agent binary for testing build-mock-agent: @echo "Building mock-agent..." - @mkdir -p $(BUILD_DIR) + @$(MKDIR) $(BUILD_DIR) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/mock-agent ./cmd/mock-agent ## Build the unified kandev binary build: build-agentctl build-mock-agent @echo "Building $(BINARY_NAME)..." - @mkdir -p $(BUILD_DIR) + @$(MKDIR) $(BUILD_DIR) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/kandev ## Build all binaries (unified + individual services + agentctl) @@ -39,13 +50,13 @@ build-all: build build-agentctl ## Build the agentctl sidecar binary (includes embedded MCP server) build-agentctl: @echo "Building agentctl..." - @mkdir -p $(BUILD_DIR) + @$(MKDIR) $(BUILD_DIR) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl ./cmd/agentctl ## Clean build artifacts clean: @echo "Cleaning..." - @rm -rf $(BUILD_DIR) + @$(RMDIR) $(BUILD_DIR) $(GO) clean ## Run the application @@ -97,7 +108,7 @@ deps: ## Build for multiple platforms build-cross: @echo "Building for multiple platforms..." - @mkdir -p $(BUILD_DIR) + @$(MKDIR) $(BUILD_DIR) GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/kandev GOOS=darwin GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 ./cmd/kandev GOOS=darwin GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/kandev diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 7c9ebfda55..9557cdd1fd 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -5,6 +5,7 @@ go 1.24.0 toolchain go1.24.11 require ( + github.com/UserExistsError/conpty v0.1.4 github.com/coder/acp-go-sdk v0.6.3 github.com/creack/pty v1.1.21 github.com/docker/docker v28.5.2+incompatible diff --git a/apps/backend/go.sum b/apps/backend/go.sum index f65fd66408..e523842671 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -42,6 +42,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJyBS02to= +github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= @@ -479,6 +481,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/apps/backend/internal/agentctl/server/process/cmdline.go b/apps/backend/internal/agentctl/server/process/cmdline.go new file mode 100644 index 0000000000..8b646bc9be --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/cmdline.go @@ -0,0 +1,75 @@ +package process + +import "strings" + +// escapeArg rewrites a command-line argument following the MSDN +// CommandLineToArgvW parsing rules (same algorithm as Go's +// syscall.EscapeArg on Windows): +// +// - every backslash (\) is doubled, but only if immediately +// followed by a double quote ("); +// - every double quote (") is escaped with a backslash (\); +// - finally, the argument is wrapped in double quotes only if +// it contains spaces or tabs. +// - an empty string becomes "" (two double-quote characters). +func escapeArg(s string) string { + if len(s) == 0 { + return `""` + } + + needsBackslash := false + hasSpace := false + for i := 0; i < len(s); i++ { + switch s[i] { + case '"', '\\': + needsBackslash = true + case ' ', '\t': + hasSpace = true + } + } + + if !needsBackslash && !hasSpace { + return s + } + if !needsBackslash { + return `"` + s + `"` + } + + var b []byte + if hasSpace { + b = append(b, '"') + } + slashes := 0 + for i := 0; i < len(s); i++ { + c := s[i] + switch c { + default: + slashes = 0 + case '\\': + slashes++ + case '"': + for ; slashes > 0; slashes-- { + b = append(b, '\\') + } + b = append(b, '\\') + } + b = append(b, c) + } + if hasSpace { + for ; slashes > 0; slashes-- { + b = append(b, '\\') + } + b = append(b, '"') + } + return string(b) +} + +// buildCmdLine joins arguments into a single command-line string +// with proper quoting for Windows CreateProcess. +func buildCmdLine(args []string) string { + escaped := make([]string, len(args)) + for i, arg := range args { + escaped[i] = escapeArg(arg) + } + return strings.Join(escaped, " ") +} diff --git a/apps/backend/internal/agentctl/server/process/cmdline_test.go b/apps/backend/internal/agentctl/server/process/cmdline_test.go new file mode 100644 index 0000000000..ba4c4b9811 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/cmdline_test.go @@ -0,0 +1,52 @@ +package process + +import "testing" + +func TestEscapeArg(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", `""`}, + {"simple", "hello", "hello"}, + {"with space", "hello world", `"hello world"`}, + {"with tab", "hello\tworld", `"hello` + "\t" + `world"`}, + {"with quote", `say "hi"`, `"say \"hi\""`}, + {"backslash no quote", `a\b`, `a\b`}, + {"backslash before quote", `a\"`, `a\\\"` }, + {"trailing backslash with space", `a b\`, `"a b\\"`}, + {"multiple trailing backslashes with space", `a b\\`, `"a b\\\\"`}, + {"only backslashes", `\\`, `\\`}, + {"backslash then quote", `\\"`, `\\\\\"` }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := escapeArg(tt.in) + if got != tt.want { + t.Errorf("escapeArg(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestBuildCmdLine(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {"single", []string{"cmd.exe"}, "cmd.exe"}, + {"simple args", []string{"git", "status"}, "git status"}, + {"arg with space", []string{"C:\\Program Files\\app.exe", "-f"}, `"C:\Program Files\app.exe" -f`}, + {"arg with quote", []string{"echo", `"hello"`}, `echo \"hello\"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildCmdLine(tt.args) + if got != tt.want { + t.Errorf("buildCmdLine(%v) = %q, want %q", tt.args, got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/agentctl/server/process/interactive_runner.go b/apps/backend/internal/agentctl/server/process/interactive_runner.go index f51646271d..d84294c613 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_runner.go +++ b/apps/backend/internal/agentctl/server/process/interactive_runner.go @@ -11,16 +11,13 @@ import ( "context" "fmt" "io" - "os" "os/exec" "regexp" "sort" "strings" "sync" - "syscall" "time" - "github.com/creack/pty" "github.com/google/uuid" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/logger" @@ -70,7 +67,7 @@ type DirectOutputWriter interface { type interactiveProcess struct { info InteractiveProcessInfo cmd *exec.Cmd - ptmx *os.File // PTY master file descriptor (using creack/pty) + ptmx PtyHandle // PTY handle (Unix: creack/pty, Windows: ConPTY) buffer *ringBuffer // Turn detection @@ -330,12 +327,9 @@ func (r *InteractiveRunner) startProcess(proc *interactiveProcess, cols, rows in // Note: Do NOT set Setpgid when using PTY - it conflicts with terminal control // The PTY session handles process group management - // Start process in PTY with exact dimensions from frontend using creack/pty - // This is the battle-tested PTY library that properly handles resize/SIGWINCH - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{ - Cols: uint16(cols), - Rows: uint16(rows), - }) + // Start process in PTY with exact dimensions from frontend + // Unix: creack/pty, Windows: ConPTY + ptmx, err := startPTYWithSize(cmd, cols, rows) if err != nil { return fmt.Errorf("failed to start pty: %w", err) } @@ -473,7 +467,7 @@ func (r *InteractiveRunner) Stop(ctx context.Context, processID string) error { // Terminate the process directly (PTY handles its own session management) if proc.cmd != nil && proc.cmd.Process != nil { - _ = proc.cmd.Process.Signal(syscall.SIGTERM) + _ = terminateProcess(proc.cmd.Process) // Wait for the wait() goroutine to finish (it calls cmd.Wait). // If it doesn't exit in time, force-kill the process. @@ -515,14 +509,18 @@ func (r *InteractiveRunner) GetBySession(sessionID string) (*InteractiveProcessI } // isProcessAlive checks if the underlying OS process is still running. +// Uses a non-blocking check on waitDone which is closed when cmd.Wait returns. // Must be called with proc.mu held. func (r *InteractiveRunner) isProcessAlive(proc *interactiveProcess) bool { - if proc.cmd != nil && proc.cmd.Process != nil { - // Signal 0 checks if process exists without sending a signal - err := proc.cmd.Process.Signal(syscall.Signal(0)) - return err == nil + if proc.cmd == nil || proc.cmd.Process == nil { + return false + } + select { + case <-proc.waitDone: + return false + default: + return true } - return false } // IsProcessRunning checks if a process with the given ID exists and is running. @@ -605,13 +603,10 @@ func (r *InteractiveRunner) ResizeBySession(sessionID string, cols, rows uint16) statusTracker := proc.statusTracker proc.mu.Unlock() - // If process started, resize the PTY using creack/pty's Setsize - // This properly sets the window size and sends SIGWINCH to the process + // If process started, resize the PTY + // On Unix this sends SIGWINCH; on Windows it updates the ConPTY dimensions if ptyInstance != nil { - if err := pty.Setsize(ptyInstance, &pty.Winsize{ - Cols: cols, - Rows: rows, - }); err != nil { + if err := ptyInstance.Resize(cols, rows); err != nil { return fmt.Errorf("failed to resize PTY: %w", err) } } @@ -835,25 +830,14 @@ func (r *InteractiveRunner) handleStateChange(proc *interactiveProcess, state Ag // 3. Adding a timeout here would leave the process unreachable and create leaks func (r *InteractiveRunner) wait(proc *interactiveProcess) { defer close(proc.waitDone) - err := proc.cmd.Wait() - exitCode := 0 + + proc.mu.Lock() + ptyHandle := proc.ptmx + proc.mu.Unlock() + + exitCode, signalName, err := waitPtyProcess(proc.cmd, ptyHandle) status := types.ProcessStatusExited - var signalName string if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - if waitStatus, ok := exitErr.Sys().(syscall.WaitStatus); ok { - if waitStatus.Signaled() { - signalName = waitStatus.Signal().String() - exitCode = 128 + int(waitStatus.Signal()) - } else { - exitCode = waitStatus.ExitStatus() - } - } else { - exitCode = 1 - } - } else { - exitCode = 1 - } status = types.ProcessStatusFailed } @@ -1354,18 +1338,9 @@ func (r *InteractiveRunner) StartUserShell(ctx context.Context, sessionID, termi initialCommand = existingEntry.InitialCommand } - // Determine shell to use - shell := preferredShell - if shell == "" { - shell = os.Getenv("SHELL") - } - if shell == "" { - shell = "/bin/sh" - } - req := InteractiveStartRequest{ SessionID: sessionID, - Command: []string{shell, "-l"}, // Login shell + Command: defaultShellCommand(preferredShell), WorkingDir: workingDir, InitialCommand: initialCommand, DisableTurnDetection: true, // User shells must not trigger turn complete / MarkReady @@ -1403,7 +1378,7 @@ func (r *InteractiveRunner) StartUserShell(ctx context.Context, sessionID, termi zap.String("session_id", sessionID), zap.String("terminal_id", terminalID), zap.String("process_id", info.ID), - zap.String("shell", shell), + zap.String("shell", req.Command[0]), zap.String("working_dir", workingDir), zap.String("label", opts.Label), zap.String("initial_command", opts.InitialCommand)) @@ -1544,10 +1519,7 @@ func (r *InteractiveRunner) ResizeUserShell(sessionID, terminalID string, cols, proc.mu.Unlock() if ptyInstance != nil { - if err := pty.Setsize(ptyInstance, &pty.Winsize{ - Cols: cols, - Rows: rows, - }); err != nil { + if err := ptyInstance.Resize(cols, rows); err != nil { return fmt.Errorf("failed to resize PTY: %w", err) } } diff --git a/apps/backend/internal/agentctl/server/process/process_signal_unix.go b/apps/backend/internal/agentctl/server/process/process_signal_unix.go new file mode 100644 index 0000000000..3594ea14dc --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/process_signal_unix.go @@ -0,0 +1,35 @@ +//go:build !windows + +package process + +import ( + "os" + "os/exec" + "syscall" +) + +// terminateProcess sends SIGTERM to the process for graceful shutdown. +func terminateProcess(p *os.Process) error { + return p.Signal(syscall.SIGTERM) +} + +// waitPtyProcess waits for the PTY process to exit and returns exit info. +// On Unix, uses cmd.Wait() which inspects WaitStatus for signal information. +func waitPtyProcess(cmd *exec.Cmd, _ PtyHandle) (exitCode int, signalName string, err error) { + err = cmd.Wait() + if err == nil { + return 0, "", nil + } + exitErr, ok := err.(*exec.ExitError) + if !ok { + return 1, "", err + } + waitStatus, ok := exitErr.Sys().(syscall.WaitStatus) + if !ok { + return 1, "", err + } + if waitStatus.Signaled() { + return 128 + int(waitStatus.Signal()), waitStatus.Signal().String(), err + } + return waitStatus.ExitStatus(), "", err +} diff --git a/apps/backend/internal/agentctl/server/process/process_signal_windows.go b/apps/backend/internal/agentctl/server/process/process_signal_windows.go new file mode 100644 index 0000000000..2c768beb3f --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/process_signal_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +package process + +import ( + "os" + "os/exec" +) + +// terminateProcess kills the process on Windows. +// Windows does not support SIGTERM; process termination is immediate. +func terminateProcess(p *os.Process) error { + return p.Kill() +} + +// waitPtyProcess waits for the PTY process to exit and returns exit info. +// On Windows, uses cmd.Process.Wait() since the process may have been started +// via ConPTY rather than cmd.Start(). +func waitPtyProcess(cmd *exec.Cmd, _ PtyHandle) (exitCode int, signalName string, err error) { + state, err := cmd.Process.Wait() + if err != nil { + return 1, "", err + } + code := state.ExitCode() + if code != 0 { + return code, "", &exec.ExitError{ProcessState: state} + } + return 0, "", nil +} diff --git a/apps/backend/internal/agentctl/server/process/pty_handle.go b/apps/backend/internal/agentctl/server/process/pty_handle.go new file mode 100644 index 0000000000..c3759854d9 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/pty_handle.go @@ -0,0 +1,12 @@ +package process + +import "io" + +// PtyHandle abstracts PTY operations across Unix and Windows. +// On Unix, this wraps creack/pty (*os.File). +// On Windows, this wraps Windows ConPTY. +type PtyHandle interface { + io.ReadWriteCloser + // Resize changes the PTY window size. + Resize(cols, rows uint16) error +} diff --git a/apps/backend/internal/agentctl/server/process/pty_unix.go b/apps/backend/internal/agentctl/server/process/pty_unix.go new file mode 100644 index 0000000000..97e80b68ae --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/pty_unix.go @@ -0,0 +1,36 @@ +//go:build !windows + +package process + +import ( + "os" + "os/exec" + + "github.com/creack/pty" +) + +// unixPTY wraps a Unix PTY master file descriptor. +type unixPTY struct { + f *os.File +} + +func (p *unixPTY) Read(b []byte) (int, error) { return p.f.Read(b) } +func (p *unixPTY) Write(b []byte) (int, error) { return p.f.Write(b) } +func (p *unixPTY) Close() error { return p.f.Close() } + +func (p *unixPTY) Resize(cols, rows uint16) error { + return pty.Setsize(p.f, &pty.Winsize{Cols: cols, Rows: rows}) +} + +// startPTYWithSize starts the command in a Unix PTY with the given dimensions. +// The command is started via pty.StartWithSize which calls cmd.Start() internally. +func startPTYWithSize(cmd *exec.Cmd, cols, rows int) (PtyHandle, error) { + f, err := pty.StartWithSize(cmd, &pty.Winsize{ + Cols: uint16(cols), + Rows: uint16(rows), + }) + if err != nil { + return nil, err + } + return &unixPTY{f: f}, nil +} diff --git a/apps/backend/internal/agentctl/server/process/pty_windows.go b/apps/backend/internal/agentctl/server/process/pty_windows.go new file mode 100644 index 0000000000..a390a319ed --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/pty_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +package process + +import ( + "fmt" + "os" + "os/exec" + + "github.com/UserExistsError/conpty" +) + +// windowsPTY wraps a Windows ConPTY pseudo-console. +type windowsPTY struct { + cpty *conpty.ConPty +} + +func (p *windowsPTY) Read(b []byte) (int, error) { return p.cpty.Read(b) } +func (p *windowsPTY) Write(b []byte) (int, error) { return p.cpty.Write(b) } +func (p *windowsPTY) Close() error { return p.cpty.Close() } + +func (p *windowsPTY) Resize(cols, rows uint16) error { + return p.cpty.Resize(int(cols), int(rows)) +} + +// startPTYWithSize starts the command in a Windows ConPTY with the given dimensions. +// ConPTY manages process creation internally, so this builds a command line from +// the exec.Cmd and starts the process via ConPTY. After this call, cmd.Process +// is set so callers can manage the process lifecycle. +func startPTYWithSize(cmd *exec.Cmd, cols, rows int) (PtyHandle, error) { + cmdLine := buildCmdLine(cmd.Args) + if len(cmd.Args) == 0 { + cmdLine = escapeArg(cmd.Path) + } + + opts := []conpty.ConPtyOption{ + conpty.ConPtyDimensions(cols, rows), + } + if cmd.Dir != "" { + opts = append(opts, conpty.ConPtyWorkDir(cmd.Dir)) + } + + // Pass environment variables directly to the child process via ConPTY. + if cmd.Env != nil { + opts = append(opts, conpty.ConPtyEnv(cmd.Env)) + } + + cpty, err := conpty.Start(cmdLine, opts...) + if err != nil { + return nil, err + } + + // Set cmd.Process so callers can use PID, Kill, Wait, etc. + pid := cpty.Pid() + proc, err := os.FindProcess(int(pid)) + if err != nil { + _ = cpty.Close() + return nil, fmt.Errorf("failed to find ConPTY process %d: %w", pid, err) + } + cmd.Process = proc + + return &windowsPTY{cpty: cpty}, nil +} + diff --git a/apps/backend/internal/agentctl/server/process/runner.go b/apps/backend/internal/agentctl/server/process/runner.go index 72c479bef4..01468b35e7 100644 --- a/apps/backend/internal/agentctl/server/process/runner.go +++ b/apps/backend/internal/agentctl/server/process/runner.go @@ -229,8 +229,9 @@ func (r *ProcessRunner) Start(ctx context.Context, req StartProcessRequest) (*Pr id := uuid.New().String() now := time.Now().UTC() - // Use "sh -lc" to execute command with login shell environment - cmd := exec.CommandContext(ctx, "sh", "-lc", req.Command) + // Execute command through the system shell (Unix: sh -lc, Windows: cmd /c) + prog, shellArgs := shellExecArgs(req.Command) + cmd := exec.CommandContext(ctx, prog, shellArgs...) if req.WorkingDir != "" { cmd.Dir = req.WorkingDir } diff --git a/apps/backend/internal/agentctl/server/process/shell_test.go b/apps/backend/internal/agentctl/server/process/shell_test.go new file mode 100644 index 0000000000..c9ce9d75eb --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/shell_test.go @@ -0,0 +1,48 @@ +package process + +import ( + "runtime" + "testing" +) + +func TestDefaultShellCommand_CustomShell(t *testing.T) { + cmd := defaultShellCommand("/usr/local/bin/fish") + if cmd[0] != "/usr/local/bin/fish" { + t.Errorf("expected preferred shell as first element, got %q", cmd[0]) + } +} + +func TestDefaultShellCommand_EmptyPreferred(t *testing.T) { + cmd := defaultShellCommand("") + if len(cmd) == 0 { + t.Fatal("expected non-empty command") + } + // On Unix, should fall back to $SHELL or /bin/sh with -l flag + // On Windows, should fall back to %COMSPEC% or powershell.exe + if runtime.GOOS != "windows" { + if cmd[len(cmd)-1] != "-l" { + t.Errorf("expected login flag -l on Unix, got %v", cmd) + } + } +} + +func TestShellExecArgs(t *testing.T) { + prog, args := shellExecArgs("echo hello") + if prog == "" { + t.Fatal("expected non-empty program") + } + if len(args) == 0 { + t.Fatal("expected non-empty args") + } + // The command string should appear in the args + found := false + for _, a := range args { + if a == "echo hello" { + found = true + break + } + } + if !found { + t.Errorf("expected command string in args, got prog=%q args=%v", prog, args) + } +} diff --git a/apps/backend/internal/agentctl/server/process/shell_unix.go b/apps/backend/internal/agentctl/server/process/shell_unix.go new file mode 100644 index 0000000000..eb77c6c92e --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/shell_unix.go @@ -0,0 +1,25 @@ +//go:build !windows + +package process + +import "os" + +// defaultShellCommand returns the command and args for starting an interactive login shell. +// On Unix, uses $SHELL (or /bin/sh) with the -l (login) flag. +func defaultShellCommand(preferredShell string) []string { + shell := preferredShell + if shell == "" { + shell = os.Getenv("SHELL") + } + if shell == "" { + shell = "/bin/sh" + } + return []string{shell, "-l"} +} + +// shellExecArgs returns the program and arguments needed to execute a command +// string through the system shell. +// On Unix: sh -lc "command" +func shellExecArgs(command string) (prog string, args []string) { + return "sh", []string{"-lc", command} +} diff --git a/apps/backend/internal/agentctl/server/process/shell_windows.go b/apps/backend/internal/agentctl/server/process/shell_windows.go new file mode 100644 index 0000000000..567d5c3d72 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/shell_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package process + +import "os" + +// defaultShellCommand returns the command and args for starting an interactive shell. +// On Windows, uses %COMSPEC% (typically cmd.exe) or falls back to powershell.exe. +// No -l flag since Windows shells don't use login shell mode. +func defaultShellCommand(preferredShell string) []string { + shell := preferredShell + if shell == "" { + shell = os.Getenv("COMSPEC") + } + if shell == "" { + shell = "powershell.exe" + } + return []string{shell} +} + +// shellExecArgs returns the program and arguments needed to execute a command +// string through the system shell. +// On Windows: cmd /c "command" +func shellExecArgs(command string) (prog string, args []string) { + return "cmd", []string{"/c", command} +} diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index c9c479f7d7..2ccc32591d 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -5,6 +5,8 @@ package config import ( "fmt" "os" + "path/filepath" + "runtime" "strings" "time" @@ -174,13 +176,13 @@ func setDefaults(v *viper.Viper) { v.SetDefault("nats.clientId", "kandev-client") v.SetDefault("nats.maxReconnects", 10) - // Docker defaults + // Docker defaults — platform-aware host and volume path v.SetDefault("docker.enabled", true) // Docker runtime enabled by default if Docker is available - v.SetDefault("docker.host", "unix:///var/run/docker.sock") + v.SetDefault("docker.host", DefaultDockerHost()) v.SetDefault("docker.apiVersion", "1.41") v.SetDefault("docker.tlsVerify", false) v.SetDefault("docker.defaultNetwork", "kandev-network") - v.SetDefault("docker.volumeBasePath", "/var/lib/kandev/volumes") + v.SetDefault("docker.volumeBasePath", defaultDockerVolumePath()) // Agent defaults (runtime selection is now per-task based on executor type) v.SetDefault("agent.standaloneHost", "localhost") @@ -209,6 +211,30 @@ func setDefaults(v *viper.Viper) { v.SetDefault("worktree.cleanupOnRemove", true) } +// DefaultDockerHost returns the platform-appropriate Docker socket path. +// Respects DOCKER_HOST env var as override (standard Docker convention). +func DefaultDockerHost() string { + if host := os.Getenv("DOCKER_HOST"); host != "" { + return host + } + if runtime.GOOS == "windows" { + return "npipe:////./pipe/docker_engine" + } + return "unix:///var/run/docker.sock" +} + +// defaultDockerVolumePath returns the platform-appropriate volume base path. +func defaultDockerVolumePath() string { + if runtime.GOOS == "windows" { + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local") + } + return filepath.Join(localAppData, "kandev", "volumes") + } + return "/var/lib/kandev/volumes" +} + // Load reads configuration from environment variables, config file, and defaults. // Environment variables use the prefix KANDEV_ with snake_case naming. // Config file should be named config.yaml and placed in the current directory or /etc/kandev/. diff --git a/apps/backend/internal/task/repository/sqlite/defaults.go b/apps/backend/internal/task/repository/sqlite/defaults.go index 5bebba47cf..f8addbc16a 100644 --- a/apps/backend/internal/task/repository/sqlite/defaults.go +++ b/apps/backend/internal/task/repository/sqlite/defaults.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" + "github.com/kandev/kandev/internal/common/config" commonsqlite "github.com/kandev/kandev/internal/common/sqlite" "github.com/kandev/kandev/internal/task/models" ) @@ -137,7 +138,7 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error { isSystem: false, resumable: true, config: map[string]string{ - "docker_host": "unix:///var/run/docker.sock", + "docker_host": config.DefaultDockerHost(), }, }, } diff --git a/apps/cli/README_internal.md b/apps/cli/README_internal.md index 42d9ebe958..94d683f40a 100644 --- a/apps/cli/README_internal.md +++ b/apps/cli/README_internal.md @@ -141,5 +141,6 @@ scripts/release/publish-launcher.sh 0.1.0 | macOS (Apple Silicon) | `kandev-macos-arm64.zip` | | macOS (Intel) | `kandev-macos-x64.zip` | | Linux (x64) | `kandev-linux-x64.zip` | +| Linux (ARM64) | `kandev-linux-arm64.zip` | | Windows (x64) | `kandev-windows-x64.zip` | -| Windows (ARM64) | `kandev-windows-arm64.zip` | +| Windows (ARM64) | Falls back to `windows-x64` (x64 emulation) | diff --git a/apps/cli/src/platform.ts b/apps/cli/src/platform.ts index fb7740bac8..7a9d670751 100644 --- a/apps/cli/src/platform.ts +++ b/apps/cli/src/platform.ts @@ -5,10 +5,10 @@ const isWindows = process.platform === "win32"; export type PlatformArch = "x64" | "arm64"; export type PlatformDir = | "linux-x64" + | "linux-arm64" | "macos-x64" | "macos-arm64" - | "windows-x64" - | "windows-arm64"; + | "windows-x64"; export function getBinaryName(base: string): string { return isWindows ? `${base}.exe` : base; @@ -40,9 +40,11 @@ export function getPlatformDir(): PlatformDir { const platform = process.platform; const arch = getEffectiveArch(); if (platform === "linux" && arch === "x64") return "linux-x64"; + if (platform === "linux" && arch === "arm64") return "linux-arm64"; if (platform === "darwin" && arch === "x64") return "macos-x64"; if (platform === "darwin" && arch === "arm64") return "macos-arm64"; if (platform === "win32" && arch === "x64") return "windows-x64"; - if (platform === "win32" && arch === "arm64") return "windows-arm64"; + // Windows ARM64 runs x64 binaries via emulation — no native arm64 build yet + if (platform === "win32" && arch === "arm64") return "windows-x64"; throw new Error(`Unsupported platform: ${platform}-${arch}`); } diff --git a/apps/cli/src/process.ts b/apps/cli/src/process.ts index aba3256751..0c8c5f6094 100644 --- a/apps/cli/src/process.ts +++ b/apps/cli/src/process.ts @@ -37,17 +37,23 @@ export function createProcessSupervisor(): { const attachSignalHandlers = () => { process.on("SIGINT", onSignal); - process.on("SIGTERM", onSignal); + // SIGTERM is not available on Windows — only attach where supported + if (process.platform !== "win32") { + process.on("SIGTERM", onSignal); + } }; return { children, shutdown, attachSignalHandlers }; } /** - * Send SIGTERM to a process and wait for it to exit. - * Falls back to SIGKILL if the process doesn't exit within the timeout. + * Terminate a process and wait for it to exit. + * On Unix: sends SIGTERM, falls back to SIGKILL after timeout. + * On Windows: tree-kill uses taskkill (no SIGTERM/SIGKILL distinction). */ function waitForProcessExit(child: ChildLike, timeoutMs: number): Promise { + const isWindows = process.platform === "win32"; + return new Promise((resolve) => { const pid = child.pid as number; @@ -68,10 +74,12 @@ function waitForProcessExit(child: ChildLike, timeoutMs: number): Promise resolve(); }; - // Set up timeout for fallback SIGKILL + // Set up timeout for force-kill fallback const timeout = setTimeout(() => { - console.log(`[kandev] process ${pid} did not exit in time, sending SIGKILL`); - kill(pid, "SIGKILL", done); + console.log(`[kandev] process ${pid} did not exit in time, force killing`); + // On Windows, tree-kill always force-kills (no signal distinction) + // On Unix, escalate to SIGKILL + kill(pid, isWindows ? undefined : "SIGKILL", done); }, timeoutMs); // Listen for exit event if available @@ -82,8 +90,8 @@ function waitForProcessExit(child: ChildLike, timeoutMs: number): Promise }); } - // Send SIGTERM - kill(pid, "SIGTERM", (err) => { + // Graceful termination: SIGTERM on Unix, default kill on Windows + kill(pid, isWindows ? undefined : "SIGTERM", (err) => { // If kill fails (process already gone), we're done if (err) { clearTimeout(timeout); diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index a032a247b4..410a841a66 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,7 +1,22 @@ import { spawn, type ChildProcess, type StdioOptions } from "node:child_process"; +import { readFileSync } from "node:fs"; import { createProcessSupervisor } from "./process"; +let _isWSL: boolean | undefined; +function isWSL(): boolean { + if (_isWSL === undefined) { + try { + _isWSL = readFileSync("/proc/version", "utf8") + .toLowerCase() + .includes("microsoft"); + } catch { + _isWSL = false; + } + } + return _isWSL; +} + export type WebLaunchOptions = { command: string; args: string[]; @@ -18,18 +33,17 @@ export function openBrowser(url: string) { if (process.env.KANDEV_NO_BROWSER === "1") { return; } + const useCmd = process.platform === "win32" || isWSL(); const opener = process.platform === "darwin" ? "open" - : process.platform === "win32" - ? "cmd" + : useCmd + ? "cmd.exe" : "xdg-open"; - const args = - process.platform === "win32" - ? ["/c", "start", "", url] - : [url]; + const args = useCmd ? ["/c", "start", "", url] : [url]; try { const child = spawn(opener, args, { stdio: "ignore", detached: true }); + child.on("error", () => {}); // ignore async spawn errors (e.g. xdg-open missing) child.unref(); } catch { // ignore browser launch errors diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index bb6e56e773..3ca35f0513 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -367,7 +367,7 @@ importers: version: 11.12.2 next: specifier: 16.1.1 - version: 16.1.1(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -432,6 +432,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.8) + cross-env: + specifier: ^7.0.3 + version: 7.0.3 eslint: specifier: ^9 version: 9.39.2(jiti@2.6.1) @@ -1178,89 +1181,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1521,24 +1540,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.1': resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.1': resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.1': resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.1': resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==} @@ -2373,66 +2396,79 @@ packages: resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} @@ -2550,24 +2586,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -3042,41 +3082,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3499,6 +3547,11 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4756,24 +4809,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -9839,6 +9896,10 @@ snapshots: crelt@1.0.6: {} + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -10375,8 +10436,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.1 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10398,7 +10459,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -10409,22 +10470,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10435,7 +10496,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -11877,7 +11938,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.1.1(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.1 '@swc/helpers': 0.5.15 @@ -11886,7 +11947,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.1 '@next/swc-darwin-x64': 16.1.1 @@ -13005,12 +13066,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3): + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 - optionalDependencies: - '@babel/core': 7.28.6 stylis@4.3.6: {} diff --git a/apps/web/package.json b/apps/web/package.json index e207dbc701..ff3e158b28 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "dev": "next dev --hostname 0.0.0.0", - "build": "NODE_ENV=production next build", - "start": "NODE_ENV=production next start", + "build": "cross-env NODE_ENV=production next build", + "start": "cross-env NODE_ENV=production next start", "lint": "eslint", "test": "vitest run" }, @@ -104,6 +104,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.1", + "cross-env": "^7.0.3", "happy-dom": "^20.1.0", "tailwindcss": "^4", "typescript": "^5", diff --git a/docs/windows_support.md b/docs/windows_support.md new file mode 100644 index 0000000000..8ff0aec6e0 --- /dev/null +++ b/docs/windows_support.md @@ -0,0 +1,112 @@ +# Windows Support (WSL) + +Kandev runs on Windows through WSL (Windows Subsystem for Linux). + +## Prerequisites + +- Windows 10 (version 2004+) or Windows 11 +- WSL 2 with a Linux distribution installed (Ubuntu recommended) + +## Setup + +### 1. Install WSL + +From PowerShell (as Administrator): + +```powershell +wsl --install +``` + +Restart your machine if prompted, then open your Linux distribution from the Start menu. + +### 2. Install system dependencies + +```bash +sudo apt update +sudo apt install build-essential libatomic1 git +``` + +- `build-essential` provides `gcc`, required by `go-sqlite3` (CGO) +- `libatomic1` is required by Node.js + +### 3. Install mise (runtime manager) + +```bash +curl https://mise.run | sh +``` + +Add mise to your shell profile: + +```bash +echo 'eval "$(~/.local/bin/mise activate bash)"' >> ~/.bashrc +source ~/.bashrc +``` + +### 4. Install runtimes + +```bash +mise use -g node +mise use -g pnpm +mise use -g go +``` + +### 5. Clone and install + +```bash +git clone kandev +cd kandev +make install +``` + +### 6. Run + +```bash +make dev # development mode +make start # production mode +``` + +## Troubleshooting + +### `libatomic.so.1: cannot open shared object file` + +Node.js requires `libatomic1`. Install it: + +```bash +sudo apt install libatomic1 +``` + +### `pnpm: command not found` + +Ensure pnpm is installed and activated via mise: + +```bash +mise use -g pnpm +``` + +### `Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo` + +The SQLite driver needs a C compiler. Install `build-essential`: + +```bash +sudo apt install build-essential +``` + +### Browser doesn't auto-open + +WSL interop must be enabled so the CLI can launch your Windows browser. Ensure `/etc/wsl.conf` contains: + +```ini +[interop] +enabled=true +appendWindowsPath=true +``` + +Then restart WSL from PowerShell: + +```powershell +wsl --shutdown +``` + +### `cd: can't cd to apps` during `make install` + +This is a symptom of pnpm not being installed. Follow step 4 above.