From 041a19f0605ca4c65bf5a2b3923cd805f65b0bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:49:15 +0100 Subject: [PATCH] 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 --- apps/backend/internal/system/system.go | 3 +- apps/backend/internal/system/updates/apply.go | 313 ++++++++++++++++ .../internal/system/updates/apply_test.go | 303 ++++++++++++++++ .../internal/system/updates/handler.go | 34 ++ .../internal/system/updates/handler_test.go | 115 ++++++ .../internal/system/updates/install_state.go | 190 ++++++++++ .../system/updates/install_state_test.go | 159 ++++++++ .../internal/system/updates/service.go | 201 +++++++++- apps/cli/src/constants.test.ts | 35 ++ apps/cli/src/constants.ts | 16 + apps/cli/src/run.test.ts | 3 + apps/cli/src/run.ts | 30 +- apps/cli/src/service/args.test.ts | 24 ++ apps/cli/src/service/args.ts | 36 +- apps/cli/src/service/index.ts | 7 + apps/cli/src/service/launchctl.test.ts | 122 +++++++ apps/cli/src/service/launchctl.ts | 106 ++++++ apps/cli/src/service/linux.ts | 29 +- apps/cli/src/service/macos.ts | 48 ++- apps/cli/src/service/metadata.test.ts | 66 ++++ apps/cli/src/service/metadata.ts | 76 ++++ apps/cli/src/service/paths.test.ts | 32 ++ apps/cli/src/service/paths.ts | 20 +- apps/cli/src/service/self_update.test.ts | 191 ++++++++++ apps/cli/src/service/self_update.ts | 281 ++++++++++++++ apps/cli/src/service/templates.test.ts | 37 ++ apps/cli/src/service/templates.ts | 26 ++ apps/cli/src/start.ts | 8 +- .../system/job-progress-indicator.test.tsx | 69 ++++ .../system/job-progress-indicator.tsx | 16 +- .../settings/system/self-update-progress.tsx | 106 ++++++ .../settings/system/updates-card.test.tsx | 131 +++++++ .../settings/system/updates-card.tsx | 343 ++++++++++++++---- .../web/e2e/tests/system/updates-page.spec.ts | 147 ++++++++ .../domains/system/use-self-update.test.ts | 124 +++++++ .../hooks/domains/system/use-self-update.ts | 206 +++++++++++ apps/web/lib/api/domains/system-api.test.ts | 11 + apps/web/lib/api/domains/system-api.ts | 11 + apps/web/lib/types/system.ts | 16 +- .../0012-service-only-self-update.md | 38 ++ docs/decisions/INDEX.md | 1 + docs/run-as-a-service.md | 23 +- docs/specs/system-page/spec.md | 9 +- scripts/release/package-cli.sh | 4 +- 44 files changed, 3630 insertions(+), 136 deletions(-) create mode 100644 apps/backend/internal/system/updates/apply.go create mode 100644 apps/backend/internal/system/updates/apply_test.go create mode 100644 apps/backend/internal/system/updates/install_state.go create mode 100644 apps/backend/internal/system/updates/install_state_test.go create mode 100644 apps/cli/src/constants.test.ts create mode 100644 apps/cli/src/service/launchctl.test.ts create mode 100644 apps/cli/src/service/launchctl.ts create mode 100644 apps/cli/src/service/metadata.test.ts create mode 100644 apps/cli/src/service/metadata.ts create mode 100644 apps/cli/src/service/self_update.test.ts create mode 100644 apps/cli/src/service/self_update.ts create mode 100644 apps/web/components/settings/system/job-progress-indicator.test.tsx create mode 100644 apps/web/components/settings/system/self-update-progress.tsx create mode 100644 apps/web/components/settings/system/updates-card.test.tsx create mode 100644 apps/web/hooks/domains/system/use-self-update.test.ts create mode 100644 apps/web/hooks/domains/system/use-self-update.ts create mode 100644 docs/decisions/0012-service-only-self-update.md diff --git a/apps/backend/internal/system/system.go b/apps/backend/internal/system/system.go index d68223cbd6..e0e720682c 100644 --- a/apps/backend/internal/system/system.go +++ b/apps/backend/internal/system/system.go @@ -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)) diff --git a/apps/backend/internal/system/updates/apply.go b/apps/backend/internal/system/updates/apply.go new file mode 100644 index 0000000000..e70efc82fd --- /dev/null +++ b/apps/backend/internal/system/updates/apply.go @@ -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-.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(" " + escapeXML(arg) + "\n") + } + var envXML strings.Builder + for _, kv := range selfUpdateEnvironment() { + key, value, _ := strings.Cut(kv, "=") + envXML.WriteString(" " + escapeXML(key) + "\n") + envXML.WriteString(" " + escapeXML(value) + "\n") + } + logPath := filepath.Join(req.Intent.Install.LogDir, "self-update-launchd.log") + return ` + + + + Label + ` + escapeXML(label) + ` + ProgramArguments + +` + argsXML.String() + ` + EnvironmentVariables + +` + envXML.String() + ` + RunAtLoad + + KeepAlive + + ProcessType + Background + StandardOutPath + ` + escapeXML(logPath) + ` + StandardErrorPath + ` + escapeXML(logPath) + ` + + +` +} + +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" +} diff --git a/apps/backend/internal/system/updates/apply_test.go b/apps/backend/internal/system/updates/apply_test.go new file mode 100644 index 0000000000..3b9e6681e4 --- /dev/null +++ b/apps/backend/internal/system/updates/apply_test.go @@ -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, "KeepAlive\n ") { + t.Fatalf("plist must set KeepAlive=false to run once:\n%s", plist) + } + for _, want := range []string{ + "com.kdlbs.kandev.self-update.test", + "RunAtLoad\n ", + "/opt/homebrew/bin/node", + "/tmp/npm-global/lib/node_modules/kandev/bin/cli.js", + "self-update", + "/tmp/intent.json", + "PATH\n /opt/homebrew/bin:/usr/bin", + "NPM_CONFIG_PREFIX\n /tmp/npm-global", + "/tmp/kandev/logs/self-update-launchd.log", + } { + 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 +} diff --git a/apps/backend/internal/system/updates/handler.go b/apps/backend/internal/system/updates/handler.go index 378134e128..4d3325875f 100644 --- a/apps/backend/internal/system/updates/handler.go +++ b/apps/backend/internal/system/updates/handler.go @@ -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}) + } +} diff --git a/apps/backend/internal/system/updates/handler_test.go b/apps/backend/internal/system/updates/handler_test.go index 4be2cef77b..b8405da2f7 100644 --- a/apps/backend/internal/system/updates/handler_test.go +++ b/apps/backend/internal/system/updates/handler_test.go @@ -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()) + } +} diff --git a/apps/backend/internal/system/updates/install_state.go b/apps/backend/internal/system/updates/install_state.go new file mode 100644 index 0000000000..4ec7af1a8f --- /dev/null +++ b/apps/backend/internal/system/updates/install_state.go @@ -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) +} diff --git a/apps/backend/internal/system/updates/install_state_test.go b/apps/backend/internal/system/updates/install_state_test.go new file mode 100644 index 0000000000..b1e80b6d28 --- /dev/null +++ b/apps/backend/internal/system/updates/install_state_test.go @@ -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 +} diff --git a/apps/backend/internal/system/updates/service.go b/apps/backend/internal/system/updates/service.go index 173dad07ee..694051ca7c 100644 --- a/apps/backend/internal/system/updates/service.go +++ b/apps/backend/internal/system/updates/service.go @@ -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), } } diff --git a/apps/cli/src/constants.test.ts b/apps/cli/src/constants.test.ts new file mode 100644 index 0000000000..7df977abbd --- /dev/null +++ b/apps/cli/src/constants.test.ts @@ -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"); + }); +}); diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 8311127381..1c84ce7f0d 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -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. diff --git a/apps/cli/src/run.test.ts b/apps/cli/src/run.test.ts index ff91cc4270..9fe7976ec1 100644 --- a/apps/cli/src/run.test.ts +++ b/apps/cli/src/run.test.ts @@ -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, diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 1a2a06361f..31744dceb5 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -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([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 { 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")); diff --git a/apps/cli/src/service/args.test.ts b/apps/cli/src/service/args.test.ts index 2fe41680c8..6d85f7a215 100644 --- a/apps/cli/src/service/args.test.ts +++ b/apps/cli/src/service/args.test.ts @@ -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/); + }); }); diff --git a/apps/cli/src/service/args.ts b/apps/cli/src/service/args.ts index 0645505945..ff9130f623 100644 --- a/apps/cli/src/service/args.ts +++ b/apps/cli/src/service/args.ts @@ -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([ @@ -35,6 +40,7 @@ const VALID_ACTIONS = new Set([ "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 "); + } const installOnly: Array = ["port", "homeDir", "noBootStart"]; if (args.action !== "install") { for (const flag of installOnly) { diff --git a/apps/cli/src/service/index.ts b/apps/cli/src/service/index.ts index eea76decbc..8eec834644 100644 --- a/apps/cli/src/service/index.ts +++ b/apps/cli/src/service/index.ts @@ -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 { 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); diff --git a/apps/cli/src/service/launchctl.test.ts b/apps/cli/src/service/launchctl.test.ts new file mode 100644 index 0000000000..bdb20a2b09 --- /dev/null +++ b/apps/cli/src/service/launchctl.test.ts @@ -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); + }); +}); diff --git a/apps/cli/src/service/launchctl.ts b/apps/cli/src/service/launchctl.ts new file mode 100644 index 0000000000..34de23af97 --- /dev/null +++ b/apps/cli/src/service/launchctl.ts @@ -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 ` 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); +} diff --git a/apps/cli/src/service/linux.ts b/apps/cli/src/service/linux.ts index c210c337cb..465bd37e58 100644 --- a/apps/cli/src/service/linux.ts +++ b/apps/cli/src/service/linux.ts @@ -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 { 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 diff --git a/apps/cli/src/service/macos.ts b/apps/cli/src/service/macos.ts index 658201dea8..1392565684 100644 --- a/apps/cli/src/service/macos.ts +++ b/apps/cli/src/service/macos.ts @@ -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 { 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 { diff --git a/apps/cli/src/service/metadata.test.ts b/apps/cli/src/service/metadata.test.ts new file mode 100644 index 0000000000..afcfde1c93 --- /dev/null +++ b/apps/cli/src/service/metadata.test.ts @@ -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 /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 }); + } + }); +}); diff --git a/apps/cli/src/service/metadata.ts b/apps/cli/src/service/metadata.ts new file mode 100644 index 0000000000..01c19173e6 --- /dev/null +++ b/apps/cli/src/service/metadata.ts @@ -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); +} diff --git a/apps/cli/src/service/paths.test.ts b/apps/cli/src/service/paths.test.ts index d5e6ce58ff..9741aa6c94 100644 --- a/apps/cli/src/service/paths.test.ts +++ b/apps/cli/src/service/paths.test.ts @@ -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"); diff --git a/apps/cli/src/service/paths.ts b/apps/cli/src/service/paths.ts index 298326679f..4a76270170 100644 --- a/apps/cli/src/service/paths.ts +++ b/apps/cli/src/service/paths.ts @@ -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]; " + diff --git a/apps/cli/src/service/self_update.test.ts b/apps/cli/src/service/self_update.test.ts new file mode 100644 index 0000000000..7ad457363c --- /dev/null +++ b/apps/cli/src/service/self_update.test.ts @@ -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 )", () => { + 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 }); + } + }); +}); diff --git a/apps/cli/src/service/self_update.ts b/apps/cli/src/service/self_update.ts new file mode 100644 index 0000000000..4aa6feac8b --- /dev/null +++ b/apps/cli/src/service/self_update.ts @@ -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, "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 `. 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 "); + } + 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); + } +} diff --git a/apps/cli/src/service/templates.test.ts b/apps/cli/src/service/templates.test.ts index 35bd973f9b..302fe76656 100644 --- a/apps/cli/src/service/templates.test.ts +++ b/apps/cli/src/service/templates.test.ts @@ -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[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[0]); + expect(plist).toContain("KANDEV_RUNNING_AS_SERVICE"); + expect(plist).toContain("true"); + expect(plist).toContain("KANDEV_SERVICE_MODE"); + expect(plist).toContain("user"); + expect(plist).toContain("KANDEV_SERVICE_MANAGER"); + expect(plist).toContain("launchd"); + expect(plist).toContain("KANDEV_INSTALL_KIND"); + expect(plist).toContain("npm"); + expect(plist).toContain("KANDEV_SERVICE_METADATA"); + expect(plist).toContain("/Users/alice/.kandev/service/install.json"); + }); + it("quotes Environment= lines when value contains a space (greptile P1 regression)", () => { const unit = renderSystemdUnit({ launcher: NPM_LAUNCHER, diff --git a/apps/cli/src/service/templates.ts b/apps/cli/src/service/templates.ts index 99e1d3af7b..2c2d62074e 100644 --- a/apps/cli/src/service/templates.ts +++ b/apps/cli/src/service/templates.ts @@ -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]) => ` ${escapeXml(k)}\n ${escapeXml(v)}`) @@ -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], + ]; +} diff --git a/apps/cli/src/start.ts b/apps/cli/src/start.ts index 6ba00df316..4d65b54ce7 100644 --- a/apps/cli/src/start.ts +++ b/apps/cli/src/start.ts @@ -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({ diff --git a/apps/web/components/settings/system/job-progress-indicator.test.tsx b/apps/web/components/settings/system/job-progress-indicator.test.tsx new file mode 100644 index 0000000000..cfa22deaae --- /dev/null +++ b/apps/web/components/settings/system/job-progress-indicator.test.tsx @@ -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 { + 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(); + + 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(); + + 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( + , + ); + + expect(screen.getByText("Restarting service")).not.toBeNull(); + expect(screen.queryByText("Done")).toBeNull(); + }); +}); diff --git a/apps/web/components/settings/system/job-progress-indicator.tsx b/apps/web/components/settings/system/job-progress-indicator.tsx index f7f79a0b2c..da1f8231a0 100644 --- a/apps/web/components/settings/system/job-progress-indicator.tsx +++ b/apps/web/components/settings/system/job-progress-indicator.tsx @@ -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-". */ 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 && } {isFailed && } - {stateLabel(job.state)} + {isSuccess && successLabel ? successLabel : stateLabel(job.state)} {job.message && {job.message}} diff --git a/apps/web/components/settings/system/self-update-progress.tsx b/apps/web/components/settings/system/self-update-progress.tsx new file mode 100644 index 0000000000..05ac6e6ae3 --- /dev/null +++ b/apps/web/components/settings/system/self-update-progress.tsx @@ -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 ( +
+
+ + {activeText(phase, targetVersion)} +
+

+ Keep this page open. It will refresh automatically when the update finishes. +

+
+ ); +} + +function DoneRow({ targetVersion }: { targetVersion: string | null }) { + return ( +
+
+ + Updated to {targetVersion ?? "the latest version"}. +
+ +
+ ); +} + +function ErrorRow({ message, onDismiss }: { message: string | null; onDismiss: () => void }) { + return ( +
+
+ + {message ?? "The update failed."} +
+ +
+ ); +} + +function ProgressBody({ phase, targetVersion, errorMessage, onDismiss }: SelfUpdateProgressProps) { + if (phase === "done") return ; + if (phase === "error") return ; + return ; +} + +export function SelfUpdateProgress(props: SelfUpdateProgressProps) { + if (props.phase === "idle") return null; + return ( +
+ +
+ ); +} diff --git a/apps/web/components/settings/system/updates-card.test.tsx b/apps/web/components/settings/system/updates-card.test.tsx new file mode 100644 index 0000000000..70a8d22967 --- /dev/null +++ b/apps/web/components/settings/system/updates-card.test.tsx @@ -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 { + 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 { + 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(); + + 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(); + 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(); + + 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(); + + expect(screen.queryByTestId(APPLY_TESTID)).toBeNull(); + expect(screen.getByTestId("system-updates-progress").textContent).toContain( + "Updated to v1.0.1", + ); + }); +}); diff --git a/apps/web/components/settings/system/updates-card.tsx b/apps/web/components/settings/system/updates-card.tsx index b52c9c6f94..f7fb1f087a 100644 --- a/apps/web/components/settings/system/updates-card.tsx +++ b/apps/web/components/settings/system/updates-card.tsx @@ -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(null); const [retryAfter, setRetryAfter] = useState(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 ( - - - Updates - {available && ( - - Update available - - )} - + -
-
-
Current version
-
- {current} -
-
-
-
Latest release
-
- {latest} -
-
-
- -
- Last checked {formatChecked(checkedAt)} -
- -
- - {url && ( - - )} -
- - {error && ( -

- {retryAfter ? `Already checked. Try again in ${retryAfter}s.` : error} -

- )} + + + + + +
); } + +function UpdatesHeader({ available }: { available: boolean }) { + return ( + + + Updates + {available && ( + + Update available + + )} + + ); +} + +function VersionGrid({ current, latest }: { current: string; latest: string }) { + return ( +
+ + +
+ ); +} + +function VersionValue({ label, value, testId }: { label: string; value: string; testId: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function LastChecked({ checkedAt }: { checkedAt?: string }) { + return ( +
+ Last checked {formatChecked(checkedAt)} +
+ ); +} + +interface UpdateActionsProps { + checking: boolean; + showApply: boolean; + latest: string; + url?: string; + onCheck: () => Promise; + onApply: () => Promise; +} + +function UpdateActions(props: UpdateActionsProps) { + return ( +
+ + + +
+ ); +} + +function CheckNowButton({ + checking, + onCheck, +}: { + checking: boolean; + onCheck: () => Promise; +}) { + return ( + + ); +} + +function ReleaseNotesLink({ url }: { url?: string }) { + if (!url) return null; + return ( + + ); +} + +interface ApplyUpdateDialogProps { + showApply: boolean; + latest: string; + onApply: () => Promise; +} + +function ApplyUpdateDialog({ showApply, latest, onApply }: ApplyUpdateDialogProps) { + if (!showApply) return null; + return ( + + + + + + + Apply update? + + Kandev will update to {latest}, reinstall the user service, and restart it. + + + + Cancel + void onApply()} + data-testid="system-updates-apply-confirm" + > + Apply update + + + + + ); +} + +function ManualUpdateInstructions({ + show, + reason, + commands, +}: { + show: boolean; + reason?: string; + commands: string[]; +}) { + if (!show || !reason) return null; + return ( +
+

{reason}

+ +
+ ); +} + +function ManualCommands({ commands }: { commands: string[] }) { + if (commands.length === 0) return null; + return ( +
+ {commands.map((cmd) => ( + + {cmd} + + ))} +
+ ); +} + +function UpdateError({ error, retryAfter }: { error: string | null; retryAfter: number | null }) { + if (!error) return null; + return ( +

+ {retryAfter ? `Already checked. Try again in ${retryAfter}s.` : error} +

+ ); +} diff --git a/apps/web/e2e/tests/system/updates-page.spec.ts b/apps/web/e2e/tests/system/updates-page.spec.ts index 3fc360dbe6..3d6edf4152 100644 --- a/apps/web/e2e/tests/system/updates-page.spec.ts +++ b/apps/web/e2e/tests/system/updates-page.spec.ts @@ -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 }) => { diff --git a/apps/web/hooks/domains/system/use-self-update.test.ts b/apps/web/hooks/domains/system/use-self-update.test.ts new file mode 100644 index 0000000000..c57ac078ac --- /dev/null +++ b/apps/web/hooks/domains/system/use-self-update.test.ts @@ -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(); + }); +}); diff --git a/apps/web/hooks/domains/system/use-self-update.ts b/apps/web/hooks/domains/system/use-self-update.ts new file mode 100644 index 0000000000..7c980317cf --- /dev/null +++ b/apps/web/hooks/domains/system/use-self-update.ts @@ -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; + dismiss: () => void; +}; + +export function useSelfUpdate({ + latestVersion, + onComplete, +}: UseSelfUpdateArgs): SelfUpdateController { + const [phase, setPhase] = useState("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( + () => readPersisted()?.target ?? null, + ); + const [errorMessage, setErrorMessage] = useState(null); + const [jobId, setJobId] = useState(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 { + if (!jobId) return false; + try { + const job = await fetchSystemJob(jobId); + return job.state === "failed"; + } catch { + return false; + } +} diff --git a/apps/web/lib/api/domains/system-api.test.ts b/apps/web/lib/api/domains/system-api.test.ts index d24b978e84..21efd36947 100644 --- a/apps/web/lib/api/domains/system-api.test.ts +++ b/apps/web/lib/api/domains/system-api.test.ts @@ -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"); + }); }); diff --git a/apps/web/lib/api/domains/system-api.ts b/apps/web/lib/api/domains/system-api.ts index 4254883966..c06399f874 100644 --- a/apps/web/lib/api/domains/system-api.ts +++ b/apps/web/lib/api/domains/system-api.ts @@ -157,3 +157,14 @@ export function checkUpdates(options?: ApiRequestOptions): Promise { + return fetchJson(`${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 }) }, + }); +} diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts index 8eb777040d..3c44b28a28 100644 --- a/apps/web/lib/types/system.ts +++ b/apps/web/lib/types/system.ts @@ -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"; diff --git a/docs/decisions/0012-service-only-self-update.md b/docs/decisions/0012-service-only-self-update.md new file mode 100644 index 0000000000..3885268883 --- /dev/null +++ b/docs/decisions/0012-service-only-self-update.md @@ -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. +- `/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 ` 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. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 3b83b8a545..7027fb04e3 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -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 | diff --git a/docs/run-as-a-service.md b/docs/run-as-a-service.md index 998c81c546..0f0fd0ee7b 100644 --- a/docs/run-as-a-service.md +++ b/docs/run-as-a-service.md @@ -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 `/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 diff --git a/docs/specs/system-page/spec.md b/docs/specs/system-page/spec.md index 07da2ccc7f..965b23e270 100644 --- a/docs/specs/system-page/spec.md +++ b/docs/specs/system-page/spec.md @@ -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 `/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