diff --git a/doc/openapi.yaml b/doc/openapi.yaml index 9ce80ed..32799c9 100644 --- a/doc/openapi.yaml +++ b/doc/openapi.yaml @@ -1622,6 +1622,42 @@ components: type: integer minimum: 0 description: Currently-running `index_runs` rows. + update_available: + type: boolean + description: | + True when the version-check service has found a `server/v*` + release on GitHub strictly newer than the running server. + Field is omitted entirely when version-check is not wired + (set `CIX_VERSION_CHECK_ENABLED=false` to disable polling). + latest_version: + type: string + nullable: true + description: | + Latest released server version (without the `server/v` prefix, + e.g. `0.5.1`). Null until the first successful poll completes. + release_url: + type: string + nullable: true + description: GitHub release page URL for `latest_version`. Null when unknown. + version_check: + $ref: "#/components/schemas/VersionCheckStatus" + + VersionCheckStatus: + type: object + required: [enabled] + properties: + enabled: + type: boolean + description: Whether the periodic GitHub poll is running. + checked_at: + type: string + format: date-time + nullable: true + description: Last poll timestamp (UTC, RFC 3339). Null before the first poll. + error: + type: string + nullable: true + description: Last error message, if the most recent poll failed. Null on success. ProjectSettings: type: object diff --git a/server/Makefile b/server/Makefile index 22f3f0e..e3360dc 100644 --- a/server/Makefile +++ b/server/Makefile @@ -95,6 +95,16 @@ bundle: fetch-llama build @# filepath.Dir(os.Executable())/llama default resolves correctly. mkdir -p $(BUNDLE_DIR)/llama cp -R $(LLAMA_DIR)/. $(BUNDLE_DIR)/llama/ +ifeq ($(OS),darwin) + @# macOS Sequoia (26+) tightened amfid: ad-hoc-signed binaries whose + @# linked dylibs carry stale signatures or a com.apple.provenance + @# xattr from the previous bundle get SIGKILL'd within milliseconds + @# of execve — supervisor sees "signal: killed" with empty stderr. + @# `cp -R` creates new files macOS treats as untrusted, so the strip + @# + deep re-sign must run on every bundle, not just first install. + @xattr -cr $(BUNDLE_DIR)/llama/ + @codesign --force --deep --sign - $(BUNDLE_DIR)/llama/llama-server +endif @echo "Bundle ready: $(BUNDLE_DIR)" @echo "Optional: tar czf $(DIST_DIR)/$(BUNDLE_NAME).tar.gz -C $(DIST_DIR) $(BUNDLE_NAME)" diff --git a/server/cmd/cix-server/main.go b/server/cmd/cix-server/main.go index 1ea3c81..af97945 100644 --- a/server/cmd/cix-server/main.go +++ b/server/cmd/cix-server/main.go @@ -26,6 +26,7 @@ import ( "github.com/dvcdsys/code-index/server/internal/sessions" "github.com/dvcdsys/code-index/server/internal/users" "github.com/dvcdsys/code-index/server/internal/vectorstore" + "github.com/dvcdsys/code-index/server/internal/versioncheck" ) func runHealthcheck() { @@ -190,6 +191,20 @@ func run() error { } } + // Background version-check poller. The 60s initial delay keeps GitHub + // off the boot path; the goroutine exits cleanly when bgCtx is canceled + // in the shutdown branch below. + bgCtx, bgCancel := context.WithCancel(context.Background()) + defer bgCancel() + vcSvc := versioncheck.New(versioncheck.Config{ + Enabled: cfg.VersionCheckEnabled, + Interval: cfg.VersionCheckInterval, + InitialDelay: 60 * time.Second, + Repo: cfg.VersionCheckRepo, + CurrentVersion: version, + }, logger) + go vcSvc.Run(bgCtx) + handler := httpapi.NewRouter(httpapi.Deps{ DB: database, ServerVersion: version, @@ -205,6 +220,7 @@ func run() error { VectorStore: vs, Indexer: idx, RuntimeCfg: rcfg, + VersionCheck: vcSvc, }) srv := &http.Server{ diff --git a/server/dashboard/src/app/Shell.tsx b/server/dashboard/src/app/Shell.tsx index f0f8955..782e8e7 100644 --- a/server/dashboard/src/app/Shell.tsx +++ b/server/dashboard/src/app/Shell.tsx @@ -1,14 +1,17 @@ import type { ReactNode } from 'react'; import { Sidebar } from './Sidebar'; import { Footer } from './Footer'; +import { UpdateBanner } from './UpdateBanner'; // Three-row layout: sidebar + main on top, footer spanning the full // width on the bottom. min-h-0 on the inner row is required so that //
's overflow-y-auto honors the footer's height when content -// grows tall. +// grows tall. UpdateBanner sits above the main row so it spans the +// full width when a newer server release is available. export function Shell({ children }: { children: ReactNode }) { return (
+
diff --git a/server/dashboard/src/app/UpdateBanner.tsx b/server/dashboard/src/app/UpdateBanner.tsx new file mode 100644 index 0000000..0d06776 --- /dev/null +++ b/server/dashboard/src/app/UpdateBanner.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react'; +import { ArrowUpCircle, X } from 'lucide-react'; +import { useServerStatus } from '@/lib/useServerStatus'; +import { cn } from '@/lib/cn'; + +const dismissKey = (version: string) => `cix.update-dismissed.${version}`; + +// UpdateBanner renders at the top of the dashboard shell when the +// server-side version checker reports a newer `server/v*` release on +// GitHub. Dismissals are namespaced by the latest version, so dismissing +// 0.6.0 silences the banner only until 0.6.1 ships. +export function UpdateBanner() { + const { data } = useServerStatus(); + const latest = data?.latest_version ?? null; + const updateAvailable = data?.update_available === true && !!latest; + + const [dismissed, setDismissed] = useState(false); + + // Re-evaluate the localStorage flag whenever the latest version changes + // — a fresh release should re-show the banner even within an open tab. + useEffect(() => { + if (!latest) { + setDismissed(false); + return; + } + setDismissed(localStorage.getItem(dismissKey(latest)) === '1'); + }, [latest]); + + if (!updateAvailable || dismissed || !latest) return null; + + const onDismiss = () => { + localStorage.setItem(dismissKey(latest), '1'); + setDismissed(true); + }; + + return ( +
+ +
+ cix-server v{latest} is available + {data?.server_version ? ( + <> (you're running v{data.server_version}) + ) : null} + {data?.release_url ? ( + <> + {' — '} + + release notes + + + ) : null} +
+ +
+ ); +} diff --git a/server/dashboard/src/lib/useServerStatus.ts b/server/dashboard/src/lib/useServerStatus.ts index 6a900bd..049bbce 100644 --- a/server/dashboard/src/lib/useServerStatus.ts +++ b/server/dashboard/src/lib/useServerStatus.ts @@ -5,6 +5,16 @@ interface StatusPayload { server_version: string; embedding_model: string; model_loaded: boolean; + // Version-check fields are present only when the server has the + // versioncheck service wired (see CIX_VERSION_CHECK_ENABLED). + update_available?: boolean; + latest_version?: string | null; + release_url?: string | null; + version_check?: { + enabled: boolean; + checked_at?: string | null; + error?: string | null; + }; } // useServerStatus polls /api/v1/status every 30 seconds. The footer diff --git a/server/dashboard/tsconfig.tsbuildinfo b/server/dashboard/tsconfig.tsbuildinfo index 2599c3e..9c15565 100644 --- a/server/dashboard/tsconfig.tsbuildinfo +++ b/server/dashboard/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/generated.ts","./src/api/types.ts","./src/app/app.tsx","./src/app/footer.tsx","./src/app/shell.tsx","./src/app/sidebar.tsx","./src/app/themeprovider.tsx","./src/app/providers.tsx","./src/auth/authprovider.tsx","./src/auth/bootstrapneededpage.tsx","./src/auth/changepasswordpage.tsx","./src/auth/loginpage.tsx","./src/auth/useauth.ts","./src/lib/cn.ts","./src/lib/editorpreference.ts","./src/lib/formatdate.ts","./src/lib/theme.ts","./src/lib/useserverstatus.ts","./src/modules/registry.ts","./src/modules/types.ts","./src/modules/api-keys/apikeyspage.tsx","./src/modules/api-keys/hooks.ts","./src/modules/api-keys/index.ts","./src/modules/api-keys/components/apikeytable.tsx","./src/modules/api-keys/components/createapikeydialog.tsx","./src/modules/api-keys/components/revokeapikeydialog.tsx","./src/modules/home/homepage.tsx","./src/modules/home/index.ts","./src/modules/projects/projectdetailpage.tsx","./src/modules/projects/projectslistpage.tsx","./src/modules/projects/projectspage.tsx","./src/modules/projects/hooks.ts","./src/modules/projects/index.ts","./src/modules/projects/components/deleteprojectdialog.tsx","./src/modules/projects/components/projectcard.tsx","./src/modules/projects/components/projectinfocard.tsx","./src/modules/search/searchpage.tsx","./src/modules/search/hooks.ts","./src/modules/search/index.ts","./src/modules/search/components/filters.tsx","./src/modules/search/components/resultfilecard.tsx","./src/modules/search/components/resultsnippet.tsx","./src/modules/search/components/searchinput.tsx","./src/modules/server/serverpage.tsx","./src/modules/server/hooks.ts","./src/modules/server/index.ts","./src/modules/server/components/saveandrestartdialog.tsx","./src/modules/server/components/sidecarstatebadge.tsx","./src/modules/server/components/sourcepill.tsx","./src/modules/server/sections/advancedsection.tsx","./src/modules/server/sections/embeddingmodelsection.tsx","./src/modules/server/sections/runtimeparamssection.tsx","./src/modules/server/sections/sidecarsection.tsx","./src/modules/settings/settingspage.tsx","./src/modules/settings/hooks.ts","./src/modules/settings/index.ts","./src/modules/settings/components/changepasswordform.tsx","./src/modules/settings/components/sessionrow.tsx","./src/modules/settings/sections/editorsection.tsx","./src/modules/settings/sections/profilesection.tsx","./src/modules/settings/sections/sessionssection.tsx","./src/modules/settings/sections/themesection.tsx","./src/modules/users/userspage.tsx","./src/modules/users/hooks.ts","./src/modules/users/index.ts","./src/modules/users/components/deleteuserdialog.tsx","./src/modules/users/components/disableuserbutton.tsx","./src/modules/users/components/inviteuserdialog.tsx","./src/modules/users/components/userroleselect.tsx","./src/modules/users/components/userstable.tsx","./src/ui/alert.tsx","./src/ui/badge.tsx","./src/ui/button.tsx","./src/ui/card.tsx","./src/ui/dialog.tsx","./src/ui/input.tsx","./src/ui/label.tsx","./src/ui/radio-group.tsx","./src/ui/scroll-area.tsx","./src/ui/select.tsx","./src/ui/skeleton.tsx","./src/ui/slider.tsx","./src/ui/sonner.tsx","./src/ui/switch.tsx","./src/ui/table.tsx","./src/ui/tabs.tsx","./src/ui/tooltip.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/generated.ts","./src/api/types.ts","./src/app/app.tsx","./src/app/footer.tsx","./src/app/shell.tsx","./src/app/sidebar.tsx","./src/app/themeprovider.tsx","./src/app/updatebanner.tsx","./src/app/providers.tsx","./src/auth/authprovider.tsx","./src/auth/bootstrapneededpage.tsx","./src/auth/changepasswordpage.tsx","./src/auth/loginpage.tsx","./src/auth/useauth.ts","./src/lib/cn.ts","./src/lib/editorpreference.ts","./src/lib/formatdate.ts","./src/lib/theme.ts","./src/lib/useserverstatus.ts","./src/modules/registry.ts","./src/modules/types.ts","./src/modules/api-keys/apikeyspage.tsx","./src/modules/api-keys/hooks.ts","./src/modules/api-keys/index.ts","./src/modules/api-keys/components/apikeytable.tsx","./src/modules/api-keys/components/createapikeydialog.tsx","./src/modules/api-keys/components/revokeapikeydialog.tsx","./src/modules/home/homepage.tsx","./src/modules/home/index.ts","./src/modules/projects/projectdetailpage.tsx","./src/modules/projects/projectslistpage.tsx","./src/modules/projects/projectspage.tsx","./src/modules/projects/hooks.ts","./src/modules/projects/index.ts","./src/modules/projects/components/deleteprojectdialog.tsx","./src/modules/projects/components/projectcard.tsx","./src/modules/projects/components/projectinfocard.tsx","./src/modules/search/searchpage.tsx","./src/modules/search/hooks.ts","./src/modules/search/index.ts","./src/modules/search/components/filters.tsx","./src/modules/search/components/resultfilecard.tsx","./src/modules/search/components/resultsnippet.tsx","./src/modules/search/components/searchinput.tsx","./src/modules/server/serverpage.tsx","./src/modules/server/hooks.ts","./src/modules/server/index.ts","./src/modules/server/components/saveandrestartdialog.tsx","./src/modules/server/components/sidecarstatebadge.tsx","./src/modules/server/components/sourcepill.tsx","./src/modules/server/sections/advancedsection.tsx","./src/modules/server/sections/embeddingmodelsection.tsx","./src/modules/server/sections/runtimeparamssection.tsx","./src/modules/server/sections/sidecarsection.tsx","./src/modules/settings/settingspage.tsx","./src/modules/settings/hooks.ts","./src/modules/settings/index.ts","./src/modules/settings/components/changepasswordform.tsx","./src/modules/settings/components/sessionrow.tsx","./src/modules/settings/sections/editorsection.tsx","./src/modules/settings/sections/profilesection.tsx","./src/modules/settings/sections/sessionssection.tsx","./src/modules/settings/sections/themesection.tsx","./src/modules/users/userspage.tsx","./src/modules/users/hooks.ts","./src/modules/users/index.ts","./src/modules/users/components/deleteuserdialog.tsx","./src/modules/users/components/disableuserbutton.tsx","./src/modules/users/components/inviteuserdialog.tsx","./src/modules/users/components/userroleselect.tsx","./src/modules/users/components/userstable.tsx","./src/ui/alert.tsx","./src/ui/badge.tsx","./src/ui/button.tsx","./src/ui/card.tsx","./src/ui/dialog.tsx","./src/ui/input.tsx","./src/ui/label.tsx","./src/ui/radio-group.tsx","./src/ui/scroll-area.tsx","./src/ui/select.tsx","./src/ui/skeleton.tsx","./src/ui/slider.tsx","./src/ui/sonner.tsx","./src/ui/switch.tsx","./src/ui/table.tsx","./src/ui/tabs.tsx","./src/ui/tooltip.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 541a1ce..372ddc7 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -10,6 +10,7 @@ import ( "runtime" "strconv" "strings" + "time" ) // Config holds all runtime settings. Port defaults to 21847 — the same @@ -80,6 +81,18 @@ type Config struct { // CIX_BOOTSTRAP_ADMIN_PASSWORD. BootstrapAdminEmail string BootstrapAdminPassword string + + // Version-check feature — periodic GitHub poll that surfaces a + // "newer release available" banner in the dashboard. Off entirely when + // CIX_VERSION_CHECK_ENABLED=false (no outbound HTTP at all). Repo is + // configurable so forks can point at their own GitHub project; the tag + // filter is hardcoded to `server/v` since this is the server binary. + // Sources: CIX_VERSION_CHECK_ENABLED (default true), + // CIX_VERSION_CHECK_INTERVAL (default 6h, Go duration string), + // CIX_VERSION_CHECK_REPO (default "dvcdsys/code-index"). + VersionCheckEnabled bool + VersionCheckInterval time.Duration + VersionCheckRepo string } // ModelSafeName returns the embedding model name normalised for use inside @@ -238,6 +251,20 @@ func Load() (*Config, error) { c.BootstrapAdminEmail = getenv("CIX_BOOTSTRAP_ADMIN_EMAIL", "") c.BootstrapAdminPassword = getenv("CIX_BOOTSTRAP_ADMIN_PASSWORD", "") + vcEnabled, err := getenvBool("CIX_VERSION_CHECK_ENABLED", true) + if err != nil { + return nil, err + } + c.VersionCheckEnabled = vcEnabled + + vcInterval, err := getenvDuration("CIX_VERSION_CHECK_INTERVAL", 6*time.Hour) + if err != nil { + return nil, err + } + c.VersionCheckInterval = vcInterval + + c.VersionCheckRepo = getenv("CIX_VERSION_CHECK_REPO", "dvcdsys/code-index") + return c, nil } @@ -384,3 +411,15 @@ func getenvBool(key string, def bool) (bool, error) { } return b, nil } + +func getenvDuration(key string, def time.Duration) (time.Duration, error) { + v, ok := os.LookupEnv(key) + if !ok { + return def, nil + } + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("env %s: %w", key, err) + } + return d, nil +} diff --git a/server/internal/httpapi/openapi/openapi.gen.go b/server/internal/httpapi/openapi/openapi.gen.go index b2cf5cf..a7b29ae 100644 --- a/server/internal/httpapi/openapi/openapi.gen.go +++ b/server/internal/httpapi/openapi/openapi.gen.go @@ -932,14 +932,28 @@ type StatusResponse struct { // EmbeddingModel Hugging Face model id (e.g. `awhiteside/CodeRankEmbed-Q8_0-GGUF`). EmbeddingModel string `json:"embedding_model"` + // LatestVersion Latest released server version (without the `server/v` prefix, + // e.g. `0.5.1`). Null until the first successful poll completes. + LatestVersion *string `json:"latest_version,omitempty"` + // ModelLoaded Whether the llama-server sidecar reports ready within 500 ms. // False when the sidecar is starting or has crashed. ModelLoaded bool `json:"model_loaded"` // Projects Total registered projects. - Projects int `json:"projects"` + Projects int `json:"projects"` + + // ReleaseUrl GitHub release page URL for `latest_version`. Null when unknown. + ReleaseUrl *string `json:"release_url,omitempty"` ServerVersion string `json:"server_version"` Status StatusResponseStatus `json:"status"` + + // UpdateAvailable True when the version-check service has found a `server/v*` + // release on GitHub strictly newer than the running server. + // Field is omitted entirely when version-check is not wired + // (set `CIX_VERSION_CHECK_ENABLED=false` to disable polling). + UpdateAvailable *bool `json:"update_available,omitempty"` + VersionCheck *VersionCheckStatus `json:"version_check,omitempty"` } // StatusResponseStatus defines model for StatusResponse.Status. @@ -1049,6 +1063,18 @@ type UserWithStats struct { // UserWithStatsRole defines model for UserWithStats.Role. type UserWithStatsRole string +// VersionCheckStatus defines model for VersionCheckStatus. +type VersionCheckStatus struct { + // CheckedAt Last poll timestamp (UTC, RFC 3339). Null before the first poll. + CheckedAt *time.Time `json:"checked_at,omitempty"` + + // Enabled Whether the periodic GitHub poll is running. + Enabled bool `json:"enabled"` + + // Error Last error message, if the most recent poll failed. Null on success. + Error *string `json:"error,omitempty"` +} + // ProjectHash defines model for ProjectHash. type ProjectHash = string @@ -2708,201 +2734,206 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", - "/d8AAAD//w==", + "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go index 12f46b7..a279c29 100644 --- a/server/internal/httpapi/router.go +++ b/server/internal/httpapi/router.go @@ -19,6 +19,7 @@ import ( "github.com/dvcdsys/code-index/server/internal/sessions" "github.com/dvcdsys/code-index/server/internal/users" "github.com/dvcdsys/code-index/server/internal/vectorstore" + "github.com/dvcdsys/code-index/server/internal/versioncheck" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) @@ -68,6 +69,9 @@ type Deps struct { // RuntimeCfg backs the dashboard's /admin/runtime-config endpoints. Nil // in router-only tests; admin handlers return 503 when absent. RuntimeCfg *runtimecfg.Service + // VersionCheck polls GitHub for newer server releases. Nil = feature + // off; GetStatus then omits the version-check fields entirely. + VersionCheck *versioncheck.Service } // NewRouter builds the chi router with middleware and the generated diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 5f57941..cc6547a 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -87,7 +87,7 @@ func (s *Server) GetStatus(w http.ResponseWriter, r *http.Request) { model = cfg.EmbeddingModel } } - writeJSON(w, http.StatusOK, map[string]any{ + resp := map[string]any{ "status": "ok", "backend": s.Deps.Backend, "server_version": s.Deps.ServerVersion, @@ -96,7 +96,38 @@ func (s *Server) GetStatus(w http.ResponseWriter, r *http.Request) { "embedding_model": model, "projects": projectCount, "active_indexing_jobs": activeJobs, - }) + } + // Version-check fields — folded in only when the service is wired. + // `update_available` is always present (false when unknown) so the + // dashboard can branch without a null check; the optional URL/version + // fields stay nullable so the banner knows when there's nothing to + // link to yet. + if s.Deps.VersionCheck != nil { + snap := s.Deps.VersionCheck.Latest() + resp["update_available"] = snap.UpdateAvailable + resp["latest_version"] = nilIfEmpty(snap.LatestVersion) + resp["release_url"] = nilIfEmpty(snap.ReleaseURL) + vc := map[string]any{ + "enabled": snap.Enabled, + "error": nilIfEmpty(snap.LastError), + } + if snap.CheckedAt.IsZero() { + vc["checked_at"] = nil + } else { + vc["checked_at"] = snap.CheckedAt.Format(time.RFC3339) + } + resp["version_check"] = vc + } + writeJSON(w, http.StatusOK, resp) +} + +// nilIfEmpty returns nil for an empty string so writeJSON emits `null` +// rather than `""`. Lets the dashboard branch on `field !== null`. +func nilIfEmpty(s string) any { + if s == "" { + return nil + } + return s } // --------------------------------------------------------------------------- diff --git a/server/internal/versioncheck/check.go b/server/internal/versioncheck/check.go new file mode 100644 index 0000000..0626e75 --- /dev/null +++ b/server/internal/versioncheck/check.go @@ -0,0 +1,337 @@ +// Package versioncheck periodically polls GitHub for the latest cix-server +// release tag and exposes the result for the dashboard "update available" +// banner. One poll per server, regardless of how many clients are open — +// the per-instance cache avoids hammering the GitHub API and keeps an +// untrusted browser from being able to forge "latest version" claims. +// +// Tag stream is hardcoded to `server/v*` (cli/v* lives on a separate stream +// per CLAUDE.md). Pre-releases and drafts are skipped. ETag-based revalidation +// keeps unauthenticated rate-limit usage at ~4 req/day per server (default +// 6h interval, vs. the 60/h GitHub limit). +package versioncheck + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// Config configures the version-check service. Zero-value fields fall back +// to documented defaults inside New(). +type Config struct { + Enabled bool + Interval time.Duration + InitialDelay time.Duration + BaseURL string // default "https://api.github.com" — overridable for tests + Repo string // e.g. "dvcdsys/code-index" + TagPrefix string // default "server/v" + CurrentVersion string // e.g. "0.5.1" or "0.0.0-dev" + HTTPTimeout time.Duration + UserAgent string +} + +// Snapshot is a point-in-time view of the cached version-check state. +// All time / string fields are zero-valued when no successful check has +// happened yet — callers branch on LatestVersion != "" or CheckedAt.IsZero(). +type Snapshot struct { + Enabled bool + CurrentVersion string + LatestVersion string + UpdateAvailable bool + ReleaseURL string + CheckedAt time.Time + LastError string +} + +// Service polls GitHub on a ticker and serves the cached result via Latest(). +// Safe for concurrent use; Run blocks until the supplied context is canceled. +type Service struct { + cfg Config + logger *slog.Logger + client *http.Client + + mu sync.RWMutex + snapshot Snapshot + etag string + lastNotes string // unused for now; reserved for release-notes preview +} + +// New builds a Service. Defaults: BaseURL=api.github.com, TagPrefix=server/v, +// HTTPTimeout=10s, UserAgent="cix-server/". +func New(cfg Config, logger *slog.Logger) *Service { + if cfg.BaseURL == "" { + cfg.BaseURL = "https://api.github.com" + } + if cfg.TagPrefix == "" { + cfg.TagPrefix = "server/v" + } + if cfg.HTTPTimeout <= 0 { + cfg.HTTPTimeout = 10 * time.Second + } + if cfg.UserAgent == "" { + cfg.UserAgent = "cix-server/" + cfg.CurrentVersion + } + if logger == nil { + logger = slog.Default() + } + return &Service{ + cfg: cfg, + logger: logger, + client: &http.Client{Timeout: cfg.HTTPTimeout}, + snapshot: Snapshot{ + Enabled: cfg.Enabled, + CurrentVersion: cfg.CurrentVersion, + }, + } +} + +// Latest returns a copy of the current snapshot. Cheap; safe for hot paths. +func (s *Service) Latest() Snapshot { + s.mu.RLock() + defer s.mu.RUnlock() + return s.snapshot +} + +// Run loops on a ticker, refreshing the cache. Returns immediately when +// the feature is disabled. Exits cleanly on ctx.Done(). +func (s *Service) Run(ctx context.Context) { + if !s.cfg.Enabled { + s.logger.Info("version check disabled (CIX_VERSION_CHECK_ENABLED=false)") + return + } + if s.cfg.Repo == "" { + s.logger.Warn("version check enabled but CIX_VERSION_CHECK_REPO is empty — disabling") + return + } + + if s.cfg.InitialDelay > 0 { + select { + case <-ctx.Done(): + return + case <-time.After(s.cfg.InitialDelay): + } + } + + if err := s.CheckNow(ctx); err != nil { + s.logger.Warn("initial version check failed", "err", err) + } + + if s.cfg.Interval <= 0 { + return // one-shot mode (mainly for tests) + } + ticker := time.NewTicker(s.cfg.Interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.CheckNow(ctx); err != nil { + s.logger.Warn("version check failed", "err", err) + } + } + } +} + +// CheckNow performs a single GitHub poll and updates the cache. +// Returns the error so callers (including Run) can log it; the cache +// already records the error in LastError on failure. +func (s *Service) CheckNow(ctx context.Context) error { + url := fmt.Sprintf("%s/repos/%s/releases?per_page=30", strings.TrimRight(s.cfg.BaseURL, "/"), s.cfg.Repo) + reqCtx, cancel := context.WithTimeout(ctx, s.cfg.HTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) + if err != nil { + s.recordError(fmt.Errorf("build request: %w", err)) + return err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", s.cfg.UserAgent) + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + s.mu.RLock() + if s.etag != "" { + req.Header.Set("If-None-Match", s.etag) + } + s.mu.RUnlock() + + resp, err := s.client.Do(req) + if err != nil { + s.recordError(fmt.Errorf("github request: %w", err)) + return err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusNotModified: + // Cached snapshot still valid. Bump CheckedAt and clear any + // previous error so callers see "we tried, all good" rather + // than a stale failure. + s.mu.Lock() + s.snapshot.CheckedAt = time.Now().UTC() + s.snapshot.LastError = "" + s.mu.Unlock() + return nil + case http.StatusOK: + // fall through + default: + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + err := fmt.Errorf("github status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + s.recordError(err) + return err + } + + var releases []githubRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + s.recordError(fmt.Errorf("decode releases: %w", err)) + return err + } + + latestTag, htmlURL := pickLatest(releases, s.cfg.TagPrefix) + if latestTag == "" { + s.recordError(fmt.Errorf("no releases matching prefix %q", s.cfg.TagPrefix)) + return nil + } + + etag := resp.Header.Get("ETag") + updateAvail := isNewer(s.cfg.CurrentVersion, latestTag) + + s.mu.Lock() + s.etag = etag + s.snapshot = Snapshot{ + Enabled: true, + CurrentVersion: s.cfg.CurrentVersion, + LatestVersion: latestTag, + UpdateAvailable: updateAvail, + ReleaseURL: htmlURL, + CheckedAt: time.Now().UTC(), + LastError: "", + } + s.mu.Unlock() + return nil +} + +func (s *Service) recordError(err error) { + s.mu.Lock() + s.snapshot.CheckedAt = time.Now().UTC() + s.snapshot.LastError = err.Error() + s.mu.Unlock() +} + +// githubRelease is the subset of the GitHub Release schema we care about. +// Full schema: https://docs.github.com/en/rest/releases/releases +type githubRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` +} + +// pickLatest filters releases by tag prefix, drops drafts/prereleases, and +// returns the highest-semver tag (with prefix stripped) plus its release URL. +// Empty result means no matching release. +func pickLatest(rels []githubRelease, prefix string) (tag, url string) { + var bestTag, bestURL string + for _, r := range rels { + if r.Draft || r.Prerelease { + continue + } + if !strings.HasPrefix(r.TagName, prefix) { + continue + } + v := strings.TrimPrefix(r.TagName, prefix) + // Defensive: drop anything that still looks like a prerelease + // (`-rc1`, `-beta`) even if GitHub didn't flag it. + if strings.ContainsAny(v, "-+") { + continue + } + if bestTag == "" || compareSemver(v, bestTag) > 0 { + bestTag, bestURL = v, r.HTMLURL + } + } + return bestTag, bestURL +} + +// isNewer reports whether `latest` is a strictly newer semver than `current`. +// Treats unparseable / dev / empty current as "anything is newer". +func isNewer(current, latest string) bool { + if latest == "" { + return false + } + cur := strings.TrimPrefix(current, "v") + if cur == "" || strings.Contains(cur, "-dev") || !looksNumeric(cur) { + // dev / unknown build → always offer the upgrade + return true + } + return compareSemver(latest, cur) > 0 +} + +// compareSemver compares two `MAJOR.MINOR.PATCH` strings numerically. +// Returns -1, 0, 1. Non-numeric components compare lexicographically as a +// safety fallback (shouldn't happen given pickLatest's filter). +func compareSemver(a, b string) int { + pa := strings.Split(a, ".") + pb := strings.Split(b, ".") + n := len(pa) + if len(pb) > n { + n = len(pb) + } + for i := 0; i < n; i++ { + ai, aOK := 0, true + bi, bOK := 0, true + if i < len(pa) { + ai, aOK = atoi(pa[i]) + } + if i < len(pb) { + bi, bOK = atoi(pb[i]) + } + if aOK && bOK { + if ai != bi { + if ai < bi { + return -1 + } + return 1 + } + continue + } + // Lexicographic fallback when at least one component is non-numeric. + var as, bs string + if i < len(pa) { + as = pa[i] + } + if i < len(pb) { + bs = pb[i] + } + if as != bs { + if as < bs { + return -1 + } + return 1 + } + } + return 0 +} + +func atoi(s string) (int, bool) { + n, err := strconv.Atoi(s) + if err != nil { + return 0, false + } + return n, true +} + +func looksNumeric(v string) bool { + for _, p := range strings.Split(v, ".") { + if _, ok := atoi(p); !ok { + return false + } + } + return true +} diff --git a/server/internal/versioncheck/check_test.go b/server/internal/versioncheck/check_test.go new file mode 100644 index 0000000..fdd57fc --- /dev/null +++ b/server/internal/versioncheck/check_test.go @@ -0,0 +1,266 @@ +package versioncheck + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +// fakeGitHub spins up an httptest.Server that mimics the +// /repos/{owner}/{repo}/releases endpoint. The handler closure can +// flip behavior between calls (e.g. to return 304 the second time). +func fakeGitHub(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return srv +} + +func mustEncode(t *testing.T, w http.ResponseWriter, body any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(body); err != nil { + t.Fatalf("encode: %v", err) + } +} + +func newTestService(t *testing.T, baseURL, current string) *Service { + t.Helper() + return New(Config{ + Enabled: true, + Interval: 0, // one-shot + InitialDelay: 0, + BaseURL: baseURL, + Repo: "owner/repo", + TagPrefix: "server/v", + CurrentVersion: current, + HTTPTimeout: 2 * time.Second, + UserAgent: "cix-server-test", + }, nil) +} + +func TestPicksLatestServerTagSkippingCli(t *testing.T) { + srv := fakeGitHub(t, func(w http.ResponseWriter, r *http.Request) { + mustEncode(t, w, []githubRelease{ + {TagName: "cli/v0.5.0", HTMLURL: "u-cli", Prerelease: false}, + {TagName: "server/v0.4.0", HTMLURL: "u-server-040", Prerelease: false}, + {TagName: "server/v0.5.1", HTMLURL: "u-server-051", Prerelease: false}, + {TagName: "server/v0.5.0", HTMLURL: "u-server-050", Prerelease: false}, + }) + }) + + s := newTestService(t, srv.URL, "0.5.0") + if err := s.CheckNow(context.Background()); err != nil { + t.Fatalf("CheckNow: %v", err) + } + snap := s.Latest() + if snap.LatestVersion != "0.5.1" { + t.Errorf("LatestVersion = %q, want 0.5.1", snap.LatestVersion) + } + if !snap.UpdateAvailable { + t.Errorf("UpdateAvailable = false, want true (current=0.5.0, latest=0.5.1)") + } + if snap.ReleaseURL != "u-server-051" { + t.Errorf("ReleaseURL = %q, want u-server-051", snap.ReleaseURL) + } + if snap.LastError != "" { + t.Errorf("LastError = %q, want empty", snap.LastError) + } +} + +func TestSkipsPrereleaseAndDraft(t *testing.T) { + srv := fakeGitHub(t, func(w http.ResponseWriter, r *http.Request) { + mustEncode(t, w, []githubRelease{ + {TagName: "server/v0.6.0", Prerelease: true, HTMLURL: "u-pre"}, + {TagName: "server/v0.5.9", Draft: true, HTMLURL: "u-draft"}, + {TagName: "server/v0.5.1", HTMLURL: "u-good"}, + }) + }) + + s := newTestService(t, srv.URL, "0.5.0") + if err := s.CheckNow(context.Background()); err != nil { + t.Fatalf("CheckNow: %v", err) + } + snap := s.Latest() + if snap.LatestVersion != "0.5.1" { + t.Errorf("LatestVersion = %q, want 0.5.1 (prereleases/drafts must be skipped)", snap.LatestVersion) + } +} + +func TestNoUpdateWhenCurrentIsLatest(t *testing.T) { + srv := fakeGitHub(t, func(w http.ResponseWriter, r *http.Request) { + mustEncode(t, w, []githubRelease{ + {TagName: "server/v0.5.1", HTMLURL: "u-051"}, + }) + }) + + s := newTestService(t, srv.URL, "0.5.1") + if err := s.CheckNow(context.Background()); err != nil { + t.Fatalf("CheckNow: %v", err) + } + snap := s.Latest() + if snap.LatestVersion != "0.5.1" { + t.Errorf("LatestVersion = %q, want 0.5.1", snap.LatestVersion) + } + if snap.UpdateAvailable { + t.Errorf("UpdateAvailable = true, want false (running latest)") + } +} + +func TestETagRevalidation(t *testing.T) { + var calls atomic.Int32 + srv := fakeGitHub(t, func(w http.ResponseWriter, r *http.Request) { + n := calls.Add(1) + // On first call, return data + ETag. On second call, expect + // If-None-Match and respond 304. + if n == 1 { + w.Header().Set("ETag", `"abc123"`) + mustEncode(t, w, []githubRelease{ + {TagName: "server/v0.6.0", HTMLURL: "u-060"}, + }) + return + } + if got := r.Header.Get("If-None-Match"); got != `"abc123"` { + t.Errorf("expected If-None-Match=abc123 on second call, got %q", got) + } + w.WriteHeader(http.StatusNotModified) + }) + + s := newTestService(t, srv.URL, "0.5.0") + if err := s.CheckNow(context.Background()); err != nil { + t.Fatalf("first CheckNow: %v", err) + } + first := s.Latest() + if first.LatestVersion != "0.6.0" { + t.Fatalf("first.LatestVersion = %q, want 0.6.0", first.LatestVersion) + } + firstChecked := first.CheckedAt + time.Sleep(2 * time.Millisecond) // ensure CheckedAt advances + + if err := s.CheckNow(context.Background()); err != nil { + t.Fatalf("second CheckNow: %v", err) + } + second := s.Latest() + if second.LatestVersion != "0.6.0" { + t.Errorf("second.LatestVersion = %q, want 0.6.0 (304 must preserve cache)", second.LatestVersion) + } + if !second.CheckedAt.After(firstChecked) { + t.Errorf("CheckedAt did not advance on 304: first=%v second=%v", firstChecked, second.CheckedAt) + } + if calls.Load() != 2 { + t.Errorf("expected 2 GitHub calls, got %d", calls.Load()) + } +} + +func TestServerErrorPreservesCache(t *testing.T) { + var calls atomic.Int32 + srv := fakeGitHub(t, func(w http.ResponseWriter, r *http.Request) { + n := calls.Add(1) + if n == 1 { + mustEncode(t, w, []githubRelease{ + {TagName: "server/v0.5.1", HTMLURL: "u-051"}, + }) + return + } + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"boom"}`)) + }) + + s := newTestService(t, srv.URL, "0.5.0") + if err := s.CheckNow(context.Background()); err != nil { + t.Fatalf("first CheckNow: %v", err) + } + if err := s.CheckNow(context.Background()); err == nil { + t.Fatalf("second CheckNow: expected error, got nil") + } + snap := s.Latest() + if snap.LatestVersion != "0.5.1" { + t.Errorf("LatestVersion = %q, want 0.5.1 (cache must survive 5xx)", snap.LatestVersion) + } + if snap.LastError == "" { + t.Errorf("LastError empty, want populated on 5xx") + } +} + +func TestDisabledServiceDoesNotCallGitHub(t *testing.T) { + var calls atomic.Int32 + srv := fakeGitHub(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + mustEncode(t, w, []githubRelease{}) + }) + + s := New(Config{ + Enabled: false, + Interval: 10 * time.Millisecond, + BaseURL: srv.URL, + Repo: "owner/repo", + CurrentVersion: "0.5.0", + HTTPTimeout: 2 * time.Second, + }, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + s.Run(ctx) // returns ~immediately + + if calls.Load() != 0 { + t.Errorf("disabled service made %d GitHub calls, want 0", calls.Load()) + } + if s.Latest().Enabled { + t.Errorf("Latest().Enabled = true, want false") + } +} + +func TestDevBuildAlwaysSeesUpdate(t *testing.T) { + srv := fakeGitHub(t, func(w http.ResponseWriter, r *http.Request) { + mustEncode(t, w, []githubRelease{ + {TagName: "server/v0.1.0", HTMLURL: "u-010"}, + }) + }) + + s := newTestService(t, srv.URL, "0.0.0-dev") + if err := s.CheckNow(context.Background()); err != nil { + t.Fatalf("CheckNow: %v", err) + } + if !s.Latest().UpdateAvailable { + t.Errorf("UpdateAvailable = false, want true on dev build") + } +} + +func TestCompareSemverEdgeCases(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"0.5.0", "0.5.0", 0}, + {"0.5.1", "0.5.0", 1}, + {"0.5.0", "0.5.1", -1}, + {"0.10.0", "0.9.0", 1}, // numeric, not lexicographic + {"1.0.0", "0.99.99", 1}, + {"0.5", "0.5.0", 0}, // missing component treated as 0 + } + for _, c := range cases { + got := compareSemver(c.a, c.b) + if got != c.want { + t.Errorf("compareSemver(%q, %q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +func TestNetworkErrorPopulatesLastError(t *testing.T) { + // Point at an unroutable address; the request must fail before timeout. + s := New(Config{ + Enabled: true, + BaseURL: "http://127.0.0.1:1", // closed port + Repo: "owner/repo", + CurrentVersion: "0.5.0", + HTTPTimeout: 250 * time.Millisecond, + }, nil) + _ = s.CheckNow(context.Background()) + if s.Latest().LastError == "" { + t.Errorf("LastError empty, want populated on network failure") + } +}