Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions cmd/proc_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,33 @@
package cmd

import (
"context"
"os"
"os/exec"
"os/signal"
"syscall"
)

func notifyContext(ctx context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
}

func setSysProcAttr(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}

func processExists(pid int) bool {
p, err := os.FindProcess(pid)
if err != nil {
return false
}
return p.Signal(syscall.Signal(0)) == nil
}

func signalTerminate(p *os.Process) error {
return p.Signal(syscall.SIGTERM)
}

func killByName(exePath string) {
_ = exec.Command("pkill", "-f", exePath+" start").Run()
}
30 changes: 29 additions & 1 deletion cmd/proc_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,36 @@

package cmd

import "os/exec"
import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
)

func notifyContext(ctx context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(ctx, os.Interrupt)
}

func setSysProcAttr(_ *exec.Cmd) {
// No Setsid on Windows — process is already detached via Start()
}

func processExists(pid int) bool {
out, err := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/NH").Output()
if err != nil {
return false
}
return strings.Contains(string(out), fmt.Sprintf("%d", pid))
}

func signalTerminate(p *os.Process) error {
return p.Kill()
}

func killByName(exePath string) {
_ = exec.Command("taskkill", "/F", "/IM", filepath.Base(exePath)).Run()
}
3 changes: 1 addition & 2 deletions cmd/restart.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cmd
import (
"fmt"
"os"
"syscall"
"time"

"github.com/spf13/cobra"
Expand All @@ -22,7 +21,7 @@ var restartCmd = &cobra.Command{
if err == nil && processExists(pid) {
fmt.Printf("Stopping weclaw (pid=%d)...\n", pid)
if p, err := os.FindProcess(pid); err == nil {
p.Signal(syscall.SIGTERM)
_ = signalTerminate(p)
}
for i := 0; i < 20; i++ {
if !processExists(pid) {
Expand Down
20 changes: 4 additions & 16 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ import (
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"

"github.com/fastclaw-ai/weclaw/agent"
Expand Down Expand Up @@ -44,7 +42,7 @@ func runStart(cmd *cobra.Command, args []string) error {
accounts, _ := ilink.LoadAllCredentials()
if len(accounts) == 0 {
fmt.Println("No WeChat accounts found, starting login...")
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
ctx, cancel := notifyContext(context.Background())
_, err := doLogin(ctx)
cancel()
if err != nil {
Expand All @@ -54,7 +52,7 @@ func runStart(cmd *cobra.Command, args []string) error {
return runDaemon()
}

ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
ctx, cancel := notifyContext(context.Background())
defer cancel()

// Load all accounts
Expand Down Expand Up @@ -405,21 +403,12 @@ func readPid() (int, error) {
return pid, nil
}

func processExists(pid int) bool {
p, err := os.FindProcess(pid)
if err != nil {
return false
}
// Signal 0 checks if process exists without killing it
return p.Signal(syscall.Signal(0)) == nil
}

// stopAllWeclaw kills all running weclaw processes (by PID file and by process scan).
func stopAllWeclaw() {
// 1. Kill by PID file
if pid, err := readPid(); err == nil && processExists(pid) {
if p, err := os.FindProcess(pid); err == nil {
_ = p.Signal(syscall.SIGTERM)
_ = signalTerminate(p)
}
}
os.Remove(pidFile())
Expand All @@ -429,7 +418,6 @@ func stopAllWeclaw() {
if err != nil {
return
}
// Use pkill to kill all processes matching the executable path
_ = exec.Command("pkill", "-f", exe+" start").Run()
killByName(exe)
time.Sleep(500 * time.Millisecond)
}
57 changes: 36 additions & 21 deletions cmd/update.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cmd

import (
"encoding/json"
"fmt"
"io"
"log"
Expand Down Expand Up @@ -122,23 +121,27 @@ func runUpdate(cmd *cobra.Command, args []string) error {
}

func getLatestVersion() (string, error) {
resp, err := http.Get(fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo))
// Use HTTP redirect instead of API to avoid rate limits
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(fmt.Sprintf("https://github.com/%s/releases/latest", githubRepo))
if err != nil {
return "", err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
resp.Body.Close()

var release struct {
TagName string `json:"tag_name"`
loc := resp.Header.Get("Location")
if loc == "" {
return "", fmt.Errorf("no redirect from GitHub releases/latest")
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", err
parts := strings.Split(loc, "/tag/")
if len(parts) != 2 || parts[1] == "" {
return "", fmt.Errorf("unexpected redirect URL: %s", loc)
}
return release.TagName, nil
return parts[1], nil
}

func downloadFile(url string) (string, error) {
Expand Down Expand Up @@ -178,17 +181,29 @@ func replaceBinary(src, dst string) error {
return nil
}

// Try with sudo on Unix
if runtime.GOOS != "windows" {
fmt.Printf("Installing to %s (requires sudo)...\n", dst)
cmd := exec.Command("sudo", "cp", src, dst)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
if runtime.GOOS == "windows" {
// On Windows the running binary is locked. Move it aside first,
// then place the new binary. The old file will be cleaned up on
// next restart or can be deleted manually.
old := dst + ".old"
os.Remove(old)
if err := os.Rename(dst, old); err != nil {
return fmt.Errorf("cannot move old binary aside: %w", err)
}
if err := os.Rename(src, dst); err != nil {
os.Rename(old, dst)
return fmt.Errorf("cannot install new binary: %w", err)
}
return nil
}

return fmt.Errorf("cannot write to %s", dst)
// Try with sudo on Unix
fmt.Printf("Installing to %s (requires sudo)...\n", dst)
cmd := exec.Command("sudo", "cp", src, dst)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

func resolveSymlink(path string) (string, error) {
Expand Down
81 changes: 81 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Install weclaw on Windows.
.DESCRIPTION
Downloads the latest weclaw release from GitHub and installs it
to $env:LOCALAPPDATA\weclaw. Adds the directory to the user PATH
if not already present.
#>

$ErrorActionPreference = "Stop"

$Repo = "fastclaw-ai/weclaw"
$Binary = "weclaw"
$InstallDir = "$env:LOCALAPPDATA\weclaw"

# Detect architecture
$Arch = switch ($env:PROCESSOR_ARCHITECTURE) {
"AMD64" { "amd64" }
"ARM64" { "arm64" }
default { Write-Error "Unsupported architecture: $env:PROCESSOR_ARCHITECTURE"; exit 1 }
}

Write-Host "Detected: windows/$Arch"

# Get latest version via redirect
Write-Host "Fetching latest release..."
try {
$Response = Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" `
-MaximumRedirection 0 -ErrorAction SilentlyContinue -UseBasicParsing
} catch {
$Response = $_.Exception.Response
}

$Location = if ($Response.Headers["Location"]) {
$Response.Headers["Location"]
} elseif ($Response.Headers.Location) {
$Response.Headers.Location
} else {
$null
}

if (-not $Location) {
Write-Error "Could not determine latest version. Is there a release on GitHub?"
exit 1
}

$Version = ($Location -split "/tag/")[-1].Trim()
Write-Host "Latest version: $Version"

# Download
$Filename = "${Binary}_windows_${Arch}.exe"
$Url = "https://github.com/$Repo/releases/download/$Version/$Filename"

Write-Host "Downloading $Url..."
$TmpFile = Join-Path $env:TEMP $Filename

Invoke-WebRequest -Uri $Url -OutFile $TmpFile -UseBasicParsing

# Install
if (-not (Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}

$DestPath = Join-Path $InstallDir "$Binary.exe"
Move-Item -Path $TmpFile -Destination $DestPath -Force

# Add to PATH if not already present
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($UserPath -notlike "*$InstallDir*") {
[Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User")
Write-Host ""
Write-Host "Added $InstallDir to user PATH."
Write-Host "Please restart your terminal for PATH changes to take effect."
}

Write-Host ""
Write-Host "weclaw $Version installed to $DestPath"
Write-Host ""
Write-Host "Get started:"
Write-Host " weclaw start"
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ echo "Detected: ${OS}/${ARCH}"

# Get latest version
echo "Fetching latest release..."
VERSION=$(curl -fsSL -H "User-Agent: weclaw-installer" "https://api.github.com/repos/${REPO}/releases/latest" | sed -n 's/.*"tag_name" *: *"\([^"]*\)".*/\1/p')
VERSION=$(curl -fsSI "https://github.com/${REPO}/releases/latest" | grep -i '^location:' | sed 's|.*/tag/||' | tr -d '\r')

if [ -z "$VERSION" ]; then
echo "Error: could not determine latest version. Is there a release on GitHub?"
Expand Down
Loading