feat: service-managed UI self-update (#1210)

* feat: add service-managed self-update

* chore: add temporary self-update test scripts

* chore: simplify temporary script markdown fences

* chore: run frontend in temporary self-update scripts

* fix: show self-update job progress after apply

* chore: add real npm self-update test script

* fix: isolate npm self-update prefix

* fix: detect npm service installs from bin shim

* chore: reset service before real npm update test

* chore: expand real npm status diagnostics

* fix: preserve service home during self-update

* fix(cli): tee self-update helper output to a log file

* fix(cli): avoid launchd bootstrap race when reloading the macOS service

* fix(backend): run macOS self-update helper once via transient launchd plist

* fix(web): show self-update progress and lock the apply button until the update lands

* fix(cli): reinstall via the kandev wrapper on Homebrew self-update

* chore: remove temp self-update test scripts

* fix(web): drop unused apply path from useUpdates hook

* fix(backend): snapshot install state once in apply preflight

* docs: align self-update docs with transient LaunchAgent flow

* chore(release): run cli bundle from apps workspace root

* fix(backend): guard against concurrent self-update applies

* fix(backend): check scheme and helper paths in self-update apply

* fix(backend): drop duplicate binary name from npx manual command

* fix(cli): allow --help for service self-update without --intent

* fix(cli): harden self-update dir perms and darwin uid resolution

* fix(web): set required POST fields last in applyUpdate

* docs: use JSON body notation for the apply endpoint

* fix(backend): expire self-update guard so a stuck helper cannot wedge apply

* refactor(cli): collapse redundant writeSync branch in self-update log
This commit is contained in:
José Almeida
2026-06-01 17:49:15 +01:00
committed by GitHub
parent 58dd2773a2
commit 041a19f060
44 changed files with 3630 additions and 136 deletions
+2 -1
View File
@@ -89,7 +89,7 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus
Database: dbSvc,
Backups: backupsSvc,
Logs: logs.NewService(logDir, logFile, log),
Updates: updates.NewService(pool, build.Version, nil, log),
Updates: updates.NewService(pool, build.Version, nil, log, updates.WithHomeDir(homeDir), updates.WithJobs(tracker)),
}
}
@@ -118,6 +118,7 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) {
g.GET("/updates", updates.HandleGet(s.Updates))
g.POST("/updates/check", updates.HandleCheck(s.Updates))
g.POST("/updates/apply", updates.HandleApply(s.Updates))
g.GET("/jobs/:id", jobs.HandleGet(s.Jobs))
@@ -0,0 +1,313 @@
package updates
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/kandev/kandev/internal/persistence"
)
type applyRunner func(context.Context, applyRequest) (map[string]interface{}, error)
type applyRequest struct {
IntentPath string
Intent updateIntent
}
const (
applyResultStatusKey = "status"
applyResultStarted = "started"
applyResultRunnerKey = "runner"
applyResultIntentPathKey = "intent_path"
applyRunnerFake = "fake"
applyRunnerSystemdRun = "systemd-run"
applyRunnerLaunchctl = "launchctl"
)
type updateIntent struct {
Version int `json:"version"`
TargetTag string `json:"target_tag"`
TargetVersion string `json:"target_version"`
LatestURL string `json:"latest_url,omitempty"`
Install serviceInstallMetadata `json:"install"`
CreatedAt string `json:"created_at"`
}
func (s *Service) applyPreflight() (UpdatesResponse, *serviceInstallMetadata, error) {
version, releaseURL, checkedAt, err := persistence.ReadLatestVersion(s.pool.Reader())
if err != nil {
return UpdatesResponse{}, nil, err
}
// Read install state once so the ApplySupported gate below and the intent
// file written by the caller both reflect the same snapshot.
install, metadata := s.detectInstallState()
resp := s.buildResponseFrom(install, version, releaseURL, checkedAt)
if !resp.UpdateAvailable {
return UpdatesResponse{}, nil, ErrNoUpdateAvailable
}
if !resp.ApplySupported {
return UpdatesResponse{}, nil, fmt.Errorf("%w: %s", ErrApplyUnsupported, resp.ApplyUnsupportedReason)
}
if metadata == nil {
return UpdatesResponse{}, nil, ErrApplyUnsupported
}
return resp, metadata, nil
}
func (s *Service) writeApplyIntent(resp UpdatesResponse, metadata *serviceInstallMetadata) (string, updateIntent, error) {
if s.homeDir == "" {
return "", updateIntent{}, errors.New("home dir is unknown")
}
intent := updateIntent{
Version: 1,
TargetTag: resp.Latest,
TargetVersion: strings.TrimPrefix(resp.Latest, "v"),
LatestURL: resp.LatestURL,
Install: *metadata,
CreatedAt: s.now().UTC().Format(time.RFC3339Nano),
}
dir := filepath.Join(s.homeDir, "service", "update-intents")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", updateIntent{}, err
}
name := fmt.Sprintf("%d.json", s.now().UTC().UnixNano())
path := filepath.Join(dir, name)
data, err := json.MarshalIndent(intent, "", " ")
if err != nil {
return "", updateIntent{}, err
}
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
return "", updateIntent{}, err
}
return path, intent, nil
}
func (s *Service) defaultApplyRunner(ctx context.Context, req applyRequest) (map[string]interface{}, error) {
if s.getenv("KANDEV_E2E_MOCK") == "true" {
return map[string]interface{}{
applyResultStatusKey: applyResultStarted,
applyResultRunnerKey: applyRunnerFake,
applyResultIntentPathKey: req.IntentPath,
}, nil
}
install := req.Intent.Install
switch install.Manager {
case serviceManagerSystemd:
return runSystemdSelfUpdate(ctx, req)
case serviceManagerLaunchd:
return runLaunchdSelfUpdate(ctx, req)
default:
return nil, fmt.Errorf("unsupported service manager %q", install.Manager)
}
}
func runSystemdSelfUpdate(ctx context.Context, req applyRequest) (map[string]interface{}, error) {
unitName := "kandev-self-update-" + strconv.FormatInt(time.Now().UnixNano(), 10)
args := systemdSelfUpdateArgs(req, unitName)
out, err := exec.CommandContext(ctx, "systemd-run", args...).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("systemd-run self-update helper: %w: %s", err, strings.TrimSpace(string(out)))
}
return map[string]interface{}{
applyResultStatusKey: applyResultStarted,
applyResultRunnerKey: applyRunnerSystemdRun,
"unit": unitName,
applyResultIntentPathKey: req.IntentPath,
}, nil
}
func systemdSelfUpdateArgs(req applyRequest, unitName string) []string {
args := []string{
"--user",
"--unit", unitName,
"--collect",
}
for _, env := range selfUpdateEnvironment() {
args = append(args, "--setenv="+env)
}
return append(args,
req.Intent.Install.NodePath,
req.Intent.Install.CLIEntry,
"service",
"self-update",
"--intent",
req.IntentPath,
)
}
const selfUpdateHelperDirName = "self-update"
// runLaunchdSelfUpdate spawns the self-update helper as a transient launchd job.
//
// The previous implementation used `launchctl submit`, which registers the job
// with an implicit KeepAlive — launchd re-ran the one-shot updater every ~15s
// forever, so the service reinstalled and restarted in an endless loop. A
// bootstrapped plist with KeepAlive=false runs exactly once (matching what
// `systemd-run --collect` gives us on Linux). The job lives outside the kandev
// service so reinstalling/restarting the service mid-update doesn't kill it.
func runLaunchdSelfUpdate(ctx context.Context, req applyRequest) (map[string]interface{}, error) {
// Empty home/log dirs would turn the plist path and log path into relative
// locations, scattering helper files. Refuse rather than guess.
if strings.TrimSpace(req.Intent.Install.HomeDir) == "" || strings.TrimSpace(req.Intent.Install.LogDir) == "" {
return nil, fmt.Errorf("service metadata missing home_dir or log_dir")
}
label := "com.kdlbs.kandev.self-update." + strconv.FormatInt(time.Now().UnixNano(), 10)
uid := os.Getuid()
domain := launchdSelfUpdateDomain(req, uid)
dir := filepath.Join(req.Intent.Install.HomeDir, "service", selfUpdateHelperDirName)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("self-update helper dir: %w", err)
}
// Boot out and delete any idle helper job left loaded by a prior update so
// they don't accumulate (a KeepAlive=false job stays registered after it
// exits until it's booted out).
cleanupStaleLaunchdHelpers(ctx, dir, domain)
plistPath := filepath.Join(dir, label+".plist")
if err := os.WriteFile(plistPath, []byte(renderLaunchdHelperPlist(label, req)), 0o600); err != nil {
return nil, fmt.Errorf("write self-update helper plist: %w", err)
}
out, err := exec.CommandContext(ctx, "launchctl", "bootstrap", domain, plistPath).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("launchctl bootstrap self-update helper: %w: %s", err, strings.TrimSpace(string(out)))
}
return map[string]interface{}{
applyResultStatusKey: applyResultStarted,
applyResultRunnerKey: applyRunnerLaunchctl,
"label": label,
applyResultIntentPathKey: req.IntentPath,
}, nil
}
func launchdSelfUpdateDomain(req applyRequest, uid int) string {
if req.Intent.Install.Mode == installModeSystem {
return "system"
}
return fmt.Sprintf("gui/%d", uid)
}
func cleanupStaleLaunchdHelpers(ctx context.Context, dir, domain string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".plist") {
continue
}
label := strings.TrimSuffix(name, ".plist")
_ = exec.CommandContext(ctx, "launchctl", "bootout", domain+"/"+label).Run()
_ = os.Remove(filepath.Join(dir, name))
}
}
// renderLaunchdHelperPlist builds a transient LaunchAgent plist for the helper:
// RunAtLoad fires it once on bootstrap, KeepAlive=false stops launchd from
// re-running it. Update env (PATH/npm prefix) is carried via EnvironmentVariables
// so the helper can resolve npm; stdout/stderr are captured to the log dir as a
// fallback alongside the helper's own self-update-<ts>.log.
func renderLaunchdHelperPlist(label string, req applyRequest) string {
args := []string{
req.Intent.Install.NodePath,
req.Intent.Install.CLIEntry,
"service",
"self-update",
"--intent",
req.IntentPath,
}
var argsXML strings.Builder
for _, arg := range args {
argsXML.WriteString(" <string>" + escapeXML(arg) + "</string>\n")
}
var envXML strings.Builder
for _, kv := range selfUpdateEnvironment() {
key, value, _ := strings.Cut(kv, "=")
envXML.WriteString(" <key>" + escapeXML(key) + "</key>\n")
envXML.WriteString(" <string>" + escapeXML(value) + "</string>\n")
}
logPath := filepath.Join(req.Intent.Install.LogDir, "self-update-launchd.log")
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>` + escapeXML(label) + `</string>
<key>ProgramArguments</key>
<array>
` + argsXML.String() + ` </array>
<key>EnvironmentVariables</key>
<dict>
` + envXML.String() + ` </dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>ProcessType</key>
<string>Background</string>
<key>StandardOutPath</key>
<string>` + escapeXML(logPath) + `</string>
<key>StandardErrorPath</key>
<string>` + escapeXML(logPath) + `</string>
</dict>
</plist>
`
}
func selfUpdateEnvironment() []string {
keys := []string{"PATH", "npm_config_prefix", "NPM_CONFIG_PREFIX"}
env := make([]string, 0, len(keys))
for _, key := range keys {
if value := os.Getenv(key); value != "" {
env = append(env, key+"="+value)
}
}
return env
}
func sameOriginOrNoOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
originURL, err := url.Parse(origin)
if err != nil || originURL.Host == "" {
return false
}
requestHost := r.Host
if requestHost == "" {
requestHost = r.URL.Host
}
if !strings.EqualFold(originURL.Host, requestHost) {
return false
}
return strings.EqualFold(originURL.Scheme, requestScheme(r))
}
// requestScheme reports the scheme the server was reached on so the same-origin
// check rejects a cross-scheme Origin (e.g. http://host against an https host).
// A reverse proxy terminating TLS upstream is honoured via X-Forwarded-Proto.
func requestScheme(r *http.Request) string {
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
if i := strings.IndexByte(proto, ','); i >= 0 {
proto = proto[:i]
}
return strings.TrimSpace(proto)
}
if r.TLS != nil {
return "https"
}
return "http"
}
@@ -0,0 +1,303 @@
package updates
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/persistence"
"github.com/kandev/kandev/internal/system/jobs"
)
func TestService_ApplyQueuesSelfUpdateJobAndWritesIntent(t *testing.T) {
homeDir := t.TempDir()
metadataPath, _ := writeServiceInstallForTest(t, homeDir, serviceInstallMetadata{
Manager: "systemd",
Mode: "user",
Kind: "npm",
HomeDir: homeDir,
LogDir: filepath.Join(homeDir, "logs"),
ServicePath: filepath.Join(homeDir, "kandev.service"),
NodePath: "/usr/bin/node",
CLIEntry: "/usr/lib/node_modules/kandev/bin/cli.js",
Port: 38429,
})
t.Setenv(envRunningAsService, "true")
t.Setenv(envServiceMode, "user")
t.Setenv(envServiceManager, "systemd")
t.Setenv(envInstallKind, "npm")
t.Setenv(envServiceMetadata, metadataPath)
pool := newTestPool(t)
if err := persistence.WriteLatestVersion(pool.Writer(), "v1.0.1", "https://example/v1.0.1", time.Now().UTC()); err != nil {
t.Fatalf("write latest: %v", err)
}
tracker := jobs.NewTracker(nil, logger.Default())
var gotReq applyRequest
svc := NewService(
pool,
"v1.0.0",
nil,
logger.Default(),
WithHomeDir(homeDir),
WithJobs(tracker),
WithApplyRunner(func(_ context.Context, req applyRequest) (map[string]interface{}, error) {
gotReq = req
return map[string]interface{}{"status": "started"}, nil
}),
)
jobID, err := svc.Apply(context.Background(), "UPDATE")
if err != nil {
t.Fatalf("Apply: %v", err)
}
waitForJobState(t, tracker, jobID, jobs.StateSucceeded)
if gotReq.IntentPath == "" {
t.Fatalf("runner did not receive intent path")
}
data, err := os.ReadFile(gotReq.IntentPath)
if err != nil {
t.Fatalf("read intent: %v", err)
}
var intent updateIntent
if err := json.Unmarshal(data, &intent); err != nil {
t.Fatalf("unmarshal intent: %v", err)
}
if intent.TargetVersion != "1.0.1" {
t.Fatalf("TargetVersion=%q want 1.0.1", intent.TargetVersion)
}
if intent.Install.Port != 38429 {
t.Fatalf("Port=%d want 38429", intent.Install.Port)
}
}
func TestService_ApplyRejectsUnsupportedInstall(t *testing.T) {
pool := newTestPool(t)
if err := persistence.WriteLatestVersion(pool.Writer(), "v1.0.1", "https://example/v1.0.1", time.Now().UTC()); err != nil {
t.Fatalf("write latest: %v", err)
}
svc := NewService(pool, "v1.0.0", nil, logger.Default(), WithJobs(jobs.NewTracker(nil, logger.Default())))
_, err := svc.Apply(context.Background(), "UPDATE")
if !errors.Is(err, ErrApplyUnsupported) {
t.Fatalf("err=%v want ErrApplyUnsupported", err)
}
}
func TestService_ApplyRejectsConcurrentSelfUpdate(t *testing.T) {
homeDir := t.TempDir()
metadataPath, _ := writeServiceInstallForTest(t, homeDir, serviceInstallMetadata{
Manager: "systemd",
Mode: "user",
Kind: "npm",
HomeDir: homeDir,
LogDir: filepath.Join(homeDir, "logs"),
ServicePath: filepath.Join(homeDir, "kandev.service"),
NodePath: "/usr/bin/node",
CLIEntry: "/usr/lib/node_modules/kandev/bin/cli.js",
})
t.Setenv(envRunningAsService, "true")
t.Setenv(envServiceMode, "user")
t.Setenv(envServiceManager, "systemd")
t.Setenv(envInstallKind, "npm")
t.Setenv(envServiceMetadata, metadataPath)
pool := newTestPool(t)
if err := persistence.WriteLatestVersion(pool.Writer(), "v1.0.1", "https://example/v1.0.1", time.Now().UTC()); err != nil {
t.Fatalf("write latest: %v", err)
}
// Block the first helper so its job stays running while the second apply is
// attempted, making the in-flight guard deterministic.
release := make(chan struct{})
svc := NewService(pool, "v1.0.0", nil, logger.Default(),
WithHomeDir(homeDir),
WithJobs(jobs.NewTracker(nil, logger.Default())),
WithApplyRunner(func(_ context.Context, _ applyRequest) (map[string]interface{}, error) {
<-release
return map[string]interface{}{"status": "started"}, nil
}),
)
if _, err := svc.Apply(context.Background(), "UPDATE"); err != nil {
t.Fatalf("first Apply: %v", err)
}
if _, err := svc.Apply(context.Background(), "UPDATE"); !errors.Is(err, ErrApplyInProgress) {
t.Fatalf("second Apply err=%v want ErrApplyInProgress", err)
}
close(release)
}
func TestService_ApplyGuardExpiresAfterTTL(t *testing.T) {
homeDir := t.TempDir()
metadataPath, _ := writeServiceInstallForTest(t, homeDir, serviceInstallMetadata{
Manager: "systemd",
Mode: "user",
Kind: "npm",
HomeDir: homeDir,
LogDir: filepath.Join(homeDir, "logs"),
ServicePath: filepath.Join(homeDir, "kandev.service"),
NodePath: "/usr/bin/node",
CLIEntry: "/usr/lib/node_modules/kandev/bin/cli.js",
})
t.Setenv(envRunningAsService, "true")
t.Setenv(envServiceMode, "user")
t.Setenv(envServiceManager, "systemd")
t.Setenv(envInstallKind, "npm")
t.Setenv(envServiceMetadata, metadataPath)
pool := newTestPool(t)
if err := persistence.WriteLatestVersion(pool.Writer(), "v1.0.1", "https://example/v1.0.1", time.Now().UTC()); err != nil {
t.Fatalf("write latest: %v", err)
}
svc := NewService(pool, "v1.0.0", nil, logger.Default(),
WithHomeDir(homeDir),
WithJobs(jobs.NewTracker(nil, logger.Default())),
WithApplyRunner(func(_ context.Context, _ applyRequest) (map[string]interface{}, error) {
// Launch "succeeds" (helper started) but never restarts the backend,
// so the guard is not released by an error path.
return map[string]interface{}{"status": "started"}, nil
}),
)
clock := time.Now()
svc.now = func() time.Time { return clock }
if _, err := svc.Apply(context.Background(), "UPDATE"); err != nil {
t.Fatalf("first Apply: %v", err)
}
// Within the TTL a second apply is still refused.
if _, err := svc.Apply(context.Background(), "UPDATE"); !errors.Is(err, ErrApplyInProgress) {
t.Fatalf("within TTL err=%v want ErrApplyInProgress", err)
}
// Past the TTL the guard expires (helper assumed dead) and a retry succeeds.
clock = clock.Add(applyGuardTTL + time.Second)
if _, err := svc.Apply(context.Background(), "UPDATE"); err != nil {
t.Fatalf("after TTL Apply: %v", err)
}
}
func TestService_ApplyRejectsWrongConfirm(t *testing.T) {
svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default())
_, err := svc.Apply(context.Background(), "NOPE")
if !errors.Is(err, ErrApplyConfirm) {
t.Fatalf("err=%v want ErrApplyConfirm", err)
}
}
func TestSystemdSelfUpdateArgsPropagateUpdateEnvironment(t *testing.T) {
t.Setenv("PATH", "/opt/homebrew/bin:/usr/bin")
t.Setenv("npm_config_prefix", "/tmp/npm-global")
t.Setenv("NPM_CONFIG_PREFIX", "/tmp/npm-global")
req := applyRequest{
IntentPath: "/tmp/intent.json",
Intent: updateIntent{Install: serviceInstallMetadata{
NodePath: "/opt/homebrew/bin/node",
CLIEntry: "/tmp/npm-global/lib/node_modules/kandev/bin/cli.js",
}},
}
got := systemdSelfUpdateArgs(req, "kandev-self-update-test")
want := []string{
"--user",
"--unit", "kandev-self-update-test",
"--collect",
"--setenv=PATH=/opt/homebrew/bin:/usr/bin",
"--setenv=npm_config_prefix=/tmp/npm-global",
"--setenv=NPM_CONFIG_PREFIX=/tmp/npm-global",
"/opt/homebrew/bin/node",
"/tmp/npm-global/lib/node_modules/kandev/bin/cli.js",
"service",
"self-update",
"--intent",
"/tmp/intent.json",
}
if !stringSlicesEqual(got, want) {
t.Fatalf("args=%#v want %#v", got, want)
}
}
func TestRenderLaunchdHelperPlistRunsOnceWithUpdateEnvironment(t *testing.T) {
t.Setenv("PATH", "/opt/homebrew/bin:/usr/bin")
t.Setenv("npm_config_prefix", "/tmp/npm-global")
t.Setenv("NPM_CONFIG_PREFIX", "/tmp/npm-global")
req := applyRequest{
IntentPath: "/tmp/intent.json",
Intent: updateIntent{Install: serviceInstallMetadata{
NodePath: "/opt/homebrew/bin/node",
CLIEntry: "/tmp/npm-global/lib/node_modules/kandev/bin/cli.js",
LogDir: "/tmp/kandev/logs",
}},
}
plist := renderLaunchdHelperPlist("com.kdlbs.kandev.self-update.test", req)
// The whole point of this change: the helper must run exactly once. A
// KeepAlive=true (the implicit behaviour of `launchctl submit`) would make
// launchd re-run the one-shot updater forever.
if !strings.Contains(plist, "<key>KeepAlive</key>\n <false/>") {
t.Fatalf("plist must set KeepAlive=false to run once:\n%s", plist)
}
for _, want := range []string{
"<string>com.kdlbs.kandev.self-update.test</string>",
"<key>RunAtLoad</key>\n <true/>",
"<string>/opt/homebrew/bin/node</string>",
"<string>/tmp/npm-global/lib/node_modules/kandev/bin/cli.js</string>",
"<string>self-update</string>",
"<string>/tmp/intent.json</string>",
"<key>PATH</key>\n <string>/opt/homebrew/bin:/usr/bin</string>",
"<key>NPM_CONFIG_PREFIX</key>\n <string>/tmp/npm-global</string>",
"<string>/tmp/kandev/logs/self-update-launchd.log</string>",
} {
if !strings.Contains(plist, want) {
t.Fatalf("plist missing %q:\n%s", want, plist)
}
}
// Must not regress to the looping `launchctl submit` mechanism.
if strings.Contains(plist, "submit") {
t.Fatalf("plist should not reference submit:\n%s", plist)
}
}
func TestLaunchdSelfUpdateDomain(t *testing.T) {
user := applyRequest{Intent: updateIntent{Install: serviceInstallMetadata{Mode: installModeUser}}}
if got := launchdSelfUpdateDomain(user, 501); got != "gui/501" {
t.Fatalf("user domain=%q want gui/501", got)
}
system := applyRequest{Intent: updateIntent{Install: serviceInstallMetadata{Mode: installModeSystem}}}
if got := launchdSelfUpdateDomain(system, 501); got != "system" {
t.Fatalf("system domain=%q want system", got)
}
}
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func waitForJobState(t *testing.T, tracker *jobs.Tracker, id string, want jobs.State) *jobs.Job {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
job := tracker.Get(id)
if job != nil && job.State == want {
return job
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("job %s did not reach %s", id, want)
return nil
}
@@ -1,6 +1,7 @@
package updates
import (
"context"
"errors"
"math"
"net/http"
@@ -21,6 +22,10 @@ func HandleGet(svc *Service) gin.HandlerFunc {
}
}
type applyRequestBody struct {
Confirm string `json:"confirm"`
}
// HandleCheck triggers a synchronous GitHub poll. When the per-process
// limiter denies the request a 429 is returned with retry_after_seconds.
// Other errors are surfaced as 502 since the upstream is GitHub.
@@ -46,3 +51,32 @@ func HandleCheck(svc *Service) gin.HandlerFunc {
c.JSON(http.StatusOK, resp)
}
}
// HandleApply queues a service-managed self-update. It is deliberately gated
// behind service metadata and a browser same-origin check because the helper
// mutates the local Kandev installation and restarts the service.
func HandleApply(svc *Service) gin.HandlerFunc {
return func(c *gin.Context) {
if !sameOriginOrNoOrigin(c.Request) {
c.JSON(http.StatusForbidden, gin.H{"error": "cross-origin update apply is not allowed"})
return
}
var req applyRequestBody
_ = c.ShouldBindJSON(&req)
jobID, err := svc.Apply(context.Background(), req.Confirm)
if errors.Is(err, ErrApplyConfirm) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, ErrNoUpdateAvailable) || errors.Is(err, ErrApplyUnsupported) ||
errors.Is(err, ErrApplyInProgress) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, ApplyResponse{JobID: jobID})
}
}
@@ -1,6 +1,7 @@
package updates
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -20,6 +21,7 @@ func newRouter(svc *Service) *gin.Engine {
api := r.Group("/api/v1/system")
api.GET("/updates", HandleGet(svc))
api.POST("/updates/check", HandleCheck(svc))
api.POST("/updates/apply", HandleApply(svc))
return r
}
@@ -47,6 +49,34 @@ func TestHandleGet_ReturnsZeroValues(t *testing.T) {
}
}
func TestHandleGet_IncludesNonServiceInstallState(t *testing.T) {
pool := newTestPool(t)
svc := NewService(pool, "v1.0.0", nil, logger.Default())
r := newRouter(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/updates", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
install, ok := body["install"].(map[string]interface{})
if !ok {
t.Fatalf("install state missing from response: %s", w.Body.String())
}
if got := install["running_as_service"]; got != false {
t.Errorf("running_as_service=%v want false", got)
}
if got := body["apply_supported"]; got != false {
t.Errorf("apply_supported=%v want false", got)
}
}
func TestHandleCheck_FirstCall200(t *testing.T) {
pool := newTestPool(t)
srv, _ := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1")
@@ -128,3 +158,88 @@ func TestHandleCheck_GitHubFailureReturns502(t *testing.T) {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
func TestHandleApply_RejectsCrossOrigin(t *testing.T) {
pool := newTestPool(t)
svc := NewService(pool, "v1.0.0", nil, logger.Default())
r := newRouter(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/apply", bytes.NewBufferString(`{"confirm":"UPDATE"}`))
req.Host = "kandev.local"
req.Header.Set("Origin", "https://evil.example")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
func TestHandleApply_WrongConfirmReturns400(t *testing.T) {
pool := newTestPool(t)
svc := NewService(pool, "v1.0.0", nil, logger.Default())
r := newRouter(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/apply", bytes.NewBufferString(`{"confirm":"NOPE"}`))
req.Host = "localhost:38429"
req.Header.Set("Origin", "http://localhost:38429")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
func TestHandleApply_RejectsCrossScheme(t *testing.T) {
pool := newTestPool(t)
svc := NewService(pool, "v1.0.0", nil, logger.Default())
r := newRouter(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/apply", bytes.NewBufferString(`{"confirm":"UPDATE"}`))
req.Host = "localhost:38429"
// Server was reached over plain http (no TLS); an https Origin is cross-scheme.
req.Header.Set("Origin", "https://localhost:38429")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s want 403", w.Code, w.Body.String())
}
}
func TestHandleApply_HonorsForwardedProtoForScheme(t *testing.T) {
pool := newTestPool(t)
svc := NewService(pool, "v1.0.0", nil, logger.Default())
r := newRouter(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/apply", bytes.NewBufferString(`{"confirm":"UPDATE"}`))
req.Host = "localhost:38429"
req.Header.Set("Origin", "https://localhost:38429")
// A reverse proxy terminated TLS upstream, so the https Origin is same-origin.
req.Header.Set("X-Forwarded-Proto", "https")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Passes the same-origin gate, so it proceeds to the install-state check and
// is refused there (409) rather than blocked as cross-origin (403).
if w.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s want 409 (not 403)", w.Code, w.Body.String())
}
}
func TestHandleApply_RejectsLoopbackDifferentPort(t *testing.T) {
pool := newTestPool(t)
svc := NewService(pool, "v1.0.0", nil, logger.Default())
r := newRouter(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/apply", bytes.NewBufferString(`{"confirm":"UPDATE"}`))
req.Host = "localhost:38429"
req.Header.Set("Origin", "http://localhost:37429")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
@@ -0,0 +1,190 @@
package updates
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
envRunningAsService = "KANDEV_RUNNING_AS_SERVICE"
envServiceMode = "KANDEV_SERVICE_MODE"
envServiceManager = "KANDEV_SERVICE_MANAGER"
envInstallKind = "KANDEV_INSTALL_KIND"
envServiceMetadata = "KANDEV_SERVICE_METADATA"
serviceMetadataVersion = 1
managedMarkerText = "managed by kandev"
installModeUser = "user"
installModeSystem = "system"
serviceManagerSystemd = "systemd"
serviceManagerLaunchd = "launchd"
installKindHomebrew = "homebrew"
installKindNPM = "npm"
installKindNPX = "npx"
manualServiceInstall = "kandev service install"
manualServiceRestart = "kandev service restart"
)
type serviceInstallMetadata struct {
Version int `json:"version"`
Manager string `json:"manager"`
Mode string `json:"mode"`
Kind string `json:"kind"`
HomeDir string `json:"home_dir"`
LogDir string `json:"log_dir"`
ServicePath string `json:"service_path"`
NodePath string `json:"node_path"`
CLIEntry string `json:"cli_entry"`
BundleDir string `json:"bundle_dir,omitempty"`
LauncherVersion string `json:"launcher_version,omitempty"`
Port int `json:"port,omitempty"`
SystemUser string `json:"system_user,omitempty"`
InstalledAt string `json:"installed_at"`
}
func (s *Service) detectInstallState() (InstallStateResponse, *serviceInstallMetadata) {
if s.getenv(envRunningAsService) != "true" {
return InstallStateResponse{}, nil
}
state := InstallStateResponse{
RunningAsService: true,
ManagedService: false,
Mode: s.getenv(envServiceMode),
Manager: s.getenv(envServiceManager),
Kind: s.getenv(envInstallKind),
MetadataPath: s.serviceMetadataPath(),
}
metadata, err := readServiceMetadata(state.MetadataPath)
if err != nil {
return state, nil
}
state = mergeInstallMetadata(state, metadata)
state.ManagedService = s.metadataMatchesService(state, metadata)
return state, metadata
}
func (s *Service) serviceMetadataPath() string {
if p := s.getenv(envServiceMetadata); p != "" {
return p
}
if s.homeDir == "" {
return ""
}
return filepath.Join(s.homeDir, "service", "install.json")
}
func readServiceMetadata(metadataPath string) (*serviceInstallMetadata, error) {
if metadataPath == "" {
return nil, errors.New("metadata path missing")
}
data, err := os.ReadFile(metadataPath)
if err != nil {
return nil, err
}
var metadata serviceInstallMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return nil, err
}
if metadata.Version != serviceMetadataVersion {
return nil, fmt.Errorf("unsupported service metadata version %d", metadata.Version)
}
if metadata.ServicePath == "" || metadata.NodePath == "" || metadata.CLIEntry == "" {
return nil, errors.New("service metadata missing required paths")
}
return &metadata, nil
}
func mergeInstallMetadata(state InstallStateResponse, metadata *serviceInstallMetadata) InstallStateResponse {
if state.Mode == "" {
state.Mode = metadata.Mode
}
if state.Manager == "" {
state.Manager = metadata.Manager
}
if state.Kind == "" {
state.Kind = metadata.Kind
}
return state
}
func (s *Service) metadataMatchesService(state InstallStateResponse, metadata *serviceInstallMetadata) bool {
if metadata == nil {
return false
}
if state.Mode != metadata.Mode || state.Manager != metadata.Manager || state.Kind != metadata.Kind {
return false
}
content, err := os.ReadFile(metadata.ServicePath)
if err != nil {
return false
}
text := string(content)
if !strings.Contains(text, managedMarkerText) {
return false
}
if !strings.Contains(text, envRunningAsService) || !strings.Contains(text, envServiceMetadata) {
return false
}
return strings.Contains(text, state.MetadataPath) || strings.Contains(text, escapeXML(state.MetadataPath))
}
func (s InstallStateResponse) applySupport() (bool, string) {
if !s.RunningAsService {
return false, "Kandev is not running as a managed service."
}
if !s.ManagedService {
return false, "Kandev service metadata is missing or invalid."
}
if s.Mode != installModeUser {
return false, "Self-update is only available for user services."
}
if s.Manager != serviceManagerSystemd && s.Manager != serviceManagerLaunchd {
return false, "This service manager does not support UI self-update."
}
if s.Kind != installKindHomebrew && s.Kind != installKindNPM && s.Kind != installKindNPX {
return false, "This installation method does not support UI self-update."
}
return true, ""
}
func manualCommands(install InstallStateResponse, latest string) []string {
target := strings.TrimPrefix(latest, "v")
if target == "" {
target = "latest"
}
installCmd, restartCmd := manualServiceCommands(install)
switch install.Kind {
case installKindHomebrew:
return []string{"brew upgrade kandev", installCmd, restartCmd}
case installKindNPM:
return []string{"npm install -g kandev@" + target, installCmd, restartCmd}
case installKindNPX:
// installCmd already starts with "kandev "; npx supplies the package, so
// strip the duplicate binary name (npx -y kandev@X service install ...).
return []string{"npx -y kandev@" + target + " " + strings.TrimPrefix(installCmd, "kandev "), restartCmd}
default:
return []string{installCmd, restartCmd}
}
}
func manualServiceCommands(install InstallStateResponse) (string, string) {
if install.Mode == installModeSystem {
return manualServiceInstall + " --system", manualServiceRestart + " --system"
}
return manualServiceInstall, manualServiceRestart
}
func escapeXML(value string) string {
replacer := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
"'", "&apos;",
)
return replacer.Replace(value)
}
@@ -0,0 +1,159 @@
package updates
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/kandev/kandev/internal/common/logger"
)
func TestService_GetManagedUserServiceSupportsApply(t *testing.T) {
homeDir := t.TempDir()
metadataPath, _ := writeServiceInstallForTest(t, homeDir, serviceInstallMetadata{
Manager: "systemd",
Mode: "user",
Kind: "npm",
HomeDir: homeDir,
LogDir: filepath.Join(homeDir, "logs"),
ServicePath: filepath.Join(homeDir, "kandev.service"),
NodePath: "/usr/bin/node",
CLIEntry: "/usr/lib/node_modules/kandev/bin/cli.js",
})
t.Setenv(envRunningAsService, "true")
t.Setenv(envServiceMode, "user")
t.Setenv(envServiceManager, "systemd")
t.Setenv(envInstallKind, "npm")
t.Setenv(envServiceMetadata, metadataPath)
svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir))
resp, err := svc.Get()
if err != nil {
t.Fatalf("Get: %v", err)
}
if !resp.Install.RunningAsService || !resp.Install.ManagedService {
t.Fatalf("install state = %+v, want managed service", resp.Install)
}
if !resp.ApplySupported {
t.Fatalf("ApplySupported=false reason=%q", resp.ApplyUnsupportedReason)
}
}
func TestService_GetSystemServiceDisablesApply(t *testing.T) {
homeDir := t.TempDir()
metadataPath, _ := writeServiceInstallForTest(t, homeDir, serviceInstallMetadata{
Manager: "systemd",
Mode: "system",
Kind: "homebrew",
HomeDir: homeDir,
LogDir: filepath.Join(homeDir, "logs"),
ServicePath: filepath.Join(homeDir, "kandev.service"),
NodePath: "/opt/homebrew/bin/node",
CLIEntry: "/opt/homebrew/opt/kandev/libexec/cli/bin/cli.js",
})
t.Setenv(envRunningAsService, "true")
t.Setenv(envServiceMode, "system")
t.Setenv(envServiceManager, "systemd")
t.Setenv(envInstallKind, "homebrew")
t.Setenv(envServiceMetadata, metadataPath)
svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir))
resp, err := svc.Get()
if err != nil {
t.Fatalf("Get: %v", err)
}
if !resp.Install.ManagedService {
t.Fatalf("expected service to be recognised as managed")
}
if resp.ApplySupported {
t.Fatalf("ApplySupported=true for system service")
}
if resp.ApplyUnsupportedReason == "" {
t.Fatalf("expected unsupported reason")
}
if !hasString(resp.ManualCommands, "kandev service install --system") {
t.Fatalf("manual commands = %v, want system install command", resp.ManualCommands)
}
if !hasString(resp.ManualCommands, "kandev service restart --system") {
t.Fatalf("manual commands = %v, want system restart command", resp.ManualCommands)
}
}
func TestService_GetForeignServiceDisablesApply(t *testing.T) {
homeDir := t.TempDir()
metadataPath, servicePath := writeServiceInstallForTest(t, homeDir, serviceInstallMetadata{
Manager: "launchd",
Mode: "user",
Kind: "npx",
HomeDir: homeDir,
LogDir: filepath.Join(homeDir, "logs"),
ServicePath: filepath.Join(homeDir, "com.kdlbs.kandev.plist"),
NodePath: "/usr/local/bin/node",
CLIEntry: "/Users/alice/.npm/_npx/cache/node_modules/kandev/bin/cli.js",
})
if err := os.WriteFile(servicePath, []byte("not managed\n"), 0o644); err != nil {
t.Fatalf("write foreign service: %v", err)
}
t.Setenv(envRunningAsService, "true")
t.Setenv(envServiceMode, "user")
t.Setenv(envServiceManager, "launchd")
t.Setenv(envInstallKind, "npx")
t.Setenv(envServiceMetadata, metadataPath)
svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir))
resp, err := svc.Get()
if err != nil {
t.Fatalf("Get: %v", err)
}
if resp.Install.ManagedService {
t.Fatalf("foreign service was treated as managed: %+v", resp.Install)
}
if resp.ApplySupported {
t.Fatalf("ApplySupported=true for foreign service")
}
}
func TestManualCommandsNPXHasNoDuplicateBinaryName(t *testing.T) {
cmds := manualCommands(InstallStateResponse{Kind: installKindNPX, Mode: installModeUser}, "v1.2.3")
if !hasString(cmds, "npx -y kandev@1.2.3 service install") {
t.Fatalf("manual commands = %v, want non-duplicated npx install command", cmds)
}
if hasString(cmds, "npx -y kandev@1.2.3 kandev service install") {
t.Fatalf("npx manual command duplicates the binary name: %v", cmds)
}
}
func writeServiceInstallForTest(t *testing.T, homeDir string, metadata serviceInstallMetadata) (string, string) {
t.Helper()
metadata.Version = serviceMetadataVersion
if metadata.InstalledAt == "" {
metadata.InstalledAt = "2026-05-29T00:00:00Z"
}
metadataPath := filepath.Join(homeDir, "service", "install.json")
metadata.ServicePath = filepath.Clean(metadata.ServicePath)
if err := os.MkdirAll(filepath.Dir(metadataPath), 0o700); err != nil {
t.Fatalf("mkdir metadata dir: %v", err)
}
data, err := json.Marshal(metadata)
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(metadataPath, data, 0o600); err != nil {
t.Fatalf("write metadata: %v", err)
}
serviceContent := managedMarkerText + "\n" + envRunningAsService + "\n" + envServiceMetadata + "=" + metadataPath + "\n"
if err := os.WriteFile(metadata.ServicePath, []byte(serviceContent), 0o644); err != nil {
t.Fatalf("write service: %v", err)
}
return metadataPath, metadata.ServicePath
}
func hasString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
+190 -11
View File
@@ -8,7 +8,10 @@ import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
@@ -16,6 +19,7 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/persistence"
"github.com/kandev/kandev/internal/system/jobs"
)
// PollInterval is the cadence at which the background goroutine polls GitHub.
@@ -24,21 +28,65 @@ const PollInterval = 6 * time.Hour
// ManualCheckWindow is the minimum gap between two manual /check calls.
const ManualCheckWindow = 30 * time.Second
// applyGuardTTL bounds how long a single self-update launch blocks further
// applies. It matches the frontend progress timeout: past this window we assume
// the helper failed without restarting the backend and allow a retry, instead
// of wedging apply behind a permanent 409.
const applyGuardTTL = 5 * time.Minute
// ErrRateLimited indicates that a manual check was denied because the
// per-process rate limiter window has not yet elapsed.
var ErrRateLimited = errors.New("updates check rate limited")
// ErrApplyConfirm indicates that POST /updates/apply did not include the
// explicit confirmation token.
var ErrApplyConfirm = errors.New("update apply requires confirm=UPDATE")
// ErrApplyUnsupported indicates that the current installation cannot be
// updated safely from the UI.
var ErrApplyUnsupported = errors.New("update apply unsupported")
// ErrNoUpdateAvailable indicates that the cached release state does not show
// a newer semver than the running binary.
var ErrNoUpdateAvailable = errors.New("no update available")
// ErrApplyInProgress indicates that a self-update helper has already been
// launched and not yet completed, so a second apply is refused.
var ErrApplyInProgress = errors.New("a self-update is already in progress")
// devVersion is the sentinel used by the ldflags-injected current version
// when no release tag was baked in (i.e. local dev builds).
const devVersion = "dev"
// UpdatesResponse is the JSON shape returned by both Get() and Check().
type UpdatesResponse struct {
Current string `json:"current"`
Latest string `json:"latest"`
LatestURL string `json:"latest_url"`
LatestCheckedAt time.Time `json:"latest_checked_at"`
UpdateAvailable bool `json:"update_available"`
Current string `json:"current"`
Latest string `json:"latest"`
LatestURL string `json:"latest_url"`
LatestCheckedAt time.Time `json:"latest_checked_at"`
UpdateAvailable bool `json:"update_available"`
Install InstallStateResponse `json:"install"`
ApplySupported bool `json:"apply_supported"`
ApplyUnsupportedReason string `json:"apply_unsupported_reason,omitempty"`
ManualCommands []string `json:"manual_commands,omitempty"`
}
// ApplyResponse is the 202 payload returned when a self-update helper has
// been queued.
type ApplyResponse struct {
JobID string `json:"job_id"`
}
// InstallStateResponse describes whether this backend is currently running
// under a kandev-managed service unit/plist. The UI uses this to hard-gate
// one-click updates.
type InstallStateResponse struct {
RunningAsService bool `json:"running_as_service"`
ManagedService bool `json:"managed_service"`
Mode string `json:"mode,omitempty"`
Manager string `json:"manager,omitempty"`
Kind string `json:"kind,omitempty"`
MetadataPath string `json:"metadata_path,omitempty"`
}
// releaseURL is the GitHub endpoint the service polls. Tests override it via
@@ -52,6 +100,18 @@ type Service struct {
httpClient *http.Client
log *logger.Logger
limiter *Limiter
jobs *jobs.Tracker
homeDir string
getenv func(string) string
now func() time.Time
applyRun applyRunner
// applyStartedAt holds the unix-nano timestamp of the last self-update launch
// (0 = none in flight). It guards against two concurrent /updates/apply calls
// each launching a helper that would race the reinstall/restart, and
// self-expires after applyGuardTTL so a helper that exits 0 but never restarts
// the backend cannot wedge apply behind a permanent 409.
applyStartedAt atomic.Int64
// releaseURL is the GitHub endpoint hit by Check + the poller; defaults
// to DefaultReleaseURL and can be overridden by SetReleaseURL for tests.
@@ -76,18 +136,59 @@ type Service struct {
// the poller and Check() without spinning up an httptest server.
type Fetcher func(ctx context.Context) (tag, url string, err error)
// Option customises Service construction without growing NewService's public
// parameter list.
type Option func(*Service)
// WithJobs wires the system job tracker used by Apply.
func WithJobs(tracker *jobs.Tracker) Option {
return func(s *Service) {
s.jobs = tracker
}
}
// WithHomeDir sets the resolved Kandev home directory. It is used as a
// fallback for service metadata discovery.
func WithHomeDir(homeDir string) Option {
return func(s *Service) {
s.homeDir = homeDir
}
}
// WithEnvReader overrides environment reads. Intended for tests.
func WithEnvReader(getenv func(string) string) Option {
return func(s *Service) {
if getenv != nil {
s.getenv = getenv
}
}
}
// WithApplyRunner overrides helper launch. Intended for tests and e2e mocks.
func WithApplyRunner(r applyRunner) Option {
return func(s *Service) {
if r != nil {
s.applyRun = r
}
}
}
// NewService constructs the updates service. When httpClient is nil, a fresh
// client carrying defaultClientTimeout is allocated — http.DefaultClient has
// no timeout, so handing it the poller would let a stalled socket hang a
// goroutine for hours. current is the running binary version (typically
// injected via ldflags); the sentinel "dev" disables UpdateAvailable.
func NewService(pool *db.Pool, current string, httpClient *http.Client, log *logger.Logger) *Service {
func NewService(pool *db.Pool, current string, httpClient *http.Client, log *logger.Logger, opts ...Option) *Service {
if httpClient == nil {
httpClient = &http.Client{Timeout: defaultClientTimeout}
}
if log == nil {
log = logger.Default()
}
homeDir := ""
if dir, err := os.UserHomeDir(); err == nil {
homeDir = filepath.Join(dir, ".kandev")
}
s := &Service{
pool: pool,
current: current,
@@ -96,8 +197,15 @@ func NewService(pool *db.Pool, current string, httpClient *http.Client, log *log
limiter: NewLimiter(ManualCheckWindow),
releaseURL: DefaultReleaseURL,
pollerInterval: PollInterval,
homeDir: homeDir,
getenv: os.Getenv,
now: time.Now,
}
s.fetcher = s.defaultFetcher
s.applyRun = s.defaultApplyRunner
for _, opt := range opts {
opt(s)
}
return s
}
@@ -151,6 +259,63 @@ func (s *Service) Check(ctx context.Context) (UpdatesResponse, error) {
return s.fetchAndPersist(ctx)
}
// Apply validates the cached update and current service install, writes an
// intent file, and queues the OS-manager helper that performs the actual
// package upgrade + service reinstall.
func (s *Service) Apply(ctx context.Context, confirm string) (string, error) {
if confirm != "UPDATE" {
return "", ErrApplyConfirm
}
if s.jobs == nil {
return "", ErrApplyUnsupported
}
resp, metadata, err := s.applyPreflight()
if err != nil {
return "", err
}
// Claim the in-flight guard across the launch. A successful launch keeps it
// held because the helper restarts the backend shortly (which clears it by
// replacing the process); a launch error releases it for an immediate retry.
// The guard self-expires after applyGuardTTL (see claimApplyGuard) so a
// helper that exits 0 but never restarts cannot block apply forever.
if !s.claimApplyGuard() {
return "", ErrApplyInProgress
}
intentPath, intent, err := s.writeApplyIntent(resp, metadata)
if err != nil {
s.applyStartedAt.Store(0)
return "", err
}
req := applyRequest{
IntentPath: intentPath,
Intent: intent,
}
jobID := s.jobs.Start(ctx, "self-update", func(jobCtx context.Context) (map[string]interface{}, error) {
result, runErr := s.applyRun(jobCtx, req)
if runErr != nil {
s.applyStartedAt.Store(0)
}
return result, runErr
})
return jobID, nil
}
// claimApplyGuard atomically reserves the self-update guard. It succeeds when no
// apply is in flight or the previous one started more than applyGuardTTL ago
// (assumed dead); it returns false while a recent apply still holds it.
func (s *Service) claimApplyGuard() bool {
now := s.now().UnixNano()
for {
prev := s.applyStartedAt.Load()
if prev != 0 && now-prev < applyGuardTTL.Nanoseconds() {
return false
}
if s.applyStartedAt.CompareAndSwap(prev, now) {
return true
}
}
}
// RetryAfter exposes the limiter's remaining window so the HTTP handler can
// surface a Retry-After value to clients.
func (s *Service) RetryAfter() time.Duration {
@@ -197,12 +362,26 @@ func (s *Service) fetchAndPersist(ctx context.Context) (UpdatesResponse, error)
}
func (s *Service) buildResponse(latest, url string, checkedAt time.Time) UpdatesResponse {
install, _ := s.detectInstallState()
return s.buildResponseFrom(install, latest, url, checkedAt)
}
// buildResponseFrom assembles the response from an already-detected install
// state. Apply reuses this so the ApplySupported gate and the intent file are
// derived from a single install-state snapshot rather than two independent
// reads (closing a narrow TOCTOU window).
func (s *Service) buildResponseFrom(install InstallStateResponse, latest, url string, checkedAt time.Time) UpdatesResponse {
applySupported, reason := install.applySupport()
return UpdatesResponse{
Current: s.current,
Latest: latest,
LatestURL: url,
LatestCheckedAt: checkedAt,
UpdateAvailable: s.updateAvailable(latest),
Current: s.current,
Latest: latest,
LatestURL: url,
LatestCheckedAt: checkedAt,
UpdateAvailable: s.updateAvailable(latest),
Install: install,
ApplySupported: applySupported,
ApplyUnsupportedReason: reason,
ManualCommands: manualCommands(install, latest),
}
}
+35
View File
@@ -0,0 +1,35 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
KANDEV_HOME_DIR,
resolveCacheDir,
resolveDataDir,
resolveDatabasePath,
resolveKandevHomeDir,
} from "./constants";
describe("runtime data paths", () => {
it("defaults to the user's kandev home", () => {
expect(resolveKandevHomeDir({})).toBe(KANDEV_HOME_DIR);
});
it("honors KANDEV_HOME_DIR for service-managed launches", () => {
const env = { KANDEV_HOME_DIR: "/srv/kandev-test" };
expect(resolveKandevHomeDir(env)).toBe("/srv/kandev-test");
expect(resolveCacheDir(env)).toBe(path.join("/srv/kandev-test", "bin"));
expect(resolveDataDir(env)).toBe(path.join("/srv/kandev-test", "data"));
expect(resolveDatabasePath(env)).toBe(path.join("/srv/kandev-test", "data", "kandev.db"));
});
it("lets an explicit KANDEV_DATABASE_PATH override the home-derived path", () => {
expect(
resolveDatabasePath({
KANDEV_HOME_DIR: "/srv/kandev-test",
KANDEV_DATABASE_PATH: "/tmp/custom.db",
}),
).toBe("/tmp/custom.db");
});
});
+16
View File
@@ -28,6 +28,22 @@ export const KANDEV_TASKS_DIR = path.join(KANDEV_HOME_DIR, "tasks");
export const CACHE_DIR = path.join(KANDEV_HOME_DIR, "bin");
export const DATA_DIR = path.join(KANDEV_HOME_DIR, "data");
export function resolveKandevHomeDir(env: NodeJS.ProcessEnv = process.env): string {
return env.KANDEV_HOME_DIR?.trim() || KANDEV_HOME_DIR;
}
export function resolveDataDir(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveKandevHomeDir(env), "data");
}
export function resolveCacheDir(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveKandevHomeDir(env), "bin");
}
export function resolveDatabasePath(env: NodeJS.ProcessEnv = process.env): string {
return env.KANDEV_DATABASE_PATH?.trim() || path.join(resolveDataDir(env), "kandev.db");
}
// Dev-mode root: an isolated kandev home inside the repo so that running
// `make dev` from inside a kandev-spawned task workspace does not touch the
// user's production state.
+3
View File
@@ -13,7 +13,10 @@ vi.mock("./constants", () => ({
get CACHE_DIR() {
return mockCacheDir.value;
},
resolveCacheDir: () => mockCacheDir.value,
DATA_DIR: "/tmp/kandev-test-data",
resolveDataDir: () => "/tmp/kandev-test-data",
resolveDatabasePath: () => "/tmp/kandev-test-data/kandev.db",
DEFAULT_BACKEND_PORT: 38429,
DEFAULT_WEB_PORT: 37429,
DEFAULT_AGENTCTL_PORT: 39429,
+17 -13
View File
@@ -4,12 +4,13 @@ import path from "node:path";
import { ensureExtracted, findBundleRoot, resolveWebServerPath } from "./bundle";
import {
CACHE_DIR,
DATA_DIR,
DEFAULT_AGENTCTL_PORT,
DEFAULT_BACKEND_PORT,
DEFAULT_WEB_PORT,
HEALTH_TIMEOUT_MS_RELEASE,
resolveCacheDir,
resolveDataDir,
resolveDatabasePath,
} from "./constants";
import { ensureAsset, getRelease } from "./github";
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health";
@@ -56,8 +57,9 @@ export function findCachedRelease(
platformDir: string,
version?: string,
): { cacheDir: string; tag: string } | null {
const rootCacheDir = resolveCacheDir();
if (version) {
const cacheDir = path.join(CACHE_DIR, version, platformDir);
const cacheDir = path.join(rootCacheDir, version, platformDir);
const bundleDir = path.join(cacheDir, "kandev");
const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev"));
if (fs.existsSync(backendBin)) {
@@ -67,15 +69,15 @@ export function findCachedRelease(
}
// No version specified — scan for cached tags and pick the latest.
if (!fs.existsSync(CACHE_DIR)) return null;
if (!fs.existsSync(rootCacheDir)) return null;
const entries = fs.readdirSync(CACHE_DIR).filter((d) => d.startsWith("v"));
const entries = fs.readdirSync(rootCacheDir).filter((d) => d.startsWith("v"));
if (entries.length === 0) return null;
const sorted = sortVersionsDesc(entries);
for (const tag of sorted) {
const cacheDir = path.join(CACHE_DIR, tag, platformDir);
const cacheDir = path.join(rootCacheDir, tag, platformDir);
const bundleDir = path.join(cacheDir, "kandev");
const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev"));
if (fs.existsSync(backendBin)) {
@@ -92,9 +94,10 @@ export function findCachedRelease(
* The previous version is kept as a fallback for offline use.
*/
export function cleanOldReleases(currentTag: string) {
const rootCacheDir = resolveCacheDir();
try {
if (!fs.existsSync(CACHE_DIR)) return;
const entries = fs.readdirSync(CACHE_DIR).filter((d) => d.startsWith("v"));
if (!fs.existsSync(rootCacheDir)) return;
const entries = fs.readdirSync(rootCacheDir).filter((d) => d.startsWith("v"));
if (entries.length <= 2) return;
const sorted = sortVersionsDesc(entries);
@@ -103,7 +106,7 @@ export function cleanOldReleases(currentTag: string) {
const keep = new Set<string>([currentTag, sorted[0], sorted[1]]);
for (const entry of entries) {
if (!keep.has(entry)) {
fs.rmSync(path.join(CACHE_DIR, entry), { recursive: true, force: true });
fs.rmSync(path.join(rootCacheDir, entry), { recursive: true, force: true });
}
}
} catch {
@@ -120,7 +123,7 @@ async function downloadRuntimeVersion(runtimeVersion: string): Promise<string> {
const release = await getRelease(runtimeVersion);
const tag = release.tag_name;
const assetName = `kandev-${platformDir}.tar.gz`;
const cacheDir = path.join(CACHE_DIR, tag, platformDir);
const cacheDir = path.join(resolveCacheDir(), tag, platformDir);
const archivePath = await ensureAsset(tag, assetName, cacheDir, (downloaded, total) => {
const percent = total ? Math.round((downloaded / total) * 100) : 0;
@@ -156,7 +159,7 @@ async function prepareBundleForLaunch({
} else {
try {
tag = await downloadRuntimeVersion(runtimeVersion);
const cacheDir = path.join(CACHE_DIR, tag, platformDir);
const cacheDir = path.join(resolveCacheDir(), tag, platformDir);
bundleDir = findBundleRoot(cacheDir);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
@@ -186,8 +189,9 @@ async function prepareBundleForLaunch({
const logLevel =
process.env.KANDEV_LOG_LEVEL?.trim() || (debug ? "debug" : verbose ? "info" : "warn");
fs.mkdirSync(DATA_DIR, { recursive: true });
const dbPath = path.join(DATA_DIR, "kandev.db");
const dataDir = resolveDataDir();
fs.mkdirSync(dataDir, { recursive: true });
const dbPath = resolveDatabasePath();
const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev"));
+24
View File
@@ -96,4 +96,28 @@ describe("parseServiceArgs", () => {
// happy path
expect(parseServiceArgs(["install", "--port=9000"]).port).toBe(9000);
});
it("parses hidden self-update helper flags", () => {
const args = parseServiceArgs(["self-update", "--intent", "/tmp/intent.json", "--dry-run"]);
expect(args.action).toBe("self-update");
expect(args.intent).toBe("/tmp/intent.json");
expect(args.dryRun).toBe(true);
});
it("requires --intent for self-update", () => {
expect(() => parseServiceArgs(["self-update"])).toThrow(/requires --intent/);
});
it("allows --help for self-update without --intent", () => {
const r = parseServiceArgs(["self-update", "--help"]);
expect(r.action).toBe("self-update");
expect(r.showHelp).toBe(true);
});
it("rejects self-update-only flags elsewhere", () => {
expect(() => parseServiceArgs(["install", "--intent=/tmp/intent.json"])).toThrow(
/--intent only applies/,
);
expect(() => parseServiceArgs(["install", "--dry-run"])).toThrow(/--dry-run only applies/);
});
});
+35 -1
View File
@@ -8,7 +8,8 @@ export type ServiceAction =
| "restart"
| "status"
| "logs"
| "config";
| "config"
| "self-update";
export type ServiceArgs = {
action: ServiceAction;
@@ -24,6 +25,10 @@ export type ServiceArgs = {
follow?: boolean;
/** Print help and exit. */
showHelp?: boolean;
/** Intent JSON path consumed by the hidden self-update helper. */
intent?: string;
/** Print the self-update command plan without mutating the install. */
dryRun?: boolean;
};
const VALID_ACTIONS = new Set<ServiceAction>([
@@ -35,6 +40,7 @@ const VALID_ACTIONS = new Set<ServiceAction>([
"status",
"logs",
"config",
"self-update",
]);
export function parseServiceArgs(argv: string[]): ServiceArgs {
@@ -66,6 +72,21 @@ export function parseServiceArgs(argv: string[]): ServiceArgs {
out.follow = true;
continue;
}
if (arg === "--dry-run") {
out.dryRun = true;
continue;
}
if (arg === "--intent") {
out.intent = takeValue(argv, i, "--intent");
i += 1;
continue;
}
if (arg.startsWith("--intent=")) {
const value = arg.slice("--intent=".length);
if (value.length === 0) throw new ParseError("--intent requires a value");
out.intent = value;
continue;
}
if (arg === "--port") {
out.port = parsePort(takeValue(argv, i, "--port"), "--port");
i += 1;
@@ -102,6 +123,19 @@ function validateActionFlags(args: ServiceArgs): void {
if (args.follow && args.action !== "logs") {
throw new ParseError(`--follow only applies to 'kandev service logs', not '${args.action}'`);
}
if (args.dryRun && args.action !== "self-update") {
throw new ParseError(
`--dry-run only applies to 'kandev service self-update', not '${args.action}'`,
);
}
if (args.intent && args.action !== "self-update") {
throw new ParseError(
`--intent only applies to 'kandev service self-update', not '${args.action}'`,
);
}
if (args.action === "self-update" && !args.showHelp && !args.intent) {
throw new ParseError("kandev service self-update requires --intent <path>");
}
const installOnly: Array<keyof ServiceArgs> = ["port", "homeDir", "noBootStart"];
if (args.action !== "install") {
for (const flag of installOnly) {
+7
View File
@@ -2,6 +2,7 @@ import { parseServiceArgs } from "./args";
import { printServiceConfig } from "./config";
import { runLinuxService } from "./linux";
import { runMacosService } from "./macos";
import { runSelfUpdateCommand } from "./self_update";
export function printServiceHelp(): void {
console.log(`kandev service — install kandev as an OS-managed service
@@ -49,6 +50,12 @@ export async function runServiceCommand(argv: string[]): Promise<void> {
printServiceConfig(args);
return;
}
// Hidden helper entrypoint used by the backend self-update endpoint. It is
// intentionally absent from help output.
if (args.action === "self-update") {
runSelfUpdateCommand(args);
return;
}
switch (process.platform) {
case "linux":
return runLinuxService(args);
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
import {
bootoutAndWait,
bootstrapWithRetry,
reloadService,
type LaunchctlResult,
} from "./launchctl";
type Call = { args: string[]; stdio: string };
function recordingRun(statusFor: (call: Call, index: number) => number | null): {
run: (args: string[], stdio: "inherit" | "ignore") => LaunchctlResult;
calls: Call[];
} {
const calls: Call[] = [];
const run = (args: string[], stdio: "inherit" | "ignore"): LaunchctlResult => {
const call = { args, stdio };
const status = statusFor(call, calls.length);
calls.push(call);
return { status };
};
return { run, calls };
}
describe("bootoutAndWait", () => {
it("polls `print` until the job is gone before returning", () => {
// print returns 0 (still loaded) twice, then non-zero (gone).
let printCount = 0;
const { run, calls } = recordingRun((call) => {
if (call.args[0] === "bootout") return 0;
if (call.args[0] === "print") {
printCount += 1;
return printCount <= 2 ? 0 : 1;
}
return 0;
});
const sleep = vi.fn();
bootoutAndWait("gui/501/com.kdlbs.kandev", { run, sleep });
expect(calls[0].args).toEqual(["bootout", "gui/501/com.kdlbs.kandev"]);
expect(calls.filter((c) => c.args[0] === "print")).toHaveLength(3);
expect(sleep).toHaveBeenCalledTimes(2);
});
it("returns immediately when nothing is loaded", () => {
const { run, calls } = recordingRun((call) => (call.args[0] === "print" ? 1 : 0));
const sleep = vi.fn();
bootoutAndWait("gui/501/com.kdlbs.kandev", { run, sleep });
expect(calls.filter((c) => c.args[0] === "print")).toHaveLength(1);
expect(sleep).not.toHaveBeenCalled();
});
});
describe("bootstrapWithRetry", () => {
it("retries through the transient EIO and succeeds", () => {
// Reproduces the launchd teardown race: bootstrap returns 5 twice, then 0.
let bootstrapCount = 0;
const { run, calls } = recordingRun(() => {
bootstrapCount += 1;
return bootstrapCount < 3 ? 5 : 0;
});
const sleep = vi.fn();
expect(() =>
bootstrapWithRetry("gui/501", "/Users/a/Library/LaunchAgents/x.plist", { run, sleep }),
).not.toThrow();
expect(calls).toHaveLength(3);
expect(sleep).toHaveBeenCalledTimes(2);
});
it("throws with the last exit code after exhausting retries", () => {
const { run, calls } = recordingRun(() => 5);
const sleep = vi.fn();
expect(() =>
bootstrapWithRetry("gui/501", "/Users/a/Library/LaunchAgents/x.plist", { run, sleep }),
).toThrow(/failed with code 5/);
expect(calls).toHaveLength(5);
});
it("succeeds on the first attempt without sleeping", () => {
const { run, calls } = recordingRun(() => 0);
const sleep = vi.fn();
bootstrapWithRetry("gui/501", "/x.plist", { run, sleep });
expect(calls).toHaveLength(1);
expect(sleep).not.toHaveBeenCalled();
});
});
describe("reloadService", () => {
it("waits for teardown, then bootstraps — surviving an EIO mid-reload", () => {
// Live-service reload: print stays loaded once, bootstrap hits EIO once.
let printCount = 0;
let bootstrapCount = 0;
const { run, calls } = recordingRun((call) => {
if (call.args[0] === "print") {
printCount += 1;
return printCount <= 1 ? 0 : 1;
}
if (call.args[0] === "bootstrap") {
bootstrapCount += 1;
return bootstrapCount < 2 ? 5 : 0;
}
return 0;
});
const sleep = vi.fn();
reloadService("gui/501/com.kdlbs.kandev", "gui/501", "/x.plist", { run, sleep });
const order = calls.map((c) => c.args[0]);
expect(order[0]).toBe("bootout");
// bootout precedes the first bootstrap
expect(order.indexOf("bootout")).toBeLessThan(order.indexOf("bootstrap"));
expect(calls.filter((c) => c.args[0] === "bootstrap")).toHaveLength(2);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { spawnSync } from "node:child_process";
/**
* launchd's `bootout` is asynchronous: the command returns as soon as launchd
* accepts the request, but the job (and, for a live service, its process) may
* still be tearing down. Issuing `bootstrap` for the same label before that
* teardown finishes races launchd and surfaces as:
*
* Bootstrap failed: 5: Input/output error
*
* This is invisible on a fresh install (nothing to tear down) and in unit tests
* (no live launchctl), but it reliably breaks self-update — there the service is
* actively running when `kandev service install` reloads it. The helpers below
* make the bootout→bootstrap dance robust: wait for the old job to actually
* disappear, then retry the bootstrap through the brief residual EIO window.
*/
export type LaunchctlResult = { status: number | null };
export type LaunchctlStdio = "inherit" | "ignore";
export type LaunchctlRunner = (args: string[], stdio: LaunchctlStdio) => LaunchctlResult;
export type SleepFn = (ms: number) => void;
export type LaunchctlDeps = {
run?: LaunchctlRunner;
sleep?: SleepFn;
};
const BOOTOUT_POLL_INTERVAL_MS = 100;
const BOOTOUT_POLL_ATTEMPTS = 50; // ~5s ceiling waiting for teardown
const BOOTSTRAP_MAX_ATTEMPTS = 5;
const BOOTSTRAP_RETRY_BASE_MS = 300;
export function spawnLaunchctl(args: string[], stdio: LaunchctlStdio): LaunchctlResult {
const res = spawnSync("launchctl", args, { stdio });
return { status: res.status };
}
// Block the current thread for `ms`. The launchctl orchestration runs in the
// synchronous `service install`/`start`/`restart` paths, so we can't await a
// timer — Atomics.wait gives a dependency-free blocking sleep.
export function sleepSync(ms: number): void {
if (ms <= 0) return;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function isLoaded(target: string, run: LaunchctlRunner): boolean {
// `launchctl print <target>` exits non-zero once the label is gone.
return run(["print", target], "ignore").status === 0;
}
/**
* Boot out a launchd job and wait until launchd has fully removed it. A no-op
* (returns promptly) when the label isn't loaded, so fresh installs aren't
* slowed down.
*/
export function bootoutAndWait(target: string, deps: LaunchctlDeps = {}): void {
const run = deps.run ?? spawnLaunchctl;
const sleep = deps.sleep ?? sleepSync;
run(["bootout", target], "ignore");
for (let attempt = 0; attempt < BOOTOUT_POLL_ATTEMPTS; attempt += 1) {
if (!isLoaded(target, run)) {
return;
}
sleep(BOOTOUT_POLL_INTERVAL_MS);
}
}
/**
* Bootstrap a launchd job, retrying through the transient EIO that launchd
* returns while a previous instance of the same label finishes tearing down.
* Throws with the last exit code if every attempt fails.
*/
export function bootstrapWithRetry(
domain: string,
plistPath: string,
deps: LaunchctlDeps = {},
): void {
const run = deps.run ?? spawnLaunchctl;
const sleep = deps.sleep ?? sleepSync;
let lastStatus: number | null = null;
for (let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt += 1) {
lastStatus = run(["bootstrap", domain, plistPath], "inherit").status;
if (lastStatus === 0) {
return;
}
if (attempt < BOOTSTRAP_MAX_ATTEMPTS) {
sleep(BOOTSTRAP_RETRY_BASE_MS * attempt);
}
}
throw new Error(`launchctl bootstrap ${domain} ${plistPath} failed with code ${lastStatus}`);
}
/**
* Reload a launchd job: fully unload (waiting for teardown) then bootstrap with
* retry. This is the safe sequence whenever the target might currently be
* running — installs that refresh a live service, and `start`.
*/
export function reloadService(
target: string,
domain: string,
plistPath: string,
deps: LaunchctlDeps = {},
): void {
bootoutAndWait(target, deps);
bootstrapWithRetry(domain, plistPath, deps);
}
+27 -2
View File
@@ -5,6 +5,11 @@ import path from "node:path";
import type { ServiceArgs } from "./args";
import { dumpJournalctlLogs, waitForServiceHealth } from "./health_check";
import { commandExists, writeUnitFile } from "./install_helpers";
import {
buildServiceInstallMetadata,
serviceMetadataPath,
writeServiceInstallMetadata,
} from "./metadata";
import {
captureLauncher,
currentUsername,
@@ -59,6 +64,9 @@ export async function runLinuxService(args: ServiceArgs): Promise<void> {
case "config":
// Handled by the dispatcher in index.ts before reaching the platform layer.
throw new Error("unreachable: config action handled in service/index.ts");
case "self-update":
// Handled by the dispatcher in index.ts before reaching the platform layer.
throw new Error("unreachable: self-update action handled in service/index.ts");
default: {
const _exhaustive: never = args.action;
throw new Error(`unhandled service action: ${_exhaustive as string}`);
@@ -77,17 +85,34 @@ function installSync(ctx: Ctx): void {
const launcher = captureLauncher();
const homeDir = resolveHomeDir(ctx.args.homeDir, ctx.isSystem);
const logDir = resolveLogDir(homeDir);
const metadataPath = serviceMetadataPath(homeDir);
const mode = ctx.isSystem ? "system" : "user";
const systemUser = ctx.isSystem ? resolveServiceUser(true) : undefined;
const unit = renderSystemdUnit({
launcher,
homeDir,
logDir,
port: ctx.args.port,
systemUser: ctx.isSystem ? resolveServiceUser(true) : undefined,
mode: ctx.isSystem ? "system" : "user",
systemUser,
mode,
serviceMetadataPath: metadataPath,
});
fs.mkdirSync(path.dirname(ctx.unitPath), { recursive: true });
const outcome = writeUnitFile(ctx.unitPath, unit);
writeServiceInstallMetadata(
metadataPath,
buildServiceInstallMetadata({
manager: "systemd",
mode,
launcher,
homeDir,
logDir,
servicePath: ctx.unitPath,
port: ctx.args.port,
systemUser,
}),
);
runSystemctl(ctx, ["daemon-reload"]);
// Always run enable --now so 'install' is fully idempotent: if the user
+40 -8
View File
@@ -6,6 +6,12 @@ import path from "node:path";
import type { ServiceArgs } from "./args";
import { dumpLaunchdLogs, waitForServiceHealth } from "./health_check";
import { commandExists, writeUnitFile } from "./install_helpers";
import { reloadService } from "./launchctl";
import {
buildServiceInstallMetadata,
serviceMetadataPath,
writeServiceInstallMetadata,
} from "./metadata";
import {
captureLauncher,
LAUNCHD_LABEL,
@@ -64,6 +70,9 @@ export async function runMacosService(args: ServiceArgs): Promise<void> {
case "config":
// Handled by the dispatcher in index.ts before reaching the platform layer.
throw new Error("unreachable: config action handled in service/index.ts");
case "self-update":
// Handled by the dispatcher in index.ts before reaching the platform layer.
throw new Error("unreachable: self-update action handled in service/index.ts");
default: {
const _exhaustive: never = args.action;
throw new Error(`unhandled service action: ${_exhaustive as string}`);
@@ -80,6 +89,9 @@ function installSync(ctx: Ctx): { logDir: string } {
const launcher = captureLauncher();
const homeDir = resolveHomeDir(ctx.args.homeDir, ctx.isSystem);
const logDir = resolveLogDir(homeDir);
const metadataPath = serviceMetadataPath(homeDir);
const mode = ctx.isSystem ? "system" : "user";
const systemUser = ctx.isSystem ? resolveServiceUser(true) : undefined;
fs.mkdirSync(logDir, { recursive: true });
const plist = renderLaunchdPlist({
@@ -87,19 +99,37 @@ function installSync(ctx: Ctx): { logDir: string } {
homeDir,
logDir,
port: ctx.args.port,
systemUser: ctx.isSystem ? resolveServiceUser(true) : undefined,
mode: ctx.isSystem ? "system" : "user",
systemUser,
mode,
serviceMetadataPath: metadataPath,
});
fs.mkdirSync(path.dirname(ctx.plistPath), { recursive: true });
const outcome = writeUnitFile(ctx.plistPath, plist);
writeServiceInstallMetadata(
metadataPath,
buildServiceInstallMetadata({
manager: "launchd",
mode,
launcher,
homeDir,
logDir,
servicePath: ctx.plistPath,
port: ctx.args.port,
systemUser,
}),
);
// launchctl bootstrap fails if the label is already loaded — bootout first
// (ignoring its error if nothing was loaded). This means 'install' is
// idempotent: re-running it reloads the unit even if the file is unchanged,
// which is how we recover from a user manually unloading the service.
spawnSync("launchctl", ["bootout", ctx.target], { stdio: "ignore" });
runLaunchctl(["bootstrap", ctx.domain, ctx.plistPath]);
//
// `reloadService` waits for bootout's async teardown before bootstrapping and
// retries the transient EIO ("Bootstrap failed: 5") that launchd returns while
// a still-running instance is torn down — the failure that broke self-update,
// where this install runs against a live service. See launchctl.ts.
reloadService(ctx.target, ctx.domain, ctx.plistPath);
runLaunchctl(["enable", ctx.target], { allowFailure: true });
console.log(
outcome === "unchanged"
@@ -131,9 +161,9 @@ function uninstall(ctx: Ctx): void {
// so each begins with a bootout-then-bootstrap dance similar to installSync.
function startService(ctx: Ctx): void {
// Idempotent: if the label is already loaded, bootstrap would fail. Bootout
// first so start works whether the service was previously running or stopped.
spawnSync("launchctl", ["bootout", ctx.target], { stdio: "ignore" });
runLaunchctl(["bootstrap", ctx.domain, ctx.plistPath]);
// first (waiting for teardown) so start works whether the service was
// previously running or stopped.
reloadService(ctx.target, ctx.domain, ctx.plistPath);
}
function stopService(ctx: Ctx): void {
@@ -146,7 +176,9 @@ function stopService(ctx: Ctx): void {
function restartService(ctx: Ctx): void {
const res = spawnSync("launchctl", ["kickstart", "-k", ctx.target], { stdio: "inherit" });
if (res.status === 0) return;
runLaunchctl(["bootstrap", ctx.domain, ctx.plistPath]);
// kickstart fails when the job isn't loaded — reload it (waiting for any
// residual teardown and retrying the transient bootstrap EIO).
reloadService(ctx.target, ctx.domain, ctx.plistPath);
}
function showStatus(ctx: Ctx): void {
+66
View File
@@ -0,0 +1,66 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildServiceInstallMetadata,
serviceMetadataPath,
writeServiceInstallMetadata,
} from "./metadata";
describe("service metadata", () => {
it("builds the install metadata written beside service units", () => {
const metadata = buildServiceInstallMetadata({
manager: "systemd",
mode: "user",
launcher: {
nodePath: "/usr/bin/node",
cliEntry: "/usr/lib/node_modules/kandev/bin/cli.js",
kind: "npm",
},
homeDir: "/home/alice/.kandev",
logDir: "/home/alice/.kandev/logs",
servicePath: "/home/alice/.config/systemd/user/kandev.service",
port: 38429,
now: new Date("2026-05-29T00:00:00.000Z"),
});
expect(metadata).toMatchObject({
version: 1,
manager: "systemd",
mode: "user",
kind: "npm",
home_dir: "/home/alice/.kandev",
service_path: "/home/alice/.config/systemd/user/kandev.service",
node_path: "/usr/bin/node",
cli_entry: "/usr/lib/node_modules/kandev/bin/cli.js",
port: 38429,
installed_at: "2026-05-29T00:00:00.000Z",
});
});
it("writes metadata JSON under <home>/service/install.json", () => {
const tmp = fs.mkdtempSync(path.join(process.cwd(), "metadata-test-"));
try {
const target = serviceMetadataPath(tmp);
writeServiceInstallMetadata(target, {
version: 1,
manager: "launchd",
mode: "user",
kind: "homebrew",
home_dir: tmp,
log_dir: path.join(tmp, "logs"),
service_path: "/Users/alice/Library/LaunchAgents/com.kdlbs.kandev.plist",
node_path: "/opt/homebrew/bin/node",
cli_entry: "/opt/homebrew/opt/kandev/libexec/cli/bin/cli.js",
installed_at: "2026-05-29T00:00:00.000Z",
});
const parsed = JSON.parse(fs.readFileSync(target, "utf8")) as { manager: string };
expect(parsed.manager).toBe("launchd");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
+76
View File
@@ -0,0 +1,76 @@
import fs from "node:fs";
import path from "node:path";
import type { LauncherInfo } from "./paths";
export type ServiceManager = "systemd" | "launchd";
export type ServiceMode = "user" | "system";
export type ServiceInstallMetadata = {
version: 1;
manager: ServiceManager;
mode: ServiceMode;
kind: LauncherInfo["kind"];
home_dir: string;
log_dir: string;
service_path: string;
node_path: string;
cli_entry: string;
bundle_dir?: string;
launcher_version?: string;
port?: number;
system_user?: string;
installed_at: string;
};
export type BuildServiceInstallMetadataInput = {
manager: ServiceManager;
mode: ServiceMode;
launcher: LauncherInfo;
homeDir: string;
logDir: string;
servicePath: string;
port?: number;
systemUser?: string;
now?: Date;
};
export function serviceMetadataPath(homeDir: string): string {
return path.join(homeDir, "service", "install.json");
}
export function buildServiceInstallMetadata(
input: BuildServiceInstallMetadataInput,
): ServiceInstallMetadata {
const out: ServiceInstallMetadata = {
version: 1,
manager: input.manager,
mode: input.mode,
kind: input.launcher.kind,
home_dir: input.homeDir,
log_dir: input.logDir,
service_path: input.servicePath,
node_path: input.launcher.nodePath,
cli_entry: input.launcher.cliEntry,
installed_at: (input.now ?? new Date()).toISOString(),
};
if (input.launcher.bundleDir) out.bundle_dir = input.launcher.bundleDir;
if (input.launcher.version) out.launcher_version = input.launcher.version;
if (input.port !== undefined) out.port = input.port;
if (input.systemUser) out.system_user = input.systemUser;
return out;
}
export function writeServiceInstallMetadata(
metadataPath: string,
metadata: ServiceInstallMetadata,
): void {
const dir = path.dirname(metadataPath);
// `mode` on mkdir/writeFile only applies when the path is created. chmod after
// so a pre-existing service dir / install.json is tightened to owner-only too
// (it can hold launcher paths and the metadata that gates self-update).
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.chmodSync(dir, 0o700);
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
fs.chmodSync(metadataPath, 0o600);
}
+32
View File
@@ -5,6 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureLauncher, homebrewShimPath, resolveHomeDir, resolveServiceUser } from "./paths";
const originalArgv = [...process.argv];
const originalBundleDir = process.env.KANDEV_BUNDLE_DIR;
const originalVersion = process.env.KANDEV_VERSION;
describe("resolveServiceUser", () => {
const originalSudoUser = process.env.SUDO_USER;
@@ -68,6 +72,32 @@ describe("resolveServiceUser", () => {
});
});
describe("captureLauncher", () => {
afterEach(() => {
process.argv.splice(0, process.argv.length, ...originalArgv);
if (originalBundleDir === undefined) delete process.env.KANDEV_BUNDLE_DIR;
else process.env.KANDEV_BUNDLE_DIR = originalBundleDir;
if (originalVersion === undefined) delete process.env.KANDEV_VERSION;
else process.env.KANDEV_VERSION = originalVersion;
vi.restoreAllMocks();
});
it("resolves npm global bin symlinks before detecting the install kind", () => {
delete process.env.KANDEV_BUNDLE_DIR;
delete process.env.KANDEV_VERSION;
process.argv[1] = "/tmp/kandev-test/npm-global/bin/kandev";
vi.spyOn(fs, "existsSync").mockReturnValue(true);
vi.spyOn(fs, "realpathSync").mockReturnValue(
"/tmp/kandev-test/npm-global/lib/node_modules/kandev/bin/cli.js",
);
expect(captureLauncher()).toMatchObject({
cliEntry: "/tmp/kandev-test/npm-global/lib/node_modules/kandev/bin/cli.js",
kind: "npm",
});
});
});
describe("resolveHomeDir", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -138,6 +168,7 @@ describe("captureLauncher shim resolution", () => {
vi.stubEnv("KANDEV_BUNDLE_DIR", "/opt/homebrew/Cellar/kandev/1.2.3/libexec");
// Both the cli.js entry and the shim resolve on disk.
vi.spyOn(fs, "existsSync").mockImplementation((p) => p === cliEntry || p === shim);
vi.spyOn(fs, "realpathSync").mockReturnValue(cliEntry);
const info = captureLauncher();
expect(info.kind).toBe("homebrew");
@@ -150,6 +181,7 @@ describe("captureLauncher shim resolution", () => {
vi.stubEnv("KANDEV_BUNDLE_DIR", "/opt/homebrew/Cellar/kandev/1.2.3/libexec");
// The cli.js entry resolves, but the floating shim does not exist.
vi.spyOn(fs, "existsSync").mockImplementation((p) => p === cliEntry);
vi.spyOn(fs, "realpathSync").mockReturnValue(cliEntry);
const info = captureLauncher();
expect(info.kind).toBe("homebrew");
+13 -7
View File
@@ -34,7 +34,7 @@ export function macosSystemPlistPath(): string {
return path.join(MACOS_SYSTEM_DAEMON_DIR, `${LAUNCHD_LABEL}.plist`);
}
export type LauncherKind = "homebrew" | "npm" | "unknown";
export type LauncherKind = "homebrew" | "npm" | "npx" | "unknown";
export type LauncherInfo = {
/** Absolute path to node executable (process.execPath at install time). */
@@ -87,8 +87,8 @@ export function homebrewShimPath(cliEntry: string): string | undefined {
*
* The unit file hard-codes absolute paths because systemd/launchd start with an
* empty PATH and may not see the user's `node` or `kandev` shim. By recording
* `process.execPath` (node) and `process.argv[1]` (cli.js) at install time we
* avoid any PATH lookups at service-run time.
* `process.execPath` (node) and the resolved CLI entry at install time we avoid
* any PATH lookups at service-run time.
*/
export function captureLauncher(): LauncherInfo {
const nodePath = process.execPath;
@@ -97,9 +97,11 @@ export function captureLauncher(): LauncherInfo {
const version = process.env.KANDEV_VERSION;
const kind: LauncherKind = bundleDir
? "homebrew"
: cliEntry.includes(`${path.sep}node_modules${path.sep}`)
? "npm"
: "unknown";
: looksLikeNpxEntry(cliEntry)
? "npx"
: cliEntry.includes(`${path.sep}node_modules${path.sep}`)
? "npm"
: "unknown";
// For Homebrew installs, prefer the floating bin shim so the unit survives
// `brew upgrade` (which deletes the versioned Cellar dir baked into nodePath
// /cliEntry). Only adopt it when the shim actually exists on disk; otherwise
@@ -112,10 +114,14 @@ export function captureLauncher(): LauncherInfo {
return { nodePath, cliEntry, kind, bundleDir, version, shimPath };
}
function looksLikeNpxEntry(cliEntry: string): boolean {
return cliEntry.includes(`${path.sep}_npx${path.sep}`);
}
function resolveCliEntry(): string {
const argvEntry = process.argv[1];
if (argvEntry && fs.existsSync(argvEntry)) {
return path.resolve(argvEntry);
return path.resolve(fs.realpathSync(argvEntry));
}
throw new Error(
"could not resolve the kandev CLI entry path from process.argv[1]; " +
+191
View File
@@ -0,0 +1,191 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { planSelfUpdate, runSelfUpdateCommand, type SelfUpdateIntent } from "./self_update";
function intent(kind: "homebrew" | "npm" | "npx" = "npm"): SelfUpdateIntent {
return {
version: 1,
target_tag: "v1.2.3",
target_version: "1.2.3",
latest_url: "https://example/v1.2.3",
created_at: "2026-05-29T00:00:00.000Z",
install: {
version: 1,
manager: "systemd",
mode: "user",
kind,
home_dir: "/home/alice/.kandev",
log_dir: "/home/alice/.kandev/logs",
service_path: "/home/alice/.config/systemd/user/kandev.service",
node_path: "/usr/bin/node",
cli_entry: "/usr/lib/node_modules/kandev/bin/cli.js",
port: 38429,
installed_at: "2026-05-29T00:00:00.000Z",
},
};
}
describe("planSelfUpdate", () => {
it("plans npm upgrade, service reinstall, then user-service restart", () => {
expect(planSelfUpdate(intent("npm"), { platform: "linux" })).toEqual([
{ command: "npm", args: ["install", "-g", "--prefix", "/usr", "kandev@1.2.3"] },
{
command: "/usr/bin/node",
args: [
"/usr/lib/node_modules/kandev/bin/cli.js",
"service",
"install",
"--home-dir",
"/home/alice/.kandev",
"--port",
"38429",
],
},
{ command: "systemctl", args: ["--user", "restart", "kandev"] },
]);
});
it("keeps npm updates inside the original global prefix", () => {
const npm = intent("npm");
npm.install.cli_entry = "/tmp/kandev-test/npm-global/lib/node_modules/kandev/bin/cli.js";
expect(planSelfUpdate(npm, { platform: "linux" })[0]).toEqual({
command: "npm",
args: ["install", "-g", "--prefix", "/tmp/kandev-test/npm-global", "kandev@1.2.3"],
});
});
it("falls back to npm's configured prefix when the cli path is non-standard", () => {
const npm = intent("npm");
npm.install.cli_entry = "/opt/kandev/bin/cli.js";
expect(planSelfUpdate(npm, { platform: "linux" })[0]).toEqual({
command: "npm",
args: ["install", "-g", "kandev@1.2.3"],
});
});
it("plans homebrew upgrade then reinstall via the kandev wrapper (not node <cli_entry>)", () => {
const brew = intent("homebrew");
// A real Homebrew install records a version-pinned Cellar cli_entry.
brew.install.cli_entry = "/opt/homebrew/Cellar/kandev/1.2.2/libexec/cli/bin/cli.js";
brew.install.node_path = "/opt/homebrew/bin/node";
const commands = planSelfUpdate(brew, { platform: "linux" });
expect(commands[0]).toEqual({ command: "brew", args: ["upgrade", "kandev"] });
// Step 2 must use the upgraded `kandev` wrapper (version-stable, re-sets
// KANDEV_BUNDLE_DIR), never `node` on the stale version-pinned cli_entry.
expect(commands[1]).toEqual({
command: "kandev",
args: ["service", "install", "--home-dir", "/home/alice/.kandev", "--port", "38429"],
});
expect(commands[1].command).not.toBe("/opt/homebrew/bin/node");
});
it("plans npx reinstall without mutating global npm packages", () => {
const commands = planSelfUpdate(intent("npx"), { platform: "linux" });
expect(commands[0]).toEqual({
command: "npx",
args: [
"-y",
"kandev@1.2.3",
"service",
"install",
"--home-dir",
"/home/alice/.kandev",
"--port",
"38429",
],
});
});
it("plans launchd restart on macOS", () => {
const mac = intent("homebrew");
mac.install.manager = "launchd";
mac.install.service_path = "/Users/alice/Library/LaunchAgents/com.kdlbs.kandev.plist";
mac.install.home_dir = "/Users/alice/.kandev";
mac.install.log_dir = "/Users/alice/.kandev/logs";
const commands = planSelfUpdate(mac, { platform: "darwin", uid: 501 });
expect(commands.at(-1)).toEqual({
command: "launchctl",
args: ["kickstart", "-k", "gui/501/com.kdlbs.kandev"],
});
});
});
describe("runSelfUpdateCommand", () => {
it("does not run commands in dry-run mode", () => {
const tmp = fs.mkdtempSync(path.join(process.cwd(), "self-update-test-"));
const intentPath = path.join(tmp, "intent.json");
fs.writeFileSync(intentPath, JSON.stringify(intent("npm")));
const runner = vi.fn();
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
runSelfUpdateCommand({ action: "self-update", intent: intentPath, dryRun: true }, runner);
expect(runner).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining('"dry_run": true'));
} finally {
log.mockRestore();
fs.rmSync(tmp, { recursive: true, force: true });
}
});
function readLog(logDir: string): string {
const file = fs.readdirSync(logDir).find((name) => name.startsWith("self-update-"));
expect(file, "a self-update log file should be written").toBeTruthy();
return fs.readFileSync(path.join(logDir, file as string), "utf8");
}
it("tees each command, its output, and exit status to a log file", () => {
const tmp = fs.mkdtempSync(path.join(process.cwd(), "self-update-test-"));
const logDir = path.join(tmp, "logs");
const it_ = intent("npm");
it_.install.log_dir = logDir;
const intentPath = path.join(tmp, "intent.json");
fs.writeFileSync(intentPath, JSON.stringify(it_));
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const runner = vi.fn().mockReturnValue({ status: 0, stdout: Buffer.from("ok output") });
try {
runSelfUpdateCommand({ action: "self-update", intent: intentPath }, runner);
const contents = readLog(logDir);
expect(contents).toContain("npm install -g");
expect(contents).toContain("service install");
expect(contents).toContain("ok output");
expect(contents).toContain("exit 0");
expect(contents).toContain("self-update completed successfully");
} finally {
stdout.mockRestore();
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("logs the failing command and its code before throwing", () => {
const tmp = fs.mkdtempSync(path.join(process.cwd(), "self-update-test-"));
const logDir = path.join(tmp, "logs");
const it_ = intent("npm");
it_.install.log_dir = logDir;
const intentPath = path.join(tmp, "intent.json");
fs.writeFileSync(intentPath, JSON.stringify(it_));
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const runner = vi.fn().mockReturnValue({ status: 7, stderr: Buffer.from("npm boom") });
try {
expect(() =>
runSelfUpdateCommand({ action: "self-update", intent: intentPath }, runner),
).toThrow(/failed with code 7/);
const contents = readLog(logDir);
expect(contents).toContain("npm boom");
expect(contents).toContain("exited with code 7");
expect(contents).not.toContain("self-update completed successfully");
} finally {
stdout.mockRestore();
stderr.mockRestore();
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
+281
View File
@@ -0,0 +1,281 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ServiceArgs } from "./args";
import type { ServiceInstallMetadata } from "./metadata";
import { LAUNCHD_LABEL, SERVICE_NAME } from "./paths";
export type SelfUpdateIntent = {
version: 1;
target_tag: string;
target_version: string;
latest_url?: string;
install: ServiceInstallMetadata;
created_at: string;
};
export type PlannedCommand = {
command: string;
args: string[];
};
export type PlanSelfUpdateOptions = {
platform?: NodeJS.Platform;
uid?: number;
};
export type CommandResult = Pick<SpawnSyncReturns<Buffer>, "status" | "error"> & {
stdout?: Buffer | string | null;
stderr?: Buffer | string | null;
};
export type CommandRunner = (command: string, args: string[]) => CommandResult;
export function readSelfUpdateIntent(intentPath: string): SelfUpdateIntent {
return JSON.parse(fs.readFileSync(intentPath, "utf8")) as SelfUpdateIntent;
}
export function planSelfUpdate(
intent: SelfUpdateIntent,
opts: PlanSelfUpdateOptions = {},
): PlannedCommand[] {
const platform = opts.platform ?? process.platform;
const target = npmVersion(intent.target_version || intent.target_tag);
const install = intent.install;
const installArgs = serviceInstallArgs(install);
const commands: PlannedCommand[] = [];
if (install.kind === "homebrew") {
// `brew upgrade` installs the tap formula's current version; it can't be
// pinned to intent.target_version the way npm/npx can (Homebrew has no
// stable "install version X" without a separate versioned formula). In
// practice both target_version and the formula derive from the same latest
// GitHub release, so they match. If the formula lags, the restarted backend
// reports the older version and the frontend progress poll (which waits for
// info.version === target_version) times out gracefully rather than
// reporting a false success.
commands.push({ command: "brew", args: ["upgrade", "kandev"] });
// Re-run service install via the upgraded `kandev` wrapper (resolved on
// PATH), NOT `node <cli_entry>`. Homebrew installs into version-pinned
// Cellar dirs, so the recorded cli_entry still points at the OLD bundle
// after `brew upgrade`; worse, invoking node on it directly runs without
// KANDEV_BUNDLE_DIR/KANDEV_VERSION, so the install is mis-detected as
// kind "unknown" and the regenerated unit loses the bundle env — the
// restarted backend then can't find its runtime and the service stays down.
// The wrapper is version-stable and re-sets the bundle env. (Mirrors the
// manual `kandev service install` we already document for Homebrew.)
commands.push({ command: "kandev", args: installArgs });
} else if (install.kind === "npm") {
commands.push({ command: "npm", args: npmInstallArgs(install.cli_entry, target) });
commands.push({ command: install.node_path, args: [install.cli_entry, ...installArgs] });
} else if (install.kind === "npx") {
commands.push({ command: "npx", args: ["-y", `kandev@${target}`, ...installArgs] });
} else {
throw new Error(`unsupported install kind "${install.kind}"`);
}
commands.push(restartCommand(install, platform, opts.uid));
return commands;
}
export function runSelfUpdateCommand(
args: ServiceArgs,
runner: CommandRunner = spawnCommand,
): void {
if (!args.intent) {
throw new Error("kandev service self-update requires --intent <path>");
}
const intent = readSelfUpdateIntent(args.intent);
const commands = planSelfUpdate(intent);
if (args.dryRun || process.env.KANDEV_E2E_MOCK === "true") {
console.log(
JSON.stringify(
{
dry_run: !!args.dryRun,
fake: process.env.KANDEV_E2E_MOCK === "true",
target_version: intent.target_version,
commands,
},
null,
2,
),
);
return;
}
const log = openSelfUpdateLog(intent);
try {
log?.line(`self-update target ${intent.target_tag} (${intent.target_version})`);
log?.line(`install kind=${intent.install.kind} manager=${intent.install.manager}`);
log?.line(`planned ${commands.length} command(s):`);
commands.forEach((step, i) =>
log?.line(` [${i + 1}/${commands.length}] ${formatCommand(step)}`),
);
runSelfUpdateSteps(commands, runner, log);
log?.line("self-update completed successfully");
} finally {
log?.close();
}
}
function runSelfUpdateSteps(
commands: PlannedCommand[],
runner: CommandRunner,
log: SelfUpdateLog | null,
): void {
for (const [i, step] of commands.entries()) {
log?.line(`\n[${i + 1}/${commands.length}] $ ${formatCommand(step)}`);
const res = runner(step.command, step.args);
teeCommandOutput(log, res);
if (res.error) {
log?.line(`! spawn error: ${res.error.message}`);
throw res.error;
}
if (res.status !== 0) {
log?.line(`! exited with code ${res.status}`);
throw new Error(`${formatCommand(step)} failed with code ${res.status}`);
}
log?.line(" exit 0");
}
}
function formatCommand(step: PlannedCommand): string {
return [step.command, ...step.args].join(" ");
}
function serviceInstallArgs(install: ServiceInstallMetadata): string[] {
const args = ["service", "install"];
if (install.mode === "system") args.push("--system");
args.push("--home-dir", install.home_dir);
if (install.port !== undefined) {
args.push("--port", String(install.port));
}
return args;
}
function npmInstallArgs(cliEntry: string, target: string): string[] {
const args = ["install", "-g"];
const prefix = npmPrefixFromCliEntry(cliEntry);
if (prefix) {
args.push("--prefix", prefix);
}
args.push(`kandev@${target}`);
return args;
}
function npmPrefixFromCliEntry(cliEntry: string): string | undefined {
const marker = `${path.sep}lib${path.sep}node_modules${path.sep}kandev${path.sep}`;
const index = cliEntry.indexOf(marker);
if (index < 0) {
return undefined;
}
return index === 0 ? path.sep : cliEntry.slice(0, index);
}
function restartCommand(
install: ServiceInstallMetadata,
platform: NodeJS.Platform,
uid?: number,
): PlannedCommand {
if (platform === "linux") {
return install.mode === "system"
? { command: "systemctl", args: ["restart", SERVICE_NAME] }
: { command: "systemctl", args: ["--user", "restart", SERVICE_NAME] };
}
if (platform === "darwin") {
// Resolve the uid lazily inside the darwin branch — Linux never needs it, so
// os.userInfo() shouldn't run there.
const resolvedUid = uid ?? os.userInfo().uid;
const domain = install.mode === "system" ? "system" : `gui/${resolvedUid}`;
return { command: "launchctl", args: ["kickstart", "-k", `${domain}/${LAUNCHD_LABEL}`] };
}
throw new Error(`unsupported platform "${platform}"`);
}
function npmVersion(versionOrTag: string): string {
const stripped = versionOrTag.replace(/^v/, "");
return stripped || "latest";
}
function spawnCommand(command: string, args: string[]): CommandResult {
// Capture stdout/stderr (rather than inheriting) so the self-update log can
// tee each step's output. The helper runs detached under launchd/systemd, so
// its console output would otherwise go nowhere visible — the log file is the
// only forensic trail when an update fails mid-flight. maxBuffer is bumped
// well above the 1 MiB default because `npm install -g` is chatty.
return spawnSync(command, args, { maxBuffer: 64 * 1024 * 1024 });
}
type SelfUpdateLog = {
path: string;
line: (message: string) => void;
raw: (chunk: Buffer | string) => void;
close: () => void;
};
/**
* Open a timestamped log file under the service's log dir for this self-update
* run. The helper is spawned outside the service process (so a restart doesn't
* kill it mid-update), which means it has no service log of its own — without
* this file a failed update on macOS/launchd is nearly invisible. Best-effort:
* if the log dir can't be created we return null and the update still runs.
*/
function openSelfUpdateLog(intent: SelfUpdateIntent): SelfUpdateLog | null {
const dir = intent.install.log_dir || logDirFromHome(intent.install.home_dir);
if (!dir) {
return null;
}
try {
fs.mkdirSync(dir, { recursive: true });
// Self-update logs can contain install paths/env; keep the dir owner-only
// even if it pre-existed with looser perms.
fs.chmodSync(dir, 0o700);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const filePath = path.join(dir, `self-update-${stamp}.log`);
const fd = fs.openSync(filePath, "a");
const write = (chunk: Buffer | string): void => {
try {
// The `Buffer | string` union matches neither writeSync overload
// directly; the cast picks one — both write the runtime value correctly.
fs.writeSync(fd, chunk as string);
} catch {
// A write failure mid-update must not abort the update itself.
}
};
process.stdout.write(`[kandev] self-update log: ${filePath}\n`);
write(`# kandev self-update ${new Date().toISOString()}\n`);
return {
path: filePath,
line: (message) => write(`${message}\n`),
raw: (chunk) => write(chunk),
close: () => {
try {
fs.closeSync(fd);
} catch {
// already closed / never opened
}
},
};
} catch {
return null;
}
}
function logDirFromHome(homeDir: string): string {
return homeDir ? path.join(homeDir, "logs") : "";
}
// Mirror a command's captured output to both the helper log and the process's
// own stdout/stderr, so whatever does manage to capture the helper's streams
// (e.g. launchd's StandardOut/ErrorPath, systemd's journal) still sees it.
function teeCommandOutput(log: SelfUpdateLog | null, res: CommandResult): void {
if (res.stdout && res.stdout.length) {
process.stdout.write(res.stdout);
log?.raw(res.stdout);
}
if (res.stderr && res.stderr.length) {
process.stderr.write(res.stderr);
log?.raw(res.stderr);
}
}
+37
View File
@@ -124,6 +124,23 @@ describe("renderSystemdUnit", () => {
expect(unit).toContain("Environment=KANDEV_SERVER_PORT=9000");
});
it("bakes service-state env when metadata path is set", () => {
const unit = renderSystemdUnit({
launcher: NPM_LAUNCHER,
homeDir: "/home/alice/.kandev",
logDir: "/home/alice/.kandev/logs",
mode: "user",
serviceMetadataPath: "/home/alice/.kandev/service/install.json",
} as Parameters<typeof renderSystemdUnit>[0]);
expect(unit).toContain("Environment=KANDEV_RUNNING_AS_SERVICE=true");
expect(unit).toContain("Environment=KANDEV_SERVICE_MODE=user");
expect(unit).toContain("Environment=KANDEV_SERVICE_MANAGER=systemd");
expect(unit).toContain("Environment=KANDEV_INSTALL_KIND=npm");
expect(unit).toContain(
"Environment=KANDEV_SERVICE_METADATA=/home/alice/.kandev/service/install.json",
);
});
it("prepends launcher node bin dir to PATH so npx-based agents resolve under fnm/nvm/asdf/volta/mise", () => {
const unit = renderSystemdUnit({
launcher: FNM_LAUNCHER,
@@ -409,6 +426,26 @@ describe("renderLaunchdPlist", () => {
expect(plist).toContain("KANDEV_VERSION");
});
it("bakes service-state env when metadata path is set", () => {
const plist = renderLaunchdPlist({
launcher: NPM_LAUNCHER,
homeDir: "/Users/alice/.kandev",
logDir: "/Users/alice/.kandev/logs",
mode: "user",
serviceMetadataPath: "/Users/alice/.kandev/service/install.json",
} as Parameters<typeof renderLaunchdPlist>[0]);
expect(plist).toContain("<key>KANDEV_RUNNING_AS_SERVICE</key>");
expect(plist).toContain("<string>true</string>");
expect(plist).toContain("<key>KANDEV_SERVICE_MODE</key>");
expect(plist).toContain("<string>user</string>");
expect(plist).toContain("<key>KANDEV_SERVICE_MANAGER</key>");
expect(plist).toContain("<string>launchd</string>");
expect(plist).toContain("<key>KANDEV_INSTALL_KIND</key>");
expect(plist).toContain("<string>npm</string>");
expect(plist).toContain("<key>KANDEV_SERVICE_METADATA</key>");
expect(plist).toContain("<string>/Users/alice/.kandev/service/install.json</string>");
});
it("quotes Environment= lines when value contains a space (greptile P1 regression)", () => {
const unit = renderSystemdUnit({
launcher: NPM_LAUNCHER,
+26
View File
@@ -17,6 +17,8 @@ export type UnitInputs = {
systemUser?: string;
/** Mode controls WantedBy and User= directives. */
mode: "user" | "system";
/** Absolute path to service install metadata, baked into managed units. */
serviceMetadataPath?: string;
};
// User-mode PATH includes ~/.local/bin and ~/.bun/bin so user-installed agent
@@ -96,6 +98,9 @@ export function renderSystemdUnit(input: UnitInputs): string {
if (!shimPath && input.launcher.version) {
env.push(envLine("KANDEV_VERSION", input.launcher.version));
}
if (input.serviceMetadataPath) {
env.push(...serviceEnvLines(input, "systemd"));
}
const wantedBy = input.mode === "system" ? "multi-user.target" : "default.target";
const userLine = input.mode === "system" && input.systemUser ? `User=${input.systemUser}\n` : "";
@@ -151,6 +156,9 @@ export function renderLaunchdPlist(input: UnitInputs): string {
if (!shimPath && input.launcher.version) {
envEntries.push(["KANDEV_VERSION", input.launcher.version]);
}
if (input.serviceMetadataPath) {
envEntries.push(...serviceEnvEntries(input, "launchd"));
}
const envXml = envEntries
.map(([k, v]) => ` <key>${escapeXml(k)}</key>\n <string>${escapeXml(v)}</string>`)
@@ -229,3 +237,21 @@ function envLine(key: string, value: string): string {
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `Environment="${key}=${escaped}"`;
}
function serviceEnvLines(input: UnitInputs, manager: "systemd" | "launchd"): string[] {
return serviceEnvEntries(input, manager).map(([key, value]) => envLine(key, value));
}
function serviceEnvEntries(
input: UnitInputs,
manager: "systemd" | "launchd",
): Array<[string, string]> {
if (!input.serviceMetadataPath) return [];
return [
["KANDEV_RUNNING_AS_SERVICE", "true"],
["KANDEV_SERVICE_MODE", input.mode],
["KANDEV_SERVICE_MANAGER", manager],
["KANDEV_INSTALL_KIND", input.launcher.kind],
["KANDEV_SERVICE_METADATA", input.serviceMetadataPath],
];
}
+6 -2
View File
@@ -15,7 +15,7 @@ import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { DATA_DIR, HEALTH_TIMEOUT_MS_RELEASE } from "./constants";
import { HEALTH_TIMEOUT_MS_RELEASE, resolveDataDir, resolveDatabasePath } from "./constants";
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health";
import { getBinaryName } from "./platform";
import { createProcessSupervisor } from "./process";
@@ -154,7 +154,11 @@ export async function runStart({
// Production mode: use warn log level for clean output unless verbose/debug
const showOutput = verbose || debug;
const dbPath = process.env.KANDEV_DATABASE_PATH || path.join(DATA_DIR, "kandev.db");
fs.mkdirSync(resolveDataDir(), { recursive: true });
// The data dir holds the SQLite DB; keep it owner-only even if it pre-existed
// with a looser umask-derived mode.
fs.chmodSync(resolveDataDir(), 0o700);
const dbPath = resolveDatabasePath();
const logLevel =
process.env.KANDEV_LOG_LEVEL?.trim() || (debug ? "debug" : verbose ? "info" : "warn");
const backendEnv = buildBackendEnv({
@@ -0,0 +1,69 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SystemJob } from "@/lib/types/system";
const mocks = vi.hoisted(() => ({
useSystemJob: vi.fn(),
useSystemJobs: vi.fn(),
}));
vi.mock("@/hooks/domains/system/use-system-jobs", () => ({
useSystemJob: mocks.useSystemJob,
useSystemJobs: mocks.useSystemJobs,
}));
import { JobProgressIndicator } from "./job-progress-indicator";
function job(overrides: Partial<SystemJob> = {}): SystemJob {
return {
id: "job-1",
kind: "self-update",
state: "succeeded",
started_at: "2026-05-30T00:00:00.000Z",
...overrides,
};
}
describe("JobProgressIndicator", () => {
beforeEach(() => {
mocks.useSystemJob.mockReset();
mocks.useSystemJobs.mockReset();
});
afterEach(() => {
cleanup();
});
it("renders a pinned job fetched by id when the websocket list missed it", () => {
mocks.useSystemJob.mockReturnValue(job({ id: "job-42" }));
mocks.useSystemJobs.mockReturnValue([]);
render(<JobProgressIndicator kind="self-update" jobId="job-42" />);
const indicator = screen.getByTestId("system-job-self-update");
expect(indicator.getAttribute("data-state")).toBe("succeeded");
expect(indicator.textContent).toContain("Done");
});
it("falls back to the latest kind-matched job when no id is pinned", () => {
mocks.useSystemJob.mockReturnValue(undefined);
mocks.useSystemJobs.mockReturnValue([job({ id: "job-2", state: "failed" })]);
render(<JobProgressIndicator kind="self-update" />);
expect(screen.getByTestId("system-job-self-update").getAttribute("data-state")).toBe("failed");
});
it("allows a flow-specific success label", () => {
mocks.useSystemJob.mockReturnValue(job({ id: "job-42", state: "succeeded" }));
mocks.useSystemJobs.mockReturnValue([]);
render(
<JobProgressIndicator kind="self-update" jobId="job-42" successLabel="Restarting service" />,
);
expect(screen.getByText("Restarting service")).not.toBeNull();
expect(screen.queryByText("Done")).toBeNull();
});
});
@@ -3,13 +3,15 @@
import { Badge } from "@kandev/ui/badge";
import { Spinner } from "@kandev/ui/spinner";
import { IconCheck, IconAlertTriangle } from "@tabler/icons-react";
import { useSystemJobs } from "@/hooks/domains/system/use-system-jobs";
import { useSystemJob, useSystemJobs } from "@/hooks/domains/system/use-system-jobs";
import type { SystemJob, SystemJobKind } from "@/lib/types/system";
type JobProgressIndicatorProps = {
kind: SystemJobKind;
/** When provided, only the matching job id is rendered (ignores all others). */
jobId?: string;
/** Label shown when the job has succeeded. */
successLabel?: string;
/** Testid suffix; default "system-job-<kind>". */
testId?: string;
};
@@ -47,9 +49,15 @@ function stateLabel(state: SystemJob["state"]): string {
}
}
export function JobProgressIndicator({ kind, jobId, testId }: JobProgressIndicatorProps) {
export function JobProgressIndicator({
kind,
jobId,
successLabel,
testId,
}: JobProgressIndicatorProps) {
const pinnedJob = useSystemJob(jobId);
const jobs = useSystemJobs(kind);
const job = pickJob(jobs, jobId);
const job = jobId ? (pinnedJob ?? pickJob(jobs, jobId)) : pickJob(jobs);
if (!job) return null;
const tid = testId ?? `system-job-${kind}`;
@@ -67,7 +75,7 @@ export function JobProgressIndicator({ kind, jobId, testId }: JobProgressIndicat
{isSuccess && <IconCheck className="size-3.5 text-emerald-500" />}
{isFailed && <IconAlertTriangle className="size-3.5 text-red-500" />}
<Badge variant={badgeVariant(job.state)} className="text-[10px]">
{stateLabel(job.state)}
{isSuccess && successLabel ? successLabel : stateLabel(job.state)}
</Badge>
{job.message && <span className="truncate max-w-[24rem]">{job.message}</span>}
</div>
@@ -0,0 +1,106 @@
"use client";
import { Button } from "@kandev/ui/button";
import { Spinner } from "@kandev/ui/spinner";
import { IconCheck, IconAlertTriangle } from "@tabler/icons-react";
import type { SelfUpdatePhase } from "@/hooks/domains/system/use-self-update";
type SelfUpdateProgressProps = {
phase: SelfUpdatePhase;
targetVersion: string | null;
errorMessage: string | null;
onDismiss: () => void;
};
function activeText(phase: SelfUpdatePhase, target: string | null): string {
const version = target ?? "the new version";
switch (phase) {
case "starting":
return `Starting update to ${version}…`;
case "installing":
return `Downloading and installing ${version}…`;
case "restarting":
return "Restarting Kandev — this can take up to a minute.";
default:
return "";
}
}
function ActiveRow({
phase,
targetVersion,
}: {
phase: SelfUpdatePhase;
targetVersion: string | null;
}) {
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm">
<Spinner className="size-4" />
<span>{activeText(phase, targetVersion)}</span>
</div>
<p className="text-xs text-muted-foreground">
Keep this page open. It will refresh automatically when the update finishes.
</p>
</div>
);
}
function DoneRow({ targetVersion }: { targetVersion: string | null }) {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm">
<IconCheck className="size-4 text-emerald-500" />
<span>Updated to {targetVersion ?? "the latest version"}.</span>
</div>
<Button
variant="outline"
size="sm"
className="cursor-pointer self-start sm:self-auto"
onClick={() => window.location.reload()}
data-testid="system-updates-progress-reload"
>
Reload page
</Button>
</div>
);
}
function ErrorRow({ message, onDismiss }: { message: string | null; onDismiss: () => void }) {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-2 text-sm text-destructive">
<IconAlertTriangle className="size-4 shrink-0" />
<span>{message ?? "The update failed."}</span>
</div>
<Button
variant="outline"
size="sm"
className="cursor-pointer self-start sm:self-auto"
onClick={onDismiss}
data-testid="system-updates-progress-dismiss"
>
Dismiss
</Button>
</div>
);
}
function ProgressBody({ phase, targetVersion, errorMessage, onDismiss }: SelfUpdateProgressProps) {
if (phase === "done") return <DoneRow targetVersion={targetVersion} />;
if (phase === "error") return <ErrorRow message={errorMessage} onDismiss={onDismiss} />;
return <ActiveRow phase={phase} targetVersion={targetVersion} />;
}
export function SelfUpdateProgress(props: SelfUpdateProgressProps) {
if (props.phase === "idle") return null;
return (
<div
className="rounded-md border bg-muted/30 px-3 py-2"
data-testid="system-updates-progress"
data-phase={props.phase}
>
<ProgressBody {...props} />
</div>
);
}
@@ -0,0 +1,131 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdatesResponse } from "@/lib/types/system";
import type { SelfUpdateController } from "@/hooks/domains/system/use-self-update";
const mocks = vi.hoisted(() => ({
useUpdates: vi.fn(),
useSelfUpdate: vi.fn(),
}));
vi.mock("@/hooks/domains/system/use-updates", () => ({
useUpdates: mocks.useUpdates,
}));
vi.mock("@/hooks/domains/system/use-self-update", () => ({
useSelfUpdate: mocks.useSelfUpdate,
}));
// The @kandev/ui Spinner source trips the classic JSX runtime under vitest;
// stub it so the card (and progress block) can render in jsdom.
vi.mock("@kandev/ui/spinner", () => ({
Spinner: () => null,
}));
import { UpdatesCard } from "./updates-card";
const APPLY_TESTID = "system-updates-apply";
function updates(overrides: Partial<UpdatesResponse> = {}): UpdatesResponse {
return {
current: "v1.0.0",
latest: "v1.0.1",
latest_url: "https://example/v1.0.1",
latest_checked_at: "2026-05-29T00:00:00.000Z",
update_available: true,
install: {
running_as_service: true,
managed_service: true,
mode: "user",
manager: "systemd",
kind: "npm",
},
apply_supported: true,
...overrides,
};
}
function selfUpdate(overrides: Partial<SelfUpdateController> = {}): SelfUpdateController {
return {
phase: "idle",
targetVersion: null,
errorMessage: null,
isUpdating: false,
start: vi.fn().mockResolvedValue(undefined),
dismiss: vi.fn(),
...overrides,
};
}
describe("UpdatesCard self-update", () => {
beforeEach(() => {
mocks.useUpdates.mockReset();
mocks.useSelfUpdate.mockReset();
mocks.useSelfUpdate.mockReturnValue(selfUpdate());
});
afterEach(() => {
cleanup();
});
it("does not render Apply update when the install is not a managed service", () => {
mocks.useUpdates.mockReturnValue({
updates: updates({
install: { running_as_service: false, managed_service: false },
apply_supported: false,
apply_unsupported_reason: "Kandev is not running as a managed service.",
manual_commands: ["kandev service install"],
}),
check: vi.fn(),
reload: vi.fn(),
});
render(<UpdatesCard />);
expect(screen.queryByTestId(APPLY_TESTID)).toBeNull();
expect(screen.getByTestId("system-updates-manual").textContent).toContain(
"Kandev is not running as a managed service.",
);
});
it("starts the self-update only after confirmation", async () => {
const start = vi.fn().mockResolvedValue(undefined);
mocks.useUpdates.mockReturnValue({ updates: updates(), check: vi.fn(), reload: vi.fn() });
mocks.useSelfUpdate.mockReturnValue(selfUpdate({ start }));
render(<UpdatesCard />);
fireEvent.click(screen.getByTestId(APPLY_TESTID));
fireEvent.click(await screen.findByTestId("system-updates-apply-confirm"));
await waitFor(() => expect(start).toHaveBeenCalledTimes(1));
});
it("hides the Apply button and shows progress while updating", () => {
mocks.useUpdates.mockReturnValue({ updates: updates(), check: vi.fn(), reload: vi.fn() });
mocks.useSelfUpdate.mockReturnValue(
selfUpdate({ phase: "restarting", targetVersion: "v1.0.1", isUpdating: true }),
);
render(<UpdatesCard />);
expect(screen.queryByTestId(APPLY_TESTID)).toBeNull();
const progress = screen.getByTestId("system-updates-progress");
expect(progress.getAttribute("data-phase")).toBe("restarting");
expect(progress.textContent).toContain("Restarting Kandev");
});
it("shows the updated confirmation when done", () => {
mocks.useUpdates.mockReturnValue({ updates: updates(), check: vi.fn(), reload: vi.fn() });
mocks.useSelfUpdate.mockReturnValue(
selfUpdate({ phase: "done", targetVersion: "v1.0.1", isUpdating: false }),
);
render(<UpdatesCard />);
expect(screen.queryByTestId(APPLY_TESTID)).toBeNull();
expect(screen.getByTestId("system-updates-progress").textContent).toContain(
"Updated to v1.0.1",
);
});
});
@@ -1,12 +1,32 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Button } from "@kandev/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@kandev/ui/alert-dialog";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Spinner } from "@kandev/ui/spinner";
import { IconExternalLink, IconRefresh } from "@tabler/icons-react";
import { IconDownload, IconExternalLink, IconRefresh } from "@tabler/icons-react";
import { useSelfUpdate } from "@/hooks/domains/system/use-self-update";
import { useUpdates } from "@/hooks/domains/system/use-updates";
import type { UpdatesResponse } from "@/lib/types/system";
import { SelfUpdateProgress } from "./self-update-progress";
interface ApplyGate {
canApply: boolean;
cannotApplyReason?: string;
manualCommands: string[];
}
function formatChecked(iso: string | undefined): string {
if (!iso) return "never";
@@ -15,8 +35,33 @@ function formatChecked(iso: string | undefined): string {
return d.toLocaleString();
}
function errorMessage(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback;
}
function retryAfterSeconds(message: string): number | null {
const match = /retry.*?(\d+)/i.exec(message);
return match ? Number(match[1]) : null;
}
function getApplyGate(updates: UpdatesResponse | null | undefined): ApplyGate {
const install = updates?.install;
const available = updates?.update_available === true;
const canApply =
available &&
install?.running_as_service === true &&
install.managed_service === true &&
updates?.apply_supported === true;
return {
canApply,
cannotApplyReason: updates?.apply_unsupported_reason,
manualCommands: updates?.manual_commands ?? [],
};
}
export function UpdatesCard() {
const { updates, check } = useUpdates();
const { updates, check, reload } = useUpdates();
const selfUpdate = useSelfUpdate({ latestVersion: updates?.latest, onComplete: reload });
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [retryAfter, setRetryAfter] = useState<number | null>(null);
@@ -28,12 +73,9 @@ export function UpdatesCard() {
try {
await check();
} catch (err) {
const message = err instanceof Error ? err.message : "Update check failed";
const message = errorMessage(err, "Update check failed");
setError(message);
const match = /retry.*?(\d+)/i.exec(message);
if (match) {
setRetryAfter(Number(match[1]));
}
setRetryAfter(retryAfterSeconds(message));
} finally {
setChecking(false);
}
@@ -41,81 +83,228 @@ export function UpdatesCard() {
const current = updates?.current ?? "-";
const latest = updates?.latest ?? "-";
const available = updates?.update_available ?? false;
const checkedAt = updates?.latest_checked_at;
const url = updates?.latest_url;
const available = updates?.update_available === true;
const gate = getApplyGate(updates);
// Hide the Apply button while an update is in flight (and once it's done —
// the version has flipped) so it can't be re-triggered mid-restart.
const showApply = gate.canApply && !selfUpdate.isUpdating && selfUpdate.phase !== "done";
return (
<Card data-testid="system-updates-card">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<IconRefresh className="h-4 w-4" />
Updates
{available && (
<Badge variant="default" className="text-[10px]" data-testid="system-updates-badge">
Update available
</Badge>
)}
</CardTitle>
<UpdatesHeader available={available} />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-xs text-muted-foreground">Current version</div>
<div className="font-mono text-sm" data-testid="system-updates-current">
{current}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Latest release</div>
<div className="font-mono text-sm" data-testid="system-updates-latest">
{latest}
</div>
</div>
</div>
<div className="text-xs text-muted-foreground" data-testid="system-updates-checked-at">
Last checked {formatChecked(checkedAt)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={checking}
onClick={() => void onCheck()}
className="cursor-pointer"
data-testid="system-updates-check"
>
{checking ? (
<Spinner className="size-3.5 mr-1" />
) : (
<IconRefresh className="h-3.5 w-3.5 mr-1" />
)}
Check now
</Button>
{url && (
<Button
asChild
variant="ghost"
size="sm"
className="cursor-pointer"
data-testid="system-updates-release-link"
>
<a href={url} target="_blank" rel="noreferrer">
Release notes
<IconExternalLink className="h-3.5 w-3.5 ml-1" />
</a>
</Button>
)}
</div>
{error && (
<p className="text-xs text-destructive" data-testid="system-updates-error">
{retryAfter ? `Already checked. Try again in ${retryAfter}s.` : error}
</p>
)}
<VersionGrid current={current} latest={latest} />
<LastChecked checkedAt={updates?.latest_checked_at} />
<UpdateActions
checking={checking}
showApply={showApply}
latest={latest}
url={updates?.latest_url}
onCheck={onCheck}
onApply={selfUpdate.start}
/>
<ManualUpdateInstructions
show={available && !gate.canApply && !selfUpdate.isUpdating}
reason={gate.cannotApplyReason}
commands={gate.manualCommands}
/>
<SelfUpdateProgress
phase={selfUpdate.phase}
targetVersion={selfUpdate.targetVersion}
errorMessage={selfUpdate.errorMessage}
onDismiss={selfUpdate.dismiss}
/>
<UpdateError error={error} retryAfter={retryAfter} />
</CardContent>
</Card>
);
}
function UpdatesHeader({ available }: { available: boolean }) {
return (
<CardTitle className="text-base flex items-center gap-2">
<IconRefresh className="h-4 w-4" />
Updates
{available && (
<Badge variant="default" className="text-[10px]" data-testid="system-updates-badge">
Update available
</Badge>
)}
</CardTitle>
);
}
function VersionGrid({ current, latest }: { current: string; latest: string }) {
return (
<div className="grid grid-cols-2 gap-3 text-sm">
<VersionValue label="Current version" value={current} testId="system-updates-current" />
<VersionValue label="Latest release" value={latest} testId="system-updates-latest" />
</div>
);
}
function VersionValue({ label, value, testId }: { label: string; value: string; testId: string }) {
return (
<div>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="font-mono text-sm" data-testid={testId}>
{value}
</div>
</div>
);
}
function LastChecked({ checkedAt }: { checkedAt?: string }) {
return (
<div className="text-xs text-muted-foreground" data-testid="system-updates-checked-at">
Last checked {formatChecked(checkedAt)}
</div>
);
}
interface UpdateActionsProps {
checking: boolean;
showApply: boolean;
latest: string;
url?: string;
onCheck: () => Promise<void>;
onApply: () => Promise<void>;
}
function UpdateActions(props: UpdateActionsProps) {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<CheckNowButton checking={props.checking} onCheck={props.onCheck} />
<ReleaseNotesLink url={props.url} />
<ApplyUpdateDialog
showApply={props.showApply}
latest={props.latest}
onApply={props.onApply}
/>
</div>
);
}
function CheckNowButton({
checking,
onCheck,
}: {
checking: boolean;
onCheck: () => Promise<void>;
}) {
return (
<Button
variant="outline"
size="sm"
disabled={checking}
onClick={() => void onCheck()}
className="cursor-pointer"
data-testid="system-updates-check"
>
{checking ? (
<Spinner className="size-3.5 mr-1" />
) : (
<IconRefresh className="h-3.5 w-3.5 mr-1" />
)}
Check now
</Button>
);
}
function ReleaseNotesLink({ url }: { url?: string }) {
if (!url) return null;
return (
<Button
asChild
variant="ghost"
size="sm"
className="cursor-pointer"
data-testid="system-updates-release-link"
>
<a href={url} target="_blank" rel="noreferrer">
Release notes
<IconExternalLink className="h-3.5 w-3.5 ml-1" />
</a>
</Button>
);
}
interface ApplyUpdateDialogProps {
showApply: boolean;
latest: string;
onApply: () => Promise<void>;
}
function ApplyUpdateDialog({ showApply, latest, onApply }: ApplyUpdateDialogProps) {
if (!showApply) return null;
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" className="cursor-pointer" data-testid="system-updates-apply">
<IconDownload className="h-3.5 w-3.5 mr-1" />
Apply update
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Apply update?</AlertDialogTitle>
<AlertDialogDescription className="text-left">
Kandev will update to {latest}, reinstall the user service, and restart it.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer"
onClick={() => void onApply()}
data-testid="system-updates-apply-confirm"
>
Apply update
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
function ManualUpdateInstructions({
show,
reason,
commands,
}: {
show: boolean;
reason?: string;
commands: string[];
}) {
if (!show || !reason) return null;
return (
<div className="space-y-2 text-xs text-muted-foreground" data-testid="system-updates-manual">
<p>{reason}</p>
<ManualCommands commands={commands} />
</div>
);
}
function ManualCommands({ commands }: { commands: string[] }) {
if (commands.length === 0) return null;
return (
<div className="space-y-1">
{commands.map((cmd) => (
<code key={cmd} className="block break-all rounded bg-muted px-2 py-1 font-mono">
{cmd}
</code>
))}
</div>
);
}
function UpdateError({ error, retryAfter }: { error: string | null; retryAfter: number | null }) {
if (!error) return null;
return (
<p className="text-xs text-destructive" data-testid="system-updates-error">
{retryAfter ? `Already checked. Try again in ${retryAfter}s.` : error}
</p>
);
}
@@ -35,6 +35,153 @@ test.describe("System Updates page", () => {
await expect(testPage.getByTestId("system-updates-current")).toHaveText("v1.0.0");
await expect(testPage.getByTestId("system-updates-latest")).toHaveText("v1.0.1");
await expect(testPage.getByTestId("system-updates-apply")).toHaveCount(0);
});
test("Apply update is available only for managed service installs", async ({ testPage }) => {
test.setTimeout(30_000);
await testPage.route("**/api/v1/system/updates/check", (route) => {
void route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
current: "v1.0.0",
latest: "v1.0.1",
latest_url: "https://example.com/r/v1.0.1",
latest_checked_at: new Date().toISOString(),
update_available: true,
install: {
running_as_service: true,
managed_service: true,
mode: "user",
manager: "systemd",
kind: "npm",
},
apply_supported: true,
}),
});
});
let applyCalled = false;
await testPage.route("**/api/v1/system/updates/apply", (route) => {
applyCalled = true;
void route.fulfill({
status: 202,
contentType: "application/json",
body: JSON.stringify({ job_id: "self-update-1" }),
});
});
await testPage.goto("/settings/system/updates");
await testPage.getByTestId("system-updates-check").click();
await expect(testPage.getByTestId("system-updates-apply")).toBeVisible({ timeout: 10_000 });
await testPage.getByTestId("system-updates-apply").click();
await testPage.getByTestId("system-updates-apply-confirm").click();
await expect.poll(() => applyCalled).toBe(true);
});
test("apply locks the button and shows progress until the version flips", async ({
testPage,
}) => {
test.setTimeout(30_000);
await testPage.route("**/api/v1/system/updates/check", (route) => {
void route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
current: "v1.0.0",
latest: "v1.0.1",
latest_url: "https://example.com/r/v1.0.1",
latest_checked_at: new Date().toISOString(),
update_available: true,
install: {
running_as_service: true,
managed_service: true,
mode: "user",
manager: "launchd",
kind: "npm",
},
apply_supported: true,
}),
});
});
await testPage.route("**/api/v1/system/updates/apply", (route) => {
void route.fulfill({
status: 202,
contentType: "application/json",
body: JSON.stringify({ job_id: "self-update-1" }),
});
});
await testPage.route("**/api/v1/system/jobs/**", (route) => {
void route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "self-update-1",
kind: "self-update",
state: "succeeded",
started_at: new Date().toISOString(),
}),
});
});
// The version poll is the "did it land" signal: report the target version so
// the flow reaches "done".
await testPage.route("**/api/v1/system/info", (route) => {
void route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
version: "v1.0.1",
commit: "abc",
build_time: new Date().toISOString(),
go_version: "go1.26",
os: "darwin",
arch: "arm64",
}),
});
});
await testPage.goto("/settings/system/updates");
await testPage.getByTestId("system-updates-check").click();
await testPage.getByTestId("system-updates-apply").click();
await testPage.getByTestId("system-updates-apply-confirm").click();
const progress = testPage.getByTestId("system-updates-progress");
await expect(progress).toBeVisible({ timeout: 10_000 });
// The button must not be re-triggerable while/after the update runs.
await expect(testPage.getByTestId("system-updates-apply")).toHaveCount(0);
await expect(progress).toHaveAttribute("data-phase", "done", { timeout: 10_000 });
await expect(progress).toContainText("Updated to v1.0.1");
});
test("mobile keeps Apply update hidden for non-service installs", async ({ testPage }) => {
await testPage.setViewportSize({ width: 390, height: 844 });
await testPage.route("**/api/v1/system/updates/check", (route) => {
void route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
current: "v1.0.0",
latest: "v1.0.1",
latest_url: "https://example.com/r/v1.0.1",
latest_checked_at: new Date().toISOString(),
update_available: true,
install: { running_as_service: false, managed_service: false },
apply_supported: false,
apply_unsupported_reason: "Kandev is not running as a managed service.",
manual_commands: ["kandev service install"],
}),
});
});
await testPage.goto("/settings/system/updates");
await testPage.getByTestId("system-updates-check").click();
await expect(testPage.getByTestId("system-updates-apply")).toHaveCount(0);
await expect(testPage.getByTestId("system-updates-manual")).toBeVisible();
});
test("changelog pagination is URL-driven via ?page=N", async ({ testPage }) => {
@@ -0,0 +1,124 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
applyUpdate: vi.fn(),
fetchSystemInfo: vi.fn(),
fetchSystemJob: vi.fn(),
}));
vi.mock("@/lib/api/domains/system-api", () => ({
applyUpdate: mocks.applyUpdate,
fetchSystemInfo: mocks.fetchSystemInfo,
fetchSystemJob: mocks.fetchSystemJob,
}));
import { useSelfUpdate } from "./use-self-update";
const STORAGE_KEY = "kandev.selfUpdate";
beforeEach(() => {
mocks.applyUpdate.mockReset();
mocks.fetchSystemInfo.mockReset();
mocks.fetchSystemJob.mockReset();
mocks.fetchSystemJob.mockResolvedValue({ state: "succeeded" });
localStorage.clear();
});
afterEach(() => {
vi.useRealTimers();
});
describe("useSelfUpdate", () => {
it("locks and persists the target while installing", async () => {
mocks.applyUpdate.mockResolvedValue({ job_id: "j1" });
mocks.fetchSystemInfo.mockResolvedValue({ version: "v1.0.0" }); // not flipped yet
const { result } = renderHook(() => useSelfUpdate({ latestVersion: "v1.0.1" }));
await act(async () => {
await result.current.start();
});
expect(mocks.applyUpdate).toHaveBeenCalledWith("UPDATE");
expect(result.current.phase).toBe("installing");
expect(result.current.isUpdating).toBe(true);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) as string).target).toBe("v1.0.1");
});
it("finishes when the version flips to the target", async () => {
mocks.applyUpdate.mockResolvedValue({ job_id: "j1" });
mocks.fetchSystemInfo.mockResolvedValue({ version: "v1.0.1" });
const onComplete = vi.fn();
const { result } = renderHook(() => useSelfUpdate({ latestVersion: "v1.0.1", onComplete }));
await act(async () => {
await result.current.start();
});
await waitFor(() => expect(result.current.phase).toBe("done"));
expect(result.current.isUpdating).toBe(false);
expect(onComplete).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it("reports 'restarting' while the backend is unreachable", async () => {
mocks.applyUpdate.mockResolvedValue({ job_id: "j1" });
mocks.fetchSystemInfo.mockRejectedValue(new Error("connection refused"));
const { result } = renderHook(() => useSelfUpdate({ latestVersion: "v1.0.1" }));
await act(async () => {
await result.current.start();
});
await waitFor(() => expect(result.current.phase).toBe("restarting"));
expect(result.current.isUpdating).toBe(true);
});
it("surfaces an error when apply fails to start", async () => {
mocks.applyUpdate.mockRejectedValue(new Error("rate limited"));
const { result } = renderHook(() => useSelfUpdate({ latestVersion: "v1.0.1" }));
await act(async () => {
await result.current.start();
});
expect(result.current.phase).toBe("error");
expect(result.current.errorMessage).toBe("rate limited");
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it("errors when the launch job reports failure", async () => {
mocks.applyUpdate.mockResolvedValue({ job_id: "j1" });
mocks.fetchSystemJob.mockResolvedValue({ state: "failed", message: "bootstrap failed" });
mocks.fetchSystemInfo.mockResolvedValue({ version: "v1.0.0" }); // never flips
const { result } = renderHook(() => useSelfUpdate({ latestVersion: "v1.0.1" }));
await act(async () => {
await result.current.start();
});
await waitFor(() => expect(result.current.phase).toBe("error"));
});
it("resumes an in-progress update persisted before a page reload", async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ target: "v1.0.1", startedAt: Date.now() }));
mocks.fetchSystemInfo.mockResolvedValue({ version: "v1.0.1" });
const { result } = renderHook(() => useSelfUpdate({ latestVersion: "v1.0.1" }));
expect(result.current.isUpdating).toBe(true);
await waitFor(() => expect(result.current.phase).toBe("done"));
});
it("ignores a stale persisted update past the safety window", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ target: "v1.0.1", startedAt: Date.now() - 10 * 60 * 1000 }),
);
const { result } = renderHook(() => useSelfUpdate({ latestVersion: "v1.0.1" }));
expect(result.current.phase).toBe("idle");
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
});
@@ -0,0 +1,206 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { applyUpdate, fetchSystemInfo, fetchSystemJob } from "@/lib/api/domains/system-api";
/**
* Lifecycle of an in-UI self-update, from the user's perspective:
*
* idle → starting → installing → restarting → done
* └──────────────────→ error
*
* The backend's apply *job* finishes in ~1s — it only means "the out-of-process
* helper was launched". The real work (npm/brew upgrade, service reinstall,
* service restart) happens in that detached helper, and the backend serving
* this page is itself restarted partway through. So the only trustworthy signal
* that the update actually landed is `/system/info` reporting the target
* version. This hook drives the whole flow off that: poll the version, treat an
* unreachable backend as "restarting" (expected), and only call it done once the
* version flips. State is mirrored to localStorage so a page reload during the
* restart window resumes the progress view instead of re-offering the button.
*/
export type SelfUpdatePhase = "idle" | "starting" | "installing" | "restarting" | "done" | "error";
const STORAGE_KEY = "kandev.selfUpdate";
const POLL_INTERVAL_MS = 2000;
// Safety net: if the version never flips (helper died after launch, network
// wedged), stop polling and surface an error instead of spinning forever.
const MAX_DURATION_MS = 5 * 60 * 1000;
type PersistedUpdate = { target: string; startedAt: number };
function readPersisted(): PersistedUpdate | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as PersistedUpdate;
if (typeof parsed.target !== "string" || typeof parsed.startedAt !== "number") return null;
return parsed;
} catch {
return null;
}
}
function writePersisted(value: PersistedUpdate): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch {
// localStorage may be unavailable (private mode/quota); progress still
// works in-memory, only the reload-resume affordance is lost.
}
}
function clearPersisted(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
}
type UseSelfUpdateArgs = {
latestVersion: string | undefined;
/** Called once the version flip is confirmed, to refresh the updates view. */
onComplete?: () => void;
};
export type SelfUpdateController = {
phase: SelfUpdatePhase;
targetVersion: string | null;
errorMessage: string | null;
/** True while an update is in flight (button should be hidden/disabled). */
isUpdating: boolean;
start: () => Promise<void>;
dismiss: () => void;
};
export function useSelfUpdate({
latestVersion,
onComplete,
}: UseSelfUpdateArgs): SelfUpdateController {
const [phase, setPhase] = useState<SelfUpdatePhase>("idle");
// Lazy-init from localStorage (SSR-safe — null on the server). It isn't
// rendered until `phase` leaves "idle", so the value differing between server
// and client can't cause a hydration mismatch.
const [targetVersion, setTargetVersion] = useState<string | null>(
() => readPersisted()?.target ?? null,
);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [jobId, setJobId] = useState<string | null>(null);
const hydrated = useRef(false);
const fail = useCallback((message: string) => {
setErrorMessage(message);
setPhase("error");
clearPersisted();
}, []);
const start = useCallback(async () => {
if (!latestVersion) return;
setErrorMessage(null);
setTargetVersion(latestVersion);
setPhase("starting");
writePersisted({ target: latestVersion, startedAt: Date.now() });
try {
const res = await applyUpdate("UPDATE");
setJobId(res.job_id);
setPhase("installing");
} catch (e) {
fail(e instanceof Error ? e.message : "Failed to start the update");
}
}, [latestVersion, fail]);
const dismiss = useCallback(() => {
setPhase("idle");
setErrorMessage(null);
setJobId(null);
clearPersisted();
}, []);
// Resume an in-progress update after a page reload (the restart window can
// outlive a manual refresh). Runs once; the version poll below confirms done.
useEffect(() => {
if (hydrated.current) return;
hydrated.current = true;
const persisted = readPersisted();
if (!persisted) return;
if (Date.now() - persisted.startedAt > MAX_DURATION_MS) {
clearPersisted();
return;
}
// Resume an in-progress update after a page reload. Promoting out of "idle"
// here (rather than in the initial state) keeps the first client render
// matching the server's; the version poll below then confirms completion.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPhase("installing");
}, []);
// Drive the flow off the live version. While installing/restarting, poll
// /system/info: success at the target version → done; an unreachable backend
// → restarting (expected); a failed launch job → error.
useEffect(() => {
if (phase !== "installing" && phase !== "restarting") return;
if (!targetVersion) return;
const startedAt = readPersisted()?.startedAt ?? Date.now();
let cancelled = false;
const tick = async () => {
if (Date.now() - startedAt > MAX_DURATION_MS) {
if (!cancelled) {
fail("Update is taking longer than expected. Refresh to check the current version.");
}
return;
}
if (await launchFailed(jobId)) {
if (!cancelled) fail("The update helper failed to start. Check the service logs.");
return;
}
try {
const info = await fetchSystemInfo({ cache: "no-store" });
if (cancelled) return;
if (info.version === targetVersion) {
setPhase("done");
clearPersisted();
onComplete?.();
}
} catch {
// Backend unreachable = it's restarting. Never downgrade out of this.
if (!cancelled) setPhase((p) => (p === "installing" ? "restarting" : p));
}
};
void tick();
const interval = setInterval(() => void tick(), POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [phase, targetVersion, jobId, onComplete, fail]);
return {
phase,
targetVersion,
errorMessage,
isUpdating: phase === "starting" || phase === "installing" || phase === "restarting",
start,
dismiss,
};
}
/**
* Returns true only when the launch job is known to have failed. A 404 (job
* tracker is in-memory and wiped on restart) or network error is treated as
* "not failed" so we keep waiting for the version flip.
*/
async function launchFailed(jobId: string | null): Promise<boolean> {
if (!jobId) return false;
try {
const job = await fetchSystemJob(jobId);
return job.state === "failed";
} catch {
return false;
}
}
@@ -23,6 +23,7 @@ import {
buildLogDownloadUrl,
fetchUpdates,
checkUpdates,
applyUpdate,
fetchSystemJob,
} from "./system-api";
@@ -261,4 +262,14 @@ describe("updates", () => {
expect(lastCall().url).toBe(`${BASE}/updates/check`);
expect(method()).toBe("POST");
});
it("applyUpdate POSTs /updates/apply with confirmation", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "self-update-1" }));
const res = await applyUpdate("UPDATE");
const { url, init } = lastCall();
expect(url).toBe(`${BASE}/updates/apply`);
expect((init?.method ?? "").toUpperCase()).toBe("POST");
expect(init?.body).toBe(JSON.stringify({ confirm: "UPDATE" }));
expect(res.job_id).toBe("self-update-1");
});
});
+11
View File
@@ -157,3 +157,14 @@ export function checkUpdates(options?: ApiRequestOptions): Promise<UpdatesRespon
init: { method: "POST", ...(options?.init ?? {}) },
});
}
export function applyUpdate(
confirm = "UPDATE",
options?: ApiRequestOptions,
): Promise<JobAcceptResponse> {
return fetchJson<JobAcceptResponse>(`${SYSTEM_BASE}/updates/apply`, {
...options,
// Spread caller init first so the required method/body can't be overridden.
init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ confirm }) },
});
}
+15 -1
View File
@@ -70,6 +70,19 @@ export interface UpdatesResponse {
/** ISO timestamp. */
latest_checked_at: string;
update_available: boolean;
install?: InstallState;
apply_supported?: boolean;
apply_unsupported_reason?: string;
manual_commands?: string[];
}
export interface InstallState {
running_as_service: boolean;
managed_service: boolean;
mode?: string;
manager?: string;
kind?: string;
metadata_path?: string;
}
export type SystemJobKind =
@@ -78,7 +91,8 @@ export type SystemJobKind =
| "factory-reset"
| "backup-create"
| "restore"
| "disk-walk";
| "disk-walk"
| "self-update";
export type SystemJobState = "queued" | "running" | "succeeded" | "failed";
@@ -0,0 +1,38 @@
# 0012: Service-only UI self-update
Status: accepted
Date: 2026-05-29
Area: backend, frontend, cli
## Context
Kandev can be installed through npm/npx or Homebrew and can also run interactively from a shell. Updating a running interactive process from the UI is brittle: the process may not have a stable service manager, restart semantics, or a durable place to hand work off before the backend exits.
The service installer already owns the OS-specific restart contract for systemd and launchd. That makes it the right boundary for UI-driven self-update.
## Decision
UI self-update is supported only when the backend proves all of these are true:
- It is running under `kandev service install`.
- The unit/plist contains kandev's managed marker and `KANDEV_SERVICE_*` environment.
- `<KANDEV_HOME_DIR>/service/install.json` matches the running service manager, mode, install kind, and service file.
- The service is user-mode, not `--system`.
- The install kind is `homebrew`, `npm`, or `npx`.
The backend returns this state from `GET /api/v1/system/updates`. The frontend renders **Apply update** only when the backend reports `apply_supported=true`; otherwise it shows manual update commands.
`POST /api/v1/system/updates/apply` writes an intent file and starts a helper outside the running service's lifetime:
- Linux user services use `systemd-run --user --collect`.
- macOS user agents use a one-shot transient LaunchAgent plist (`RunAtLoad=true`, `KeepAlive=false`) bootstrapped via `launchctl bootstrap`. (`launchctl submit` was rejected: its implicit KeepAlive re-ran the one-shot helper in a loop.)
- E2E/dev tests can fake the helper with `KANDEV_E2E_MOCK=true`.
The CLI owns the helper planner through a hidden `kandev service self-update --intent <path>` command. It upgrades the package via Homebrew, npm, or npx, re-runs `kandev service install`, then restarts the service.
## Consequences
- The UI never offers a destructive update button for an unknown install.
- System services remain explicit terminal operations because they need privilege and differ by host policy.
- Tests can cover the backend, CLI planner, and UI gate without mutating a developer machine.
- The service installer must keep metadata and `KANDEV_SERVICE_*` env vars current whenever it rewrites a unit/plist.
+1
View File
@@ -17,3 +17,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 0009 | [Fail-closed GC semantics for filesystem and container cleanup](0009-fail-closed-gc-semantics.md) | accepted | backend | 2026-05-16 |
| 0010 | [Worktree copy-files — per-repo, idempotent, host-local](0010-worktree-copy-files.md) | accepted | backend, frontend | 2026-05-19 |
| 0011 | [Transient provider errors (529 Overloaded) auto-retry with visible backoff](0011-transient-provider-error-retry.md) | accepted | backend, frontend | 2026-05-30 |
| 0012 | [Service-only UI self-update](0012-service-only-self-update.md) | accepted | backend, frontend, cli | 2026-05-29 |
+18 -5
View File
@@ -1,6 +1,6 @@
# Run Kandev as a Service
Install Kandev as an OS-managed service (systemd on Linux, launchd on macOS) so it auto-starts and stays running. Updates remain manual: `npm i -g kandev@latest` or `brew upgrade kandev`, then re-run `kandev service install`.
Install Kandev as an OS-managed service (systemd on Linux, launchd on macOS) so it auto-starts and stays running. User-mode services installed by `kandev service install` can self-update from the System → Updates page. Non-service installs and `--system` services still update manually: `npm i -g kandev@latest` or `brew upgrade kandev`, then re-run `kandev service install`.
This guide assumes you've already installed kandev via [Homebrew or npm](../apps/cli/README.md#quick-start) and that `kandev` works when run interactively.
@@ -65,9 +65,11 @@ kandev service config [--system]
## After an Upgrade
Both `npm` and Homebrew install kandev under a versioned directory (e.g. `node_modules/kandev/0.49.0/`, `Cellar/kandev/0.49.0/`). When you upgrade, the old version directory is removed and replaced — but the unit file you installed earlier still hard-codes the old absolute path.
If Kandev is running as a user-mode service installed by `kandev service install`, open **Settings → System → Updates** and use **Apply update** when it appears. The button is shown only when the backend can prove it is running from a kandev-managed service unit/plist with valid service metadata.
**Fix:** re-run install. It's idempotent.
Both `npm` and Homebrew install kandev under a versioned directory (e.g. `node_modules/kandev/0.49.0/`, `Cellar/kandev/0.49.0/`). Manual upgrades replace those paths, so the unit file must be refreshed afterward.
**Manual fix:** re-run install. It's idempotent.
```bash
npm i -g kandev@latest # or: brew upgrade kandev
@@ -76,6 +78,8 @@ kandev service install # rewrites the unit with the new paths
If you launch `kandev` interactively after an upgrade, it will detect a stale unit and print a one-line reminder. You can also check with `kandev service config` — the `cli entry:` and `node path:` lines tell you the paths that *would* be baked in by the next install.
System services (`kandev service install --system`) do not expose UI self-update. Update them from a privileged shell, then re-run `sudo kandev service install --system`.
## Linux Boot-Start (`loginctl enable-linger`)
Linux user services normally run only while you're logged in. To keep kandev running across reboots without an active SSH session, run **once**:
@@ -110,6 +114,11 @@ ExecStart=/usr/local/bin/node /usr/local/lib/node_modules/kandev/bin/cli.js --he
Environment=KANDEV_HOME_DIR=/home/alice/.kandev
Environment=KANDEV_LOG_LEVEL=info
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin
Environment=KANDEV_RUNNING_AS_SERVICE=true
Environment=KANDEV_SERVICE_MODE=user
Environment=KANDEV_SERVICE_MANAGER=systemd
Environment=KANDEV_INSTALL_KIND=npm
Environment=KANDEV_SERVICE_METADATA=/home/alice/.kandev/service/install.json
Restart=on-failure
RestartSec=5s
KillMode=mixed
@@ -119,7 +128,7 @@ TimeoutStopSec=30s
WantedBy=default.target
```
The `--headless` flag tells the CLI not to open a browser (you'll connect to it remotely or via `localhost`).
The `--headless` flag tells the CLI not to open a browser (you'll connect to it remotely or via `localhost`). The `KANDEV_SERVICE_*` variables and `<home>/service/install.json` metadata let the backend verify that UI self-update is safe before it shows the Apply button.
## Troubleshooting
@@ -151,11 +160,15 @@ This happens with `--system` if `SUDO_USER` isn't set (e.g. you logged in as roo
### After upgrading, the service silently keeps running the old version
The OS service manager keeps running whatever `ExecStart` it has — it doesn't know about npm/brew upgrades. **Always re-run `kandev service install` after an upgrade** so the unit picks up the new paths, then `kandev service restart` to pick up new code.
The OS service manager keeps running whatever `ExecStart` it has — it doesn't know about npm/brew upgrades. For managed user services, use **Apply update** on the Updates page. Otherwise, **always re-run `kandev service install` after an upgrade** so the unit picks up the new paths, then `kandev service restart` to pick up new code.
## Updating: TL;DR
```bash
# Option A: managed user service
# Use Settings -> System -> Updates -> Apply update
# Option B: manual / system service
# 1. Update the binary
npm i -g kandev@latest # or: brew upgrade kandev
+7 -2
View File
@@ -71,8 +71,9 @@ DELETE /api/v1/system/backups/:name - delete snapshot
GET /api/v1/system/logs - { files: [{ name, size, mtime }] }
GET /api/v1/system/logs/tail?n=1000 - last N lines of current log
GET /api/v1/system/logs/:name/download - stream log file
GET /api/v1/system/updates - { current, latest, latestCheckedAt, releaseUrl }
GET /api/v1/system/updates - { current, latest, latestCheckedAt, releaseUrl, install, applySupported }
POST /api/v1/system/updates/check - force GitHub re-poll; rate-limited 30s
POST /api/v1/system/updates/apply - queue service-only self-update; body { confirm: "UPDATE" }
```
Long-running operations (vacuum, optimize, reset, restore, snapshot create, disk walk) return `202 Accepted` with a `jobId` and publish progress on the existing event bus. The frontend subscribes via WS (`system.job.update` event) to render progress and final result. On success/failure the operation flips a corresponding entry in the existing health surface (e.g., a "VACUUM completed: reclaimed X MB" info issue that auto-expires).
@@ -85,7 +86,9 @@ A background goroutine in `internal/system/updates/poller.go` starts on backend
- `latest_version_url TEXT` — URL to that GitHub release
- `latest_version_checked_at INTEGER` — unix timestamp of last successful poll
The `GET /api/v1/system/updates` handler reads from `kandev_meta` only; it never calls GitHub synchronously. `POST /api/v1/system/updates/check` triggers an out-of-band refresh, rate-limited per-process to one call per 30 seconds. If the GitHub call fails (offline, rate limited, 5xx), the handler returns the last-known value and the `latestCheckedAt` exposes the staleness.
The `GET /api/v1/system/updates` handler reads from `kandev_meta` only; it never calls GitHub synchronously. It also reports the current service install state (`running_as_service`, `managed_service`, `mode`, `manager`, `kind`) so the UI can decide whether one-click apply is allowed. `POST /api/v1/system/updates/check` triggers an out-of-band refresh, rate-limited per-process to one call per 30 seconds. If the GitHub call fails (offline, rate limited, 5xx), the handler returns the last-known value and the `latestCheckedAt` exposes the staleness.
`POST /api/v1/system/updates/apply` is available only when Kandev is running as a kandev-managed user service (`systemd --user` or launchd user agent) and the latest release is newer than the current binary. It writes an update intent under `<KANDEV_HOME_DIR>/service/update-intents/`, starts a manager-owned helper (`systemd-run --user` on Linux, or a one-shot transient LaunchAgent plist bootstrapped via `launchctl bootstrap` on macOS), and returns a `self-update` system job id. Under `KANDEV_E2E_MOCK=true`, the helper path is fake so UI and backend tests can exercise the flow without mutating npm/Homebrew or restarting the service.
### Disk-usage cache
@@ -117,6 +120,8 @@ The page reads the JSON statically; no backend endpoint is needed.
- **GIVEN** the disk-usage cache is 30 minutes old, **WHEN** the user reopens the Status page, **THEN** the cached value renders instantly with "as of <30 min ago>" and **no** refresh kicks off.
- **GIVEN** the disk-usage cache is 3 hours old, **WHEN** the user reopens the Status page, **THEN** the stale value renders immediately, the page badge shows "Refreshing…", and the value updates when the background walk completes.
- **GIVEN** the user is on `1.2.3` and the GitHub latest release is `1.2.4`, **WHEN** they open `/settings/system/updates`, **THEN** an "Update available" badge renders next to the version, the changelog list shows `1.2.4` highlighted as the new entry, and the System sidebar group shows a `1` badge.
- **GIVEN** the user is running a kandev-managed user service and a newer release exists, **WHEN** they open `/settings/system/updates`, **THEN** the page shows **Apply update** and the confirmation queues a `self-update` job.
- **GIVEN** the user is not running as a kandev-managed service or is running a `--system` service, **WHEN** they open `/settings/system/updates`, **THEN** the page does not render an update-apply control and instead shows the manual update commands.
- **GIVEN** the user clicks **VACUUM** on the Database page, **WHEN** the operation completes, **THEN** the DB size delta is shown ("Reclaimed 12.3 MB"), the page Database stats refresh, and a transient info issue appears on the Status page.
- **GIVEN** the user clicks **Factory Reset**, types `RESET`, and confirms, **WHEN** the backend executes, **THEN** a fresh snapshot is created first, all tables are dropped and migrations re-run, the backend restarts, and the frontend redirects to the empty onboarding state once it reconnects.
- **GIVEN** the backend cannot reach GitHub, **WHEN** the poller fires, **THEN** the failure is logged but the previous `latest_version` and `latest_version_checked_at` remain in `kandev_meta`; the Updates page surfaces the stale value with a "Last checked <time>" subtitle.
+2 -2
View File
@@ -18,11 +18,11 @@ OUT_DIR="$ROOT_DIR/dist/kandev/cli"
echo "Building CLI for release bundle..."
# Build TypeScript
pnpm -C "$ROOT_DIR/apps" --filter kandev build
(cd "$ROOT_DIR/apps" && pnpm --filter kandev build)
echo " TypeScript build complete"
# Bundle with esbuild (inlines tree-kill and all other deps)
pnpm -C "$ROOT_DIR/apps/cli" bundle
(cd "$ROOT_DIR/apps" && pnpm --filter kandev bundle)
echo " esbuild bundle complete"
# Copy artifacts into dist/kandev/cli/