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:
@@ -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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
"'", "'",
|
||||
)
|
||||
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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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]; " +
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/
|
||||
|
||||
Reference in New Issue
Block a user