tui: show 'update available' banner at top of chat
- internal/agent/update.go: check github releases api for a newer tag than the compiled-in version, cached in $ZOT_HOME/update-check.json with a 12h ttl so startup never hits the network twice. honours $GITHUB_TOKEN for the window while the repo is private; falls back to silent no-op on any failure. skipped entirely on dev builds (version = 0.0.0 or dev) - internal/agent/modes/update_banner.go: yellow-framed block with the new version, the current version, the one-liner install command appropriate for the platform, and a link to the release page. rendered above the welcome / transcript so it's the first thing the user sees - wired through via InteractiveConfig.UpdateInfoChan to avoid an import cycle (modes -> agent). cli.go kicks off the check async and feeds the result in note: no 'dismiss' key yet \u2014 the banner stays until you update to the shown version. if the nagging gets annoying we can add a per-version dismiss cache later.
- commit
- 8e546bde705d844abf1d453887bdf49494d9513a
- author
- patriceckhart · · 2026-04-18 09:49 UTC
- parent
- 728cf4e
- signature
- unsigned
- refs
- tag: v0.0.5
5 files · +368 -20
M internal/agent/cli.go +37 -19
| @@ -199,26 +199,44 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 199 | 199 | } | |
| 200 | 200 | ||
| 201 | 201 | term := tui.NewProcTerm() | |
| 202 | + | ||
| 203 | + | // Kick off the async update check so the banner can appear when the | |
| 204 | + | // http response eventually arrives (usually <1s on cached DNS). Map | |
| 205 | + | // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import. | |
| 206 | + | updateCh := make(chan modes.UpdateInfo, 1) | |
| 207 | + | go func() { | |
| 208 | + | defer close(updateCh) | |
| 209 | + | src := <-CheckForUpdateAsync(ZotHome(), version) | |
| 210 | + | updateCh <- modes.UpdateInfo{ | |
| 211 | + | Current: src.Current, | |
| 212 | + | Latest: src.Latest, | |
| 213 | + | Available: src.Available, | |
| 214 | + | URL: src.URL, | |
| 215 | + | } | |
| 216 | + | }() | |
| 217 | + | ||
| 202 | 218 | iv := modes.NewInteractive(modes.InteractiveConfig{ | |
| 203 | - | Terminal: term, | |
| 204 | - | Theme: tui.Dark, | |
| 205 | - | Model: r.Model, | |
| 206 | - | Provider: r.Provider, | |
| 207 | - | AuthMethod: r.AuthMethod, | |
| 208 | - | BaseURL: r.BaseURL, | |
| 209 | - | Reasoning: r.Reasoning, | |
| 210 | - | SystemPrompt: r.SystemPrompt, | |
| 211 | - | Tools: r.ToolRegistry, | |
| 212 | - | MaxSteps: r.MaxSteps, | |
| 213 | - | CWD: r.CWD, | |
| 214 | - | ZotHome: ZotHome(), | |
| 215 | - | Sandbox: sharedSandbox, | |
| 216 | - | Agent: ag, | |
| 217 | - | InitialInput: args.Prompt, | |
| 218 | - | AuthManager: mgr, | |
| 219 | - | BuildAgent: buildAgent, | |
| 220 | - | BuildAgentFor: buildAgentFor, | |
| 221 | - | LoadSession: loadSession, | |
| 219 | + | Terminal: term, | |
| 220 | + | Theme: tui.Dark, | |
| 221 | + | Model: r.Model, | |
| 222 | + | Provider: r.Provider, | |
| 223 | + | AuthMethod: r.AuthMethod, | |
| 224 | + | BaseURL: r.BaseURL, | |
| 225 | + | Reasoning: r.Reasoning, | |
| 226 | + | SystemPrompt: r.SystemPrompt, | |
| 227 | + | Tools: r.ToolRegistry, | |
| 228 | + | MaxSteps: r.MaxSteps, | |
| 229 | + | CWD: r.CWD, | |
| 230 | + | ZotHome: ZotHome(), | |
| 231 | + | Version: version, | |
| 232 | + | UpdateInfoChan: updateCh, | |
| 233 | + | Sandbox: sharedSandbox, | |
| 234 | + | Agent: ag, | |
| 235 | + | InitialInput: args.Prompt, | |
| 236 | + | AuthManager: mgr, | |
| 237 | + | BuildAgent: buildAgent, | |
| 238 | + | BuildAgentFor: buildAgentFor, | |
| 239 | + | LoadSession: loadSession, | |
| 222 | 240 | PersistModel: func(providerName, model string) { | |
| 223 | 241 | // Update config.json so next launch uses the same pick. | |
| 224 | 242 | cfg, _ := LoadConfig() | |
| @@ -199,26 +199,44 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 199 | } | 199 | } | ||
| 200 | 200 | ||||
| 201 | term := tui.NewProcTerm() | 201 | term := tui.NewProcTerm() | ||
| 202 | + | ||||
| 203 | + | // Kick off the async update check so the banner can appear when the | |||
| 204 | + | // http response eventually arrives (usually <1s on cached DNS). Map | |||
| 205 | + | // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import. | |||
| 206 | + | updateCh := make(chan modes.UpdateInfo, 1) | |||
| 207 | + | go func() { | |||
| 208 | + | defer close(updateCh) | |||
| 209 | + | src := <-CheckForUpdateAsync(ZotHome(), version) | |||
| 210 | + | updateCh <- modes.UpdateInfo{ | |||
| 211 | + | Current: src.Current, | |||
| 212 | + | Latest: src.Latest, | |||
| 213 | + | Available: src.Available, | |||
| 214 | + | URL: src.URL, | |||
| 215 | + | } | |||
| 216 | + | }() | |||
| 217 | + | ||||
| 202 | iv := modes.NewInteractive(modes.InteractiveConfig{ | 218 | iv := modes.NewInteractive(modes.InteractiveConfig{ | ||
| 203 | - | Terminal: term, | 219 | + | Terminal: term, |
| 204 | - | Theme: tui.Dark, | 220 | + | Theme: tui.Dark, |
| 205 | - | Model: r.Model, | 221 | + | Model: r.Model, |
| 206 | - | Provider: r.Provider, | 222 | + | Provider: r.Provider, |
| 207 | - | AuthMethod: r.AuthMethod, | 223 | + | AuthMethod: r.AuthMethod, |
| 208 | - | BaseURL: r.BaseURL, | 224 | + | BaseURL: r.BaseURL, |
| 209 | - | Reasoning: r.Reasoning, | 225 | + | Reasoning: r.Reasoning, |
| 210 | - | SystemPrompt: r.SystemPrompt, | 226 | + | SystemPrompt: r.SystemPrompt, |
| 211 | - | Tools: r.ToolRegistry, | 227 | + | Tools: r.ToolRegistry, |
| 212 | - | MaxSteps: r.MaxSteps, | 228 | + | MaxSteps: r.MaxSteps, |
| 213 | - | CWD: r.CWD, | 229 | + | CWD: r.CWD, |
| 214 | - | ZotHome: ZotHome(), | 230 | + | ZotHome: ZotHome(), |
| 215 | - | Sandbox: sharedSandbox, | 231 | + | Version: version, |
| 216 | - | Agent: ag, | 232 | + | UpdateInfoChan: updateCh, |
| 217 | - | InitialInput: args.Prompt, | 233 | + | Sandbox: sharedSandbox, |
| 218 | - | AuthManager: mgr, | 234 | + | Agent: ag, |
| 219 | - | BuildAgent: buildAgent, | 235 | + | InitialInput: args.Prompt, |
| 220 | - | BuildAgentFor: buildAgentFor, | 236 | + | AuthManager: mgr, |
| 221 | - | LoadSession: loadSession, | 237 | + | BuildAgent: buildAgent, |
| 238 | + | BuildAgentFor: buildAgentFor, | |||
| 239 | + | LoadSession: loadSession, | |||
| 222 | PersistModel: func(providerName, model string) { | 240 | PersistModel: func(providerName, model string) { | ||
| 223 | // Update config.json so next launch uses the same pick. | 241 | // Update config.json so next launch uses the same pick. | ||
| 224 | cfg, _ := LoadConfig() | 242 | cfg, _ := LoadConfig() | ||
M internal/agent/modes/dialog_frame.go +23 -0
| @@ -30,3 +30,26 @@ func frameRule(th tui.Theme, width int) string { | |||
| 30 | 30 | } | |
| 31 | 31 | return th.FG256(th.Muted, strings.Repeat("─", width)) | |
| 32 | 32 | } | |
| 33 | + | ||
| 34 | + | // frameHeaderColor is like frameHeader but renders in a caller-supplied | |
| 35 | + | // 256-color code. Used by the update-available banner which wants a | |
| 36 | + | // yellow accent on the rules and title. | |
| 37 | + | func frameHeaderColor(th tui.Theme, title string, width, color int) string { | |
| 38 | + | label := "── " + title + " " | |
| 39 | + | if width <= 0 { | |
| 40 | + | return th.FG256(color, label) | |
| 41 | + | } | |
| 42 | + | padLen := width - len(label) | |
| 43 | + | if padLen < 0 { | |
| 44 | + | padLen = 0 | |
| 45 | + | } | |
| 46 | + | return th.FG256(color, label+strings.Repeat("─", padLen)) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // frameRuleColor is like frameRule in an explicit color. | |
| 50 | + | func frameRuleColor(th tui.Theme, width, color int) string { | |
| 51 | + | if width <= 0 { | |
| 52 | + | width = 1 | |
| 53 | + | } | |
| 54 | + | return th.FG256(color, strings.Repeat("─", width)) | |
| 55 | + | } | |
| @@ -30,3 +30,26 @@ func frameRule(th tui.Theme, width int) string { | |||||
| 30 | } | 30 | } | ||
| 31 | return th.FG256(th.Muted, strings.Repeat("─", width)) | 31 | return th.FG256(th.Muted, strings.Repeat("─", width)) | ||
| 32 | } | 32 | } | ||
| 33 | + | ||||
| 34 | + | // frameHeaderColor is like frameHeader but renders in a caller-supplied | |||
| 35 | + | // 256-color code. Used by the update-available banner which wants a | |||
| 36 | + | // yellow accent on the rules and title. | |||
| 37 | + | func frameHeaderColor(th tui.Theme, title string, width, color int) string { | |||
| 38 | + | label := "── " + title + " " | |||
| 39 | + | if width <= 0 { | |||
| 40 | + | return th.FG256(color, label) | |||
| 41 | + | } | |||
| 42 | + | padLen := width - len(label) | |||
| 43 | + | if padLen < 0 { | |||
| 44 | + | padLen = 0 | |||
| 45 | + | } | |||
| 46 | + | return th.FG256(color, label+strings.Repeat("─", padLen)) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // frameRuleColor is like frameRule in an explicit color. | |||
| 50 | + | func frameRuleColor(th tui.Theme, width, color int) string { | |||
| 51 | + | if width <= 0 { | |||
| 52 | + | width = 1 | |||
| 53 | + | } | |||
| 54 | + | return th.FG256(color, strings.Repeat("─", width)) | |||
| 55 | + | } | |||
M internal/agent/modes/interactive.go +38 -1
| @@ -48,9 +48,22 @@ type InteractiveConfig struct { | |||
| 48 | 48 | // If providerOverride is empty, the current provider is kept. | |
| 49 | 49 | BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error) | |
| 50 | 50 | ||
| 51 | - | // ZotHome is the root directory for sessions/, used by /sessions. | |
| 51 | + | // ZotHome is the root directory for sessions/, used by /sessions | |
| 52 | + | // and the update-check cache. | |
| 52 | 53 | ZotHome string | |
| 53 | 54 | ||
| 55 | + | // Version is the binary's current version (from main.version). | |
| 56 | + | // Used only for display; the update check itself is done outside | |
| 57 | + | // this package to avoid an import cycle. | |
| 58 | + | Version string | |
| 59 | + | ||
| 60 | + | // UpdateInfoChan is an optional channel that delivers the result | |
| 61 | + | // of the github-release update check. Interactive reads at most | |
| 62 | + | // one value, drops it if the check reported nothing, and otherwise | |
| 63 | + | // surfaces a yellow "update available" banner at the top of the | |
| 64 | + | // chat. Nil channel = no banner, no startup cost. | |
| 65 | + | UpdateInfoChan <-chan UpdateInfo | |
| 66 | + | ||
| 54 | 67 | // Sandbox is the shared sandbox pointer. Toggled by /lock and /unlock. | |
| 55 | 68 | Sandbox *tools.Sandbox | |
| 56 | 69 | ||
| @@ -105,6 +118,10 @@ type Interactive struct { | |||
| 105 | 118 | // condense pass from a regular assistant turn. | |
| 106 | 119 | autoCompacting bool | |
| 107 | 120 | ||
| 121 | + | // updateInfo is the result of the async update check. Zero value | |
| 122 | + | // while the check hasn't completed or nothing is available. | |
| 123 | + | updateInfo UpdateInfo | |
| 124 | + | ||
| 108 | 125 | dialog *loginDialog | |
| 109 | 126 | modelDialog *modelDialog | |
| 110 | 127 | sessionDialog *sessionDialog | |
| @@ -241,6 +258,8 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 241 | 258 | ||
| 242 | 259 | i.invalidate() | |
| 243 | 260 | ||
| 261 | + | updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select | |
| 262 | + | ||
| 244 | 263 | for { | |
| 245 | 264 | select { | |
| 246 | 265 | case <-ctx.Done(): | |
| @@ -253,6 +272,14 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 253 | 272 | case ev := <-authEvents: | |
| 254 | 273 | i.handleAuthEvent(ev) | |
| 255 | 274 | i.invalidate() | |
| 275 | + | case info, ok := <-updates: | |
| 276 | + | if ok && info.Available { | |
| 277 | + | i.mu.Lock() | |
| 278 | + | i.updateInfo = info | |
| 279 | + | i.mu.Unlock() | |
| 280 | + | i.invalidate() | |
| 281 | + | } | |
| 282 | + | updates = nil // single-shot; subsequent iterations skip this case | |
| 256 | 283 | case <-i.dirty: | |
| 257 | 284 | requestRedraw() | |
| 258 | 285 | case <-tick.C: | |
| @@ -349,6 +376,16 @@ func (i *Interactive) redraw() { | |||
| 349 | 376 | chat = append(welcomeBanner(i.cfg.Theme), chat...) | |
| 350 | 377 | } | |
| 351 | 378 | ||
| 379 | + | // Update-available banner: prepended above everything else so it's | |
| 380 | + | // the first thing the user sees when opening a new zot session. | |
| 381 | + | // Once rendered, it stays until the user updates to a newer | |
| 382 | + | // version — we don't persist a "dismissed" flag because this is | |
| 383 | + | // cheap and re-showing it is how most users remember to update. | |
| 384 | + | if i.updateInfo.Available { | |
| 385 | + | banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols) | |
| 386 | + | chat = append(banner, chat...) | |
| 387 | + | } | |
| 388 | + | ||
| 352 | 389 | // /help block: appended to the transcript so it appears at the | |
| 353 | 390 | // bottom of the chat area (right above the status bar / editor). | |
| 354 | 391 | // Prepending it would push long conversations off the top of the | |
| @@ -48,9 +48,22 @@ type InteractiveConfig struct { | |||||
| 48 | // If providerOverride is empty, the current provider is kept. | 48 | // If providerOverride is empty, the current provider is kept. | ||
| 49 | BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error) | 49 | BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error) | ||
| 50 | 50 | ||||
| 51 | - | // ZotHome is the root directory for sessions/, used by /sessions. | 51 | + | // ZotHome is the root directory for sessions/, used by /sessions |
| 52 | + | // and the update-check cache. | |||
| 52 | ZotHome string | 53 | ZotHome string | ||
| 53 | 54 | ||||
| 55 | + | // Version is the binary's current version (from main.version). | |||
| 56 | + | // Used only for display; the update check itself is done outside | |||
| 57 | + | // this package to avoid an import cycle. | |||
| 58 | + | Version string | |||
| 59 | + | ||||
| 60 | + | // UpdateInfoChan is an optional channel that delivers the result | |||
| 61 | + | // of the github-release update check. Interactive reads at most | |||
| 62 | + | // one value, drops it if the check reported nothing, and otherwise | |||
| 63 | + | // surfaces a yellow "update available" banner at the top of the | |||
| 64 | + | // chat. Nil channel = no banner, no startup cost. | |||
| 65 | + | UpdateInfoChan <-chan UpdateInfo | |||
| 66 | + | ||||
| 54 | // Sandbox is the shared sandbox pointer. Toggled by /lock and /unlock. | 67 | // Sandbox is the shared sandbox pointer. Toggled by /lock and /unlock. | ||
| 55 | Sandbox *tools.Sandbox | 68 | Sandbox *tools.Sandbox | ||
| 56 | 69 | ||||
| @@ -105,6 +118,10 @@ type Interactive struct { | |||||
| 105 | // condense pass from a regular assistant turn. | 118 | // condense pass from a regular assistant turn. | ||
| 106 | autoCompacting bool | 119 | autoCompacting bool | ||
| 107 | 120 | ||||
| 121 | + | // updateInfo is the result of the async update check. Zero value | |||
| 122 | + | // while the check hasn't completed or nothing is available. | |||
| 123 | + | updateInfo UpdateInfo | |||
| 124 | + | ||||
| 108 | dialog *loginDialog | 125 | dialog *loginDialog | ||
| 109 | modelDialog *modelDialog | 126 | modelDialog *modelDialog | ||
| 110 | sessionDialog *sessionDialog | 127 | sessionDialog *sessionDialog | ||
| @@ -241,6 +258,8 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 241 | 258 | ||||
| 242 | i.invalidate() | 259 | i.invalidate() | ||
| 243 | 260 | ||||
| 261 | + | updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select | |||
| 262 | + | ||||
| 244 | for { | 263 | for { | ||
| 245 | select { | 264 | select { | ||
| 246 | case <-ctx.Done(): | 265 | case <-ctx.Done(): | ||
| @@ -253,6 +272,14 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 253 | case ev := <-authEvents: | 272 | case ev := <-authEvents: | ||
| 254 | i.handleAuthEvent(ev) | 273 | i.handleAuthEvent(ev) | ||
| 255 | i.invalidate() | 274 | i.invalidate() | ||
| 275 | + | case info, ok := <-updates: | |||
| 276 | + | if ok && info.Available { | |||
| 277 | + | i.mu.Lock() | |||
| 278 | + | i.updateInfo = info | |||
| 279 | + | i.mu.Unlock() | |||
| 280 | + | i.invalidate() | |||
| 281 | + | } | |||
| 282 | + | updates = nil // single-shot; subsequent iterations skip this case | |||
| 256 | case <-i.dirty: | 283 | case <-i.dirty: | ||
| 257 | requestRedraw() | 284 | requestRedraw() | ||
| 258 | case <-tick.C: | 285 | case <-tick.C: | ||
| @@ -349,6 +376,16 @@ func (i *Interactive) redraw() { | |||||
| 349 | chat = append(welcomeBanner(i.cfg.Theme), chat...) | 376 | chat = append(welcomeBanner(i.cfg.Theme), chat...) | ||
| 350 | } | 377 | } | ||
| 351 | 378 | ||||
| 379 | + | // Update-available banner: prepended above everything else so it's | |||
| 380 | + | // the first thing the user sees when opening a new zot session. | |||
| 381 | + | // Once rendered, it stays until the user updates to a newer | |||
| 382 | + | // version — we don't persist a "dismissed" flag because this is | |||
| 383 | + | // cheap and re-showing it is how most users remember to update. | |||
| 384 | + | if i.updateInfo.Available { | |||
| 385 | + | banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols) | |||
| 386 | + | chat = append(banner, chat...) | |||
| 387 | + | } | |||
| 388 | + | ||||
| 352 | // /help block: appended to the transcript so it appears at the | 389 | // /help block: appended to the transcript so it appears at the | ||
| 353 | // bottom of the chat area (right above the status bar / editor). | 390 | // bottom of the chat area (right above the status bar / editor). | ||
| 354 | // Prepending it would push long conversations off the top of the | 391 | // Prepending it would push long conversations off the top of the | ||
A internal/agent/modes/update_banner.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "runtime" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // UpdateInfo mirrors agent.UpdateInfo without the import cycle. The | |
| 11 | + | // parent package builds one of these via agent.CheckForUpdate and | |
| 12 | + | // passes it in through InteractiveConfig.UpdateInfoChan. | |
| 13 | + | type UpdateInfo struct { | |
| 14 | + | Current string | |
| 15 | + | Latest string | |
| 16 | + | Available bool | |
| 17 | + | URL string | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // renderUpdateBanner builds the "new version available" block shown at | |
| 21 | + | // the top of the chat area. Yellow-framed like a warning, but worded | |
| 22 | + | // gently since this is informational, not urgent. | |
| 23 | + | // | |
| 24 | + | // Returns nil when no update is available, so callers can just | |
| 25 | + | // append (or prepend) unconditionally. | |
| 26 | + | func renderUpdateBanner(th tui.Theme, info UpdateInfo, width int) []string { | |
| 27 | + | if !info.Available { | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | color := th.Warning | |
| 31 | + | out := []string{ | |
| 32 | + | frameHeaderColor(th, "update available", width, color), | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | title := fmt.Sprintf("zot %s is available (you're on %s).", info.Latest, info.Current) | |
| 36 | + | out = append(out, " "+th.FG256(color, tui.Bold(title))) | |
| 37 | + | ||
| 38 | + | cmd := recommendedUpdateCommand() | |
| 39 | + | out = append(out, " "+th.FG256(th.Muted, "run: ")+th.FG256(th.ToolOut, cmd)) | |
| 40 | + | ||
| 41 | + | if info.URL != "" { | |
| 42 | + | out = append(out, " "+th.FG256(th.Muted, "changelog: "+info.URL)) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | out = append(out, frameRuleColor(th, width, color)) | |
| 46 | + | out = append(out, "") | |
| 47 | + | return out | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // recommendedUpdateCommand returns the one-liner appropriate for the | |
| 51 | + | // user's platform. All three point at the public install scripts so a | |
| 52 | + | // single command updates in place. | |
| 53 | + | func recommendedUpdateCommand() string { | |
| 54 | + | if runtime.GOOS == "windows" { | |
| 55 | + | return `iwr -useb https://raw.githubusercontent.com/patriceckhart/zot/main/install.ps1 | iex` | |
| 56 | + | } | |
| 57 | + | return `curl -fsSL https://raw.githubusercontent.com/patriceckhart/zot/main/install.sh | bash` | |
| 58 | + | } | |
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "runtime" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/internal/tui" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // UpdateInfo mirrors agent.UpdateInfo without the import cycle. The | |||
| 11 | + | // parent package builds one of these via agent.CheckForUpdate and | |||
| 12 | + | // passes it in through InteractiveConfig.UpdateInfoChan. | |||
| 13 | + | type UpdateInfo struct { | |||
| 14 | + | Current string | |||
| 15 | + | Latest string | |||
| 16 | + | Available bool | |||
| 17 | + | URL string | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // renderUpdateBanner builds the "new version available" block shown at | |||
| 21 | + | // the top of the chat area. Yellow-framed like a warning, but worded | |||
| 22 | + | // gently since this is informational, not urgent. | |||
| 23 | + | // | |||
| 24 | + | // Returns nil when no update is available, so callers can just | |||
| 25 | + | // append (or prepend) unconditionally. | |||
| 26 | + | func renderUpdateBanner(th tui.Theme, info UpdateInfo, width int) []string { | |||
| 27 | + | if !info.Available { | |||
| 28 | + | return nil | |||
| 29 | + | } | |||
| 30 | + | color := th.Warning | |||
| 31 | + | out := []string{ | |||
| 32 | + | frameHeaderColor(th, "update available", width, color), | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | title := fmt.Sprintf("zot %s is available (you're on %s).", info.Latest, info.Current) | |||
| 36 | + | out = append(out, " "+th.FG256(color, tui.Bold(title))) | |||
| 37 | + | ||||
| 38 | + | cmd := recommendedUpdateCommand() | |||
| 39 | + | out = append(out, " "+th.FG256(th.Muted, "run: ")+th.FG256(th.ToolOut, cmd)) | |||
| 40 | + | ||||
| 41 | + | if info.URL != "" { | |||
| 42 | + | out = append(out, " "+th.FG256(th.Muted, "changelog: "+info.URL)) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | out = append(out, frameRuleColor(th, width, color)) | |||
| 46 | + | out = append(out, "") | |||
| 47 | + | return out | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // recommendedUpdateCommand returns the one-liner appropriate for the | |||
| 51 | + | // user's platform. All three point at the public install scripts so a | |||
| 52 | + | // single command updates in place. | |||
| 53 | + | func recommendedUpdateCommand() string { | |||
| 54 | + | if runtime.GOOS == "windows" { | |||
| 55 | + | return `iwr -useb https://raw.githubusercontent.com/patriceckhart/zot/main/install.ps1 | iex` | |||
| 56 | + | } | |||
| 57 | + | return `curl -fsSL https://raw.githubusercontent.com/patriceckhart/zot/main/install.sh | bash` | |||
| 58 | + | } | |||
A internal/agent/update.go +212 -0
| @@ -0,0 +1,212 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | "sync" | |
| 13 | + | "time" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // updateCheckTTL is how often we hit the GitHub API to look for a new | |
| 17 | + | // release. Half a day is frequent enough to notice the same day a | |
| 18 | + | // release ships without spamming the API on every zot launch. | |
| 19 | + | const updateCheckTTL = 12 * time.Hour | |
| 20 | + | ||
| 21 | + | // updateCheckFile is the on-disk cache, keyed to the current binary | |
| 22 | + | // version. Writes a JSON blob with the last observed latest tag and | |
| 23 | + | // when we checked, so we can skip the network call on most launches. | |
| 24 | + | const updateCheckFile = "update-check.json" | |
| 25 | + | ||
| 26 | + | // githubReleasesAPI is the REST endpoint we query. Using the API (not | |
| 27 | + | // the HTML redirect) because the JSON response is stable and small. | |
| 28 | + | const githubReleasesAPI = "https://api.github.com/repos/patriceckhart/zot/releases/latest" | |
| 29 | + | ||
| 30 | + | // UpdateInfo describes the result of an update check. Zero-value means | |
| 31 | + | // "no update available, no error, don't show anything". | |
| 32 | + | type UpdateInfo struct { | |
| 33 | + | Current string // e.g. "0.0.4" | |
| 34 | + | Latest string // e.g. "0.0.5" | |
| 35 | + | Available bool // true when latest > current | |
| 36 | + | URL string // release page url for the changelog link | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // updateCache is the on-disk structure written to $ZOT_HOME. | |
| 40 | + | type updateCache struct { | |
| 41 | + | CheckedAt time.Time `json:"checked_at"` | |
| 42 | + | // The version that was current when we last checked. Invalidates | |
| 43 | + | // the cache if the binary itself has been updated since. | |
| 44 | + | CurrentAt string `json:"current_at"` | |
| 45 | + | Latest string `json:"latest"` | |
| 46 | + | URL string `json:"url"` | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // CheckForUpdate returns info about a newer release, using a cached | |
| 50 | + | // result when one is fresh enough. Designed to be called at tui | |
| 51 | + | // startup and rendered as a dismissible banner. | |
| 52 | + | // | |
| 53 | + | // Always returns a usable UpdateInfo (zero-value on error). The | |
| 54 | + | // banner renderer skips the display when Available is false, so a | |
| 55 | + | // network failure silently no-ops; we never block startup on this. | |
| 56 | + | func CheckForUpdate(ctx context.Context, zotHome, currentVersion string) UpdateInfo { | |
| 57 | + | // Dev builds ("0.0.0") never have an update to offer. Skip. | |
| 58 | + | if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" { | |
| 59 | + | return UpdateInfo{} | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | cachePath := filepath.Join(zotHome, updateCheckFile) | |
| 63 | + | if c, ok := readUpdateCache(cachePath); ok { | |
| 64 | + | // Cache is fresh and tracks the same binary version. | |
| 65 | + | if time.Since(c.CheckedAt) < updateCheckTTL && c.CurrentAt == currentVersion { | |
| 66 | + | return buildInfo(currentVersion, c.Latest, c.URL) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | latest, url, err := fetchLatestRelease(ctx) | |
| 71 | + | if err != nil { | |
| 72 | + | // Network or auth failure (common while the repo is private | |
| 73 | + | // and no GITHUB_TOKEN is set). Silent no-op; we'll try again | |
| 74 | + | // after the TTL on the next launch. | |
| 75 | + | return UpdateInfo{} | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | _ = writeUpdateCache(cachePath, updateCache{ | |
| 79 | + | CheckedAt: time.Now().UTC(), | |
| 80 | + | CurrentAt: currentVersion, | |
| 81 | + | Latest: latest, | |
| 82 | + | URL: url, | |
| 83 | + | }) | |
| 84 | + | ||
| 85 | + | return buildInfo(currentVersion, latest, url) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // CheckForUpdateAsync runs CheckForUpdate in a goroutine, delivers the | |
| 89 | + | // result to the returned channel, and never blocks startup. The | |
| 90 | + | // channel is always closed; receivers should `ok`-check. | |
| 91 | + | func CheckForUpdateAsync(zotHome, currentVersion string) <-chan UpdateInfo { | |
| 92 | + | ch := make(chan UpdateInfo, 1) | |
| 93 | + | go func() { | |
| 94 | + | defer close(ch) | |
| 95 | + | ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) | |
| 96 | + | defer cancel() | |
| 97 | + | ch <- CheckForUpdate(ctx, zotHome, currentVersion) | |
| 98 | + | }() | |
| 99 | + | return ch | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func buildInfo(current, latest, url string) UpdateInfo { | |
| 103 | + | info := UpdateInfo{ | |
| 104 | + | Current: current, | |
| 105 | + | Latest: strings.TrimPrefix(latest, "v"), | |
| 106 | + | URL: url, | |
| 107 | + | } | |
| 108 | + | info.Available = versionLess(info.Current, info.Latest) | |
| 109 | + | return info | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // versionLess returns a < b for dotted semver-ish tags like "0.0.4". | |
| 113 | + | // Non-numeric components compare as zero, which is fine for zot's | |
| 114 | + | // x.y.z-only scheme. | |
| 115 | + | func versionLess(a, b string) bool { | |
| 116 | + | as := splitVersion(a) | |
| 117 | + | bs := splitVersion(b) | |
| 118 | + | for i := 0; i < 3; i++ { | |
| 119 | + | av, bv := 0, 0 | |
| 120 | + | if i < len(as) { | |
| 121 | + | av = as[i] | |
| 122 | + | } | |
| 123 | + | if i < len(bs) { | |
| 124 | + | bv = bs[i] | |
| 125 | + | } | |
| 126 | + | if av != bv { | |
| 127 | + | return av < bv | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | return false | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func splitVersion(s string) []int { | |
| 134 | + | s = strings.TrimPrefix(s, "v") | |
| 135 | + | // Strip build-metadata suffix like "(abc1234, 2026-04-18)". | |
| 136 | + | if i := strings.IndexAny(s, " ("); i > 0 { | |
| 137 | + | s = s[:i] | |
| 138 | + | } | |
| 139 | + | parts := strings.Split(s, ".") | |
| 140 | + | out := make([]int, 0, len(parts)) | |
| 141 | + | for _, p := range parts { | |
| 142 | + | n, _ := strconv.Atoi(p) | |
| 143 | + | out = append(out, n) | |
| 144 | + | } | |
| 145 | + | return out | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // fetchLatestRelease queries the GitHub API for the latest published | |
| 149 | + | // release. Honours $GITHUB_TOKEN for private repos. | |
| 150 | + | func fetchLatestRelease(ctx context.Context) (tag, url string, err error) { | |
| 151 | + | req, err := http.NewRequestWithContext(ctx, "GET", githubReleasesAPI, nil) | |
| 152 | + | if err != nil { | |
| 153 | + | return "", "", err | |
| 154 | + | } | |
| 155 | + | req.Header.Set("accept", "application/vnd.github+json") | |
| 156 | + | req.Header.Set("x-github-api-version", "2022-11-28") | |
| 157 | + | if tok := os.Getenv("GITHUB_TOKEN"); tok != "" { | |
| 158 | + | req.Header.Set("authorization", "Bearer "+tok) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | client := &http.Client{Timeout: 4 * time.Second} | |
| 162 | + | resp, err := client.Do(req) | |
| 163 | + | if err != nil { | |
| 164 | + | return "", "", err | |
| 165 | + | } | |
| 166 | + | defer resp.Body.Close() | |
| 167 | + | if resp.StatusCode != 200 { | |
| 168 | + | return "", "", fmt.Errorf("github api %d", resp.StatusCode) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | var body struct { | |
| 172 | + | TagName string `json:"tag_name"` | |
| 173 | + | HTMLURL string `json:"html_url"` | |
| 174 | + | } | |
| 175 | + | if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { | |
| 176 | + | return "", "", err | |
| 177 | + | } | |
| 178 | + | return body.TagName, body.HTMLURL, nil | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // readUpdateCache loads the last check result. Returns ok=false on | |
| 182 | + | // any error (missing file, bad json) so callers just treat it as a | |
| 183 | + | // cache miss and re-fetch. | |
| 184 | + | func readUpdateCache(path string) (updateCache, bool) { | |
| 185 | + | b, err := os.ReadFile(path) | |
| 186 | + | if err != nil { | |
| 187 | + | return updateCache{}, false | |
| 188 | + | } | |
| 189 | + | var c updateCache | |
| 190 | + | if err := json.Unmarshal(b, &c); err != nil { | |
| 191 | + | return updateCache{}, false | |
| 192 | + | } | |
| 193 | + | return c, true | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func writeUpdateCache(path string, c updateCache) error { | |
| 197 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 198 | + | return err | |
| 199 | + | } | |
| 200 | + | b, err := json.MarshalIndent(c, "", " ") | |
| 201 | + | if err != nil { | |
| 202 | + | return err | |
| 203 | + | } | |
| 204 | + | return os.WriteFile(path, b, 0o600) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // cachedLatest, cachedLatestOnce: used elsewhere in the binary if we | |
| 208 | + | // want a synchronous read without triggering a network call. | |
| 209 | + | var ( | |
| 210 | + | cachedLatest string | |
| 211 | + | cachedLatestOnce sync.Once | |
| 212 | + | ) | |
| @@ -0,0 +1,212 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/http" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "strconv" | |||
| 11 | + | "strings" | |||
| 12 | + | "sync" | |||
| 13 | + | "time" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // updateCheckTTL is how often we hit the GitHub API to look for a new | |||
| 17 | + | // release. Half a day is frequent enough to notice the same day a | |||
| 18 | + | // release ships without spamming the API on every zot launch. | |||
| 19 | + | const updateCheckTTL = 12 * time.Hour | |||
| 20 | + | ||||
| 21 | + | // updateCheckFile is the on-disk cache, keyed to the current binary | |||
| 22 | + | // version. Writes a JSON blob with the last observed latest tag and | |||
| 23 | + | // when we checked, so we can skip the network call on most launches. | |||
| 24 | + | const updateCheckFile = "update-check.json" | |||
| 25 | + | ||||
| 26 | + | // githubReleasesAPI is the REST endpoint we query. Using the API (not | |||
| 27 | + | // the HTML redirect) because the JSON response is stable and small. | |||
| 28 | + | const githubReleasesAPI = "https://api.github.com/repos/patriceckhart/zot/releases/latest" | |||
| 29 | + | ||||
| 30 | + | // UpdateInfo describes the result of an update check. Zero-value means | |||
| 31 | + | // "no update available, no error, don't show anything". | |||
| 32 | + | type UpdateInfo struct { | |||
| 33 | + | Current string // e.g. "0.0.4" | |||
| 34 | + | Latest string // e.g. "0.0.5" | |||
| 35 | + | Available bool // true when latest > current | |||
| 36 | + | URL string // release page url for the changelog link | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // updateCache is the on-disk structure written to $ZOT_HOME. | |||
| 40 | + | type updateCache struct { | |||
| 41 | + | CheckedAt time.Time `json:"checked_at"` | |||
| 42 | + | // The version that was current when we last checked. Invalidates | |||
| 43 | + | // the cache if the binary itself has been updated since. | |||
| 44 | + | CurrentAt string `json:"current_at"` | |||
| 45 | + | Latest string `json:"latest"` | |||
| 46 | + | URL string `json:"url"` | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // CheckForUpdate returns info about a newer release, using a cached | |||
| 50 | + | // result when one is fresh enough. Designed to be called at tui | |||
| 51 | + | // startup and rendered as a dismissible banner. | |||
| 52 | + | // | |||
| 53 | + | // Always returns a usable UpdateInfo (zero-value on error). The | |||
| 54 | + | // banner renderer skips the display when Available is false, so a | |||
| 55 | + | // network failure silently no-ops; we never block startup on this. | |||
| 56 | + | func CheckForUpdate(ctx context.Context, zotHome, currentVersion string) UpdateInfo { | |||
| 57 | + | // Dev builds ("0.0.0") never have an update to offer. Skip. | |||
| 58 | + | if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" { | |||
| 59 | + | return UpdateInfo{} | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | cachePath := filepath.Join(zotHome, updateCheckFile) | |||
| 63 | + | if c, ok := readUpdateCache(cachePath); ok { | |||
| 64 | + | // Cache is fresh and tracks the same binary version. | |||
| 65 | + | if time.Since(c.CheckedAt) < updateCheckTTL && c.CurrentAt == currentVersion { | |||
| 66 | + | return buildInfo(currentVersion, c.Latest, c.URL) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | latest, url, err := fetchLatestRelease(ctx) | |||
| 71 | + | if err != nil { | |||
| 72 | + | // Network or auth failure (common while the repo is private | |||
| 73 | + | // and no GITHUB_TOKEN is set). Silent no-op; we'll try again | |||
| 74 | + | // after the TTL on the next launch. | |||
| 75 | + | return UpdateInfo{} | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | _ = writeUpdateCache(cachePath, updateCache{ | |||
| 79 | + | CheckedAt: time.Now().UTC(), | |||
| 80 | + | CurrentAt: currentVersion, | |||
| 81 | + | Latest: latest, | |||
| 82 | + | URL: url, | |||
| 83 | + | }) | |||
| 84 | + | ||||
| 85 | + | return buildInfo(currentVersion, latest, url) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // CheckForUpdateAsync runs CheckForUpdate in a goroutine, delivers the | |||
| 89 | + | // result to the returned channel, and never blocks startup. The | |||
| 90 | + | // channel is always closed; receivers should `ok`-check. | |||
| 91 | + | func CheckForUpdateAsync(zotHome, currentVersion string) <-chan UpdateInfo { | |||
| 92 | + | ch := make(chan UpdateInfo, 1) | |||
| 93 | + | go func() { | |||
| 94 | + | defer close(ch) | |||
| 95 | + | ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) | |||
| 96 | + | defer cancel() | |||
| 97 | + | ch <- CheckForUpdate(ctx, zotHome, currentVersion) | |||
| 98 | + | }() | |||
| 99 | + | return ch | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func buildInfo(current, latest, url string) UpdateInfo { | |||
| 103 | + | info := UpdateInfo{ | |||
| 104 | + | Current: current, | |||
| 105 | + | Latest: strings.TrimPrefix(latest, "v"), | |||
| 106 | + | URL: url, | |||
| 107 | + | } | |||
| 108 | + | info.Available = versionLess(info.Current, info.Latest) | |||
| 109 | + | return info | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // versionLess returns a < b for dotted semver-ish tags like "0.0.4". | |||
| 113 | + | // Non-numeric components compare as zero, which is fine for zot's | |||
| 114 | + | // x.y.z-only scheme. | |||
| 115 | + | func versionLess(a, b string) bool { | |||
| 116 | + | as := splitVersion(a) | |||
| 117 | + | bs := splitVersion(b) | |||
| 118 | + | for i := 0; i < 3; i++ { | |||
| 119 | + | av, bv := 0, 0 | |||
| 120 | + | if i < len(as) { | |||
| 121 | + | av = as[i] | |||
| 122 | + | } | |||
| 123 | + | if i < len(bs) { | |||
| 124 | + | bv = bs[i] | |||
| 125 | + | } | |||
| 126 | + | if av != bv { | |||
| 127 | + | return av < bv | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | return false | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func splitVersion(s string) []int { | |||
| 134 | + | s = strings.TrimPrefix(s, "v") | |||
| 135 | + | // Strip build-metadata suffix like "(abc1234, 2026-04-18)". | |||
| 136 | + | if i := strings.IndexAny(s, " ("); i > 0 { | |||
| 137 | + | s = s[:i] | |||
| 138 | + | } | |||
| 139 | + | parts := strings.Split(s, ".") | |||
| 140 | + | out := make([]int, 0, len(parts)) | |||
| 141 | + | for _, p := range parts { | |||
| 142 | + | n, _ := strconv.Atoi(p) | |||
| 143 | + | out = append(out, n) | |||
| 144 | + | } | |||
| 145 | + | return out | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // fetchLatestRelease queries the GitHub API for the latest published | |||
| 149 | + | // release. Honours $GITHUB_TOKEN for private repos. | |||
| 150 | + | func fetchLatestRelease(ctx context.Context) (tag, url string, err error) { | |||
| 151 | + | req, err := http.NewRequestWithContext(ctx, "GET", githubReleasesAPI, nil) | |||
| 152 | + | if err != nil { | |||
| 153 | + | return "", "", err | |||
| 154 | + | } | |||
| 155 | + | req.Header.Set("accept", "application/vnd.github+json") | |||
| 156 | + | req.Header.Set("x-github-api-version", "2022-11-28") | |||
| 157 | + | if tok := os.Getenv("GITHUB_TOKEN"); tok != "" { | |||
| 158 | + | req.Header.Set("authorization", "Bearer "+tok) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | client := &http.Client{Timeout: 4 * time.Second} | |||
| 162 | + | resp, err := client.Do(req) | |||
| 163 | + | if err != nil { | |||
| 164 | + | return "", "", err | |||
| 165 | + | } | |||
| 166 | + | defer resp.Body.Close() | |||
| 167 | + | if resp.StatusCode != 200 { | |||
| 168 | + | return "", "", fmt.Errorf("github api %d", resp.StatusCode) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | var body struct { | |||
| 172 | + | TagName string `json:"tag_name"` | |||
| 173 | + | HTMLURL string `json:"html_url"` | |||
| 174 | + | } | |||
| 175 | + | if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { | |||
| 176 | + | return "", "", err | |||
| 177 | + | } | |||
| 178 | + | return body.TagName, body.HTMLURL, nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // readUpdateCache loads the last check result. Returns ok=false on | |||
| 182 | + | // any error (missing file, bad json) so callers just treat it as a | |||
| 183 | + | // cache miss and re-fetch. | |||
| 184 | + | func readUpdateCache(path string) (updateCache, bool) { | |||
| 185 | + | b, err := os.ReadFile(path) | |||
| 186 | + | if err != nil { | |||
| 187 | + | return updateCache{}, false | |||
| 188 | + | } | |||
| 189 | + | var c updateCache | |||
| 190 | + | if err := json.Unmarshal(b, &c); err != nil { | |||
| 191 | + | return updateCache{}, false | |||
| 192 | + | } | |||
| 193 | + | return c, true | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func writeUpdateCache(path string, c updateCache) error { | |||
| 197 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |||
| 198 | + | return err | |||
| 199 | + | } | |||
| 200 | + | b, err := json.MarshalIndent(c, "", " ") | |||
| 201 | + | if err != nil { | |||
| 202 | + | return err | |||
| 203 | + | } | |||
| 204 | + | return os.WriteFile(path, b, 0o600) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // cachedLatest, cachedLatestOnce: used elsewhere in the binary if we | |||
| 208 | + | // want a synchronous read without triggering a network call. | |||
| 209 | + | var ( | |||
| 210 | + | cachedLatest string | |||
| 211 | + | cachedLatestOnce sync.Once | |||
| 212 | + | ) | |||