fix(web): improve version parsing and timeout behavior
This commit is contained in:
parent
18ec2631aa
commit
c2d2ee6ae8
3 changed files with 216 additions and 27 deletions
|
|
@ -35,14 +35,15 @@ func getPidPath() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func isProcessRunning(pid int) bool {
|
func isProcessRunning(pid int) bool {
|
||||||
if runtime.GOOS == "windows" {
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
|
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.Contains(string(output), strconv.Itoa(pid))
|
return strings.Contains(string(output), strconv.Itoa(pid))
|
||||||
} else if runtime.GOOS == "darwin" {
|
case "darwin":
|
||||||
cmd := exec.Command("ps", "aux")
|
cmd := exec.Command("ps", "aux")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type systemVersionResponse struct {
|
type systemVersionResponse struct {
|
||||||
|
|
@ -14,18 +22,116 @@ type systemVersionResponse struct {
|
||||||
GoVersion string `json:"go_version"`
|
GoVersion string `json:"go_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Reuse the launcher gateway startup window so embedded/slow devices
|
||||||
|
// have enough time for first-run command initialization.
|
||||||
|
versionCmdTimeout = gatewayStartupWindow
|
||||||
|
findPicoclawBinaryForInfo = utils.FindPicoclawBinary
|
||||||
|
runPicoclawVersionOutput = executePicoclawVersion
|
||||||
|
versionLinePattern = regexp.MustCompile(`\bpicoclaw\s+([^\s(]+)(?:\s+\(git:\s*([^)]+)\))?`)
|
||||||
|
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||||
|
)
|
||||||
|
|
||||||
func (h *Handler) registerVersionRoutes(mux *http.ServeMux) {
|
func (h *Handler) registerVersionRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/system/version", h.handleGetVersion)
|
mux.HandleFunc("GET /api/system/version", h.handleGetVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetVersion returns runtime version information for web clients.
|
||||||
func (h *Handler) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
|
func (h *Handler) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
|
||||||
buildTime, goVer := config.FormatBuildInfo()
|
versionInfo := h.resolveSystemVersionInfo()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(systemVersionResponse{
|
json.NewEncoder(w).Encode(versionInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSystemVersionInfo prefers the actual picoclaw binary version output,
|
||||||
|
// and falls back to launcher build metadata when command execution fails.
|
||||||
|
func (h *Handler) resolveSystemVersionInfo() systemVersionResponse {
|
||||||
|
buildTime, goVer := config.FormatBuildInfo()
|
||||||
|
fallback := systemVersionResponse{
|
||||||
Version: config.GetVersion(),
|
Version: config.GetVersion(),
|
||||||
GitCommit: config.GitCommit,
|
GitCommit: config.GitCommit,
|
||||||
BuildTime: buildTime,
|
BuildTime: buildTime,
|
||||||
GoVersion: goVer,
|
GoVersion: goVer,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
execPath := strings.TrimSpace(findPicoclawBinaryForInfo())
|
||||||
|
if execPath == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), versionCmdTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
output, err := runPicoclawVersionOutput(ctx, execPath)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, ok := parsePicoclawVersionOutput(output)
|
||||||
|
if !ok {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.GoVersion == "" {
|
||||||
|
parsed.GoVersion = fallback.GoVersion
|
||||||
|
if parsed.GoVersion == "" {
|
||||||
|
parsed.GoVersion = runtime.Version()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// executePicoclawVersion runs version commands against the discovered picoclaw
|
||||||
|
// executable. It tries subcommand form first, then --version fallback.
|
||||||
|
func executePicoclawVersion(ctx context.Context, execPath string) (string, error) {
|
||||||
|
out, err := exec.CommandContext(ctx, execPath, "version").CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
flagOut, flagErr := exec.CommandContext(ctx, execPath, "--version").CombinedOutput()
|
||||||
|
if flagErr == nil {
|
||||||
|
return string(flagOut), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(out), fmt.Errorf("failed to execute version command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePicoclawVersionOutput extracts version/build/go fields from CLI output.
|
||||||
|
// It accepts banner/ANSI-decorated output and only requires the version line.
|
||||||
|
func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) {
|
||||||
|
var result systemVersionResponse
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(raw))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(scanner.Text(), ""))
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 {
|
||||||
|
result.Version = strings.TrimSpace(match[1])
|
||||||
|
if len(match) > 2 {
|
||||||
|
result.GitCommit = strings.TrimSpace(match[2])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if buildValue, ok := strings.CutPrefix(line, "Build:"); ok {
|
||||||
|
result.BuildTime = strings.TrimSpace(buildValue)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if goValue, ok := strings.CutPrefix(line, "Go:"); ok {
|
||||||
|
result.GoVersion = strings.TrimSpace(goValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Version == "" {
|
||||||
|
return systemVersionResponse{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
@ -10,22 +12,88 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetSystemVersion(t *testing.T) {
|
func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
|
||||||
originalVersion := config.Version
|
originalVersion := config.Version
|
||||||
originalGitCommit := config.GitCommit
|
originalGitCommit := config.GitCommit
|
||||||
originalBuildTime := config.BuildTime
|
originalBuildTime := config.BuildTime
|
||||||
originalGoVersion := config.GoVersion
|
originalGoVersion := config.GoVersion
|
||||||
|
originalFinder := findPicoclawBinaryForInfo
|
||||||
|
originalRunner := runPicoclawVersionOutput
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
config.Version = originalVersion
|
config.Version = originalVersion
|
||||||
config.GitCommit = originalGitCommit
|
config.GitCommit = originalGitCommit
|
||||||
config.BuildTime = originalBuildTime
|
config.BuildTime = originalBuildTime
|
||||||
config.GoVersion = originalGoVersion
|
config.GoVersion = originalGoVersion
|
||||||
|
findPicoclawBinaryForInfo = originalFinder
|
||||||
|
runPicoclawVersionOutput = originalRunner
|
||||||
})
|
})
|
||||||
|
|
||||||
config.Version = "v1.2.3"
|
config.Version = "dev"
|
||||||
config.GitCommit = "deadbeef"
|
config.GitCommit = ""
|
||||||
config.BuildTime = "2026-03-27T12:34:56Z"
|
config.BuildTime = ""
|
||||||
config.GoVersion = "go1.24.1"
|
config.GoVersion = ""
|
||||||
|
|
||||||
|
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||||
|
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||||
|
return "🦞 picoclaw v1.2.3 (git: deadbeef)\n Build: 2026-03-27T12:34:56Z\n Go: go1.25.8\n", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler("")
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var got systemVersionResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Version != "v1.2.3" {
|
||||||
|
t.Fatalf("version = %q, want %q", got.Version, "v1.2.3")
|
||||||
|
}
|
||||||
|
if got.GitCommit != "deadbeef" {
|
||||||
|
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "deadbeef")
|
||||||
|
}
|
||||||
|
if got.BuildTime != "2026-03-27T12:34:56Z" {
|
||||||
|
t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T12:34:56Z")
|
||||||
|
}
|
||||||
|
if got.GoVersion != "go1.25.8" {
|
||||||
|
t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
|
||||||
|
originalVersion := config.Version
|
||||||
|
originalGitCommit := config.GitCommit
|
||||||
|
originalBuildTime := config.BuildTime
|
||||||
|
originalGoVersion := config.GoVersion
|
||||||
|
originalFinder := findPicoclawBinaryForInfo
|
||||||
|
originalRunner := runPicoclawVersionOutput
|
||||||
|
t.Cleanup(func() {
|
||||||
|
config.Version = originalVersion
|
||||||
|
config.GitCommit = originalGitCommit
|
||||||
|
config.BuildTime = originalBuildTime
|
||||||
|
config.GoVersion = originalGoVersion
|
||||||
|
findPicoclawBinaryForInfo = originalFinder
|
||||||
|
runPicoclawVersionOutput = originalRunner
|
||||||
|
})
|
||||||
|
|
||||||
|
config.Version = "v9.9.9"
|
||||||
|
config.GitCommit = "cafebabe"
|
||||||
|
config.BuildTime = "2026-03-27T10:43:34+0000"
|
||||||
|
config.GoVersion = "go1.25.8"
|
||||||
|
|
||||||
|
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||||
|
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||||
|
return "", errors.New("binary unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
h := NewHandler("")
|
h := NewHandler("")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
@ -58,16 +126,40 @@ func TestGetSystemVersion(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSystemVersionUsesRuntimeGoVersionFallback(t *testing.T) {
|
func TestParsePicoclawVersionOutput(t *testing.T) {
|
||||||
|
raw := "\u001b[1;31m████\u001b[0m\n🦞 picoclaw 18ec263 (git: 18ec2631)\n Build: 2026-03-27T10:43:34+0000\n Go: go1.25.8\n"
|
||||||
|
got, ok := parsePicoclawVersionOutput(raw)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("parsePicoclawVersionOutput() should parse valid output")
|
||||||
|
}
|
||||||
|
if got.Version != "18ec263" {
|
||||||
|
t.Fatalf("version = %q, want %q", got.Version, "18ec263")
|
||||||
|
}
|
||||||
|
if got.GitCommit != "18ec2631" {
|
||||||
|
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "18ec2631")
|
||||||
|
}
|
||||||
|
if got.BuildTime != "2026-03-27T10:43:34+0000" {
|
||||||
|
t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T10:43:34+0000")
|
||||||
|
}
|
||||||
|
if got.GoVersion != "go1.25.8" {
|
||||||
|
t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
|
||||||
originalVersion := config.Version
|
originalVersion := config.Version
|
||||||
originalGitCommit := config.GitCommit
|
originalGitCommit := config.GitCommit
|
||||||
originalBuildTime := config.BuildTime
|
originalBuildTime := config.BuildTime
|
||||||
originalGoVersion := config.GoVersion
|
originalGoVersion := config.GoVersion
|
||||||
|
originalFinder := findPicoclawBinaryForInfo
|
||||||
|
originalRunner := runPicoclawVersionOutput
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
config.Version = originalVersion
|
config.Version = originalVersion
|
||||||
config.GitCommit = originalGitCommit
|
config.GitCommit = originalGitCommit
|
||||||
config.BuildTime = originalBuildTime
|
config.BuildTime = originalBuildTime
|
||||||
config.GoVersion = originalGoVersion
|
config.GoVersion = originalGoVersion
|
||||||
|
findPicoclawBinaryForInfo = originalFinder
|
||||||
|
runPicoclawVersionOutput = originalRunner
|
||||||
})
|
})
|
||||||
|
|
||||||
config.Version = "dev"
|
config.Version = "dev"
|
||||||
|
|
@ -75,23 +167,13 @@ func TestGetSystemVersionUsesRuntimeGoVersionFallback(t *testing.T) {
|
||||||
config.BuildTime = ""
|
config.BuildTime = ""
|
||||||
config.GoVersion = ""
|
config.GoVersion = ""
|
||||||
|
|
||||||
|
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
|
||||||
|
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
|
||||||
|
return "picoclaw v1.0.0\n", nil
|
||||||
|
}
|
||||||
|
|
||||||
h := NewHandler("")
|
h := NewHandler("")
|
||||||
mux := http.NewServeMux()
|
got := h.resolveSystemVersionInfo()
|
||||||
h.RegisterRoutes(mux)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
|
|
||||||
mux.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var got systemVersionResponse
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
||||||
t.Fatalf("unmarshal response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.GoVersion != runtime.Version() {
|
if got.GoVersion != runtime.Version() {
|
||||||
t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version())
|
t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue