Skip to content

Commit 4dbfb55

Browse files
ZacxDevclaude
andauthored
feat(cli): cached non-blocking update notice + civitai upgrade self-update (#39)
* feat(cli): cached non-blocking update notice + `civitai upgrade` self-update Adds two related features in one PR: PART 1 — daily, cached, non-blocking "new version available" notice - A root PersistentPostRun hook prints at most ONE dim stderr line after any successful command. It never does a synchronous network call: it reads a cache (~/.config/civitai/update-check.json) and, when stale, spawns a DETACHED `civitai __update-check` (hidden subcommand) that fetches the latest release and rewrites the cache. The current run uses the cached value; the refresh lands for next time. First run (no cache) only kicks off the refresh. - Refresh at most once / 24h (last_check); notice shown at most once / 24h (last_notified) even while behind. Corrupt/missing cache => empty, fail-silent. - Suppressed when: stderr is not a TTY, CI env is set, --no-update-check / CIVITAI_NO_UPDATE_CHECK, or the command is version/upgrade/completion/help/ __update-check/__complete*. Only fires when current parses and latest > current. - stderr only — never pollutes stdout, so pipes/scripts are unaffected. - Reuses the existing fetchLatestRelease / semver helpers from update_check.go; `version` keeps its own explicit synchronous check (no double-notify). PART 2 — `civitai upgrade` self-update - Resolves the latest release (unauthenticated GitHub, no token ever sent). Already >= latest and not --force => "already up to date" no-op. - Homebrew detection: if the resolved executable lives under a brew path (/Cellar/, /Caskroom/, /opt/homebrew, /usr/local/Homebrew|Cellar, /home/linuxbrew/.linuxbrew), prints the brew upgrade command instead of self-replacing (--force overrides). - Otherwise downloads the platform tarball + checksums.txt, VERIFIES the tarball SHA-256 against checksums.txt and ABORTS on mismatch (binary left untouched), extracts the binary, and atomically replaces the running executable via github.com/minio/selfupdate. Permission-denied => clear sudo/brew/go-install guidance, non-zero exit, no half-written binary. Detaching uses a small build-tagged platform split (unix Setpgid / windows CREATE_NEW_PROCESS_GROUP); the parent never Waits. Spawn + apply + executable-path + TTY are behind injectable seams so tests assert behavior without forking or replacing the test binary. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(upgrade): enforce https + GitHub host on asset downloads Audit 🟡#1 (https/host enforcement). Asset download URLs come straight out of the GitHub release JSON and were fetched with http.DefaultClient, which follows redirects including https->http downgrades with no scheme/host check. An http:// checksums.txt + http:// tarball pair would make the SHA-256 gate self-referential (both halves attacker-controlled). - validateAssetURL: require scheme==https AND host in an allowlist {github.com, objects.githubusercontent.com, release-assets.githubusercontent.com}; reject (abort, no download) otherwise. Applied to BOTH the tarball and checksums.txt URLs before any bytes are read. - assetDownloadClient: dedicated *http.Client with CheckRedirect that re-validates every hop, so an https->http downgrade redirect is rejected rather than followed. Context timeouts + body-size caps kept. - Checksum-verify-before-replace gate unchanged (defense-in-depth on transport). Tests: validateAssetURL allow/reject table; an http:// asset URL and an off-host https asset URL each abort before download with the binary untouched; the asset client rejects an https->http redirect. httptest fixtures now serve TLS and inject the loopback host via a test seam so the production allowlist is never weakened to pass tests. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 5577fb1 commit 4dbfb55

12 files changed

Lines changed: 1814 additions & 7 deletions

go.mod

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
module github.com/civitai/cli
22

3-
go 1.25
3+
go 1.25.0
44

55
require (
6+
github.com/minio/selfupdate v0.6.0
67
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1
78
github.com/spf13/cobra v1.8.1
89
github.com/spf13/viper v1.19.0
10+
golang.org/x/term v0.44.0
911
golang.org/x/text v0.14.0
1012
gopkg.in/yaml.v3 v3.0.1
1113
)
1214

1315
require (
16+
aead.dev/minisign v0.2.0 // indirect
1417
github.com/fsnotify/fsnotify v1.7.0 // indirect
1518
github.com/hashicorp/hcl v1.0.0 // indirect
1619
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -26,7 +29,8 @@ require (
2629
github.com/subosito/gotenv v1.6.0 // indirect
2730
go.uber.org/atomic v1.9.0 // indirect
2831
go.uber.org/multierr v1.9.0 // indirect
32+
golang.org/x/crypto v0.21.0 // indirect
2933
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
30-
golang.org/x/sys v0.18.0 // indirect
34+
golang.org/x/sys v0.46.0 // indirect
3135
gopkg.in/ini.v1 v1.67.0 // indirect
3236
)

go.sum

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
2+
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
13
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
24
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
35
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -21,6 +23,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
2123
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
2224
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
2325
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
26+
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
27+
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
2428
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
2529
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
2630
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
@@ -65,12 +69,32 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
6569
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
6670
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
6771
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
72+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
73+
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
74+
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
75+
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
76+
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
6877
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
6978
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
70-
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
71-
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
79+
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
80+
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
81+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
82+
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
83+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
84+
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
85+
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
86+
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
87+
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
88+
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
89+
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
90+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
91+
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
92+
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
93+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
94+
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
7295
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
7396
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
97+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
7498
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
7599
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
76100
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

internal/cmd/root.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ func isDevDefault(v string) bool {
108108

109109
// NewRootCmd builds the root command with all subcommands attached.
110110
func NewRootCmd() *cobra.Command {
111+
var noUpdateCheck bool
111112
root := &cobra.Command{
112113
Use: "civitai",
113114
Short: "Civitai CLI — author and ship App Blocks",
@@ -133,15 +134,29 @@ Get started:
133134
SilenceUsage: true,
134135
SilenceErrors: true,
135136
Version: version,
137+
// PersistentPostRun fires after ANY subcommand's RunE. It prints at most
138+
// one cached "new version available" line to stderr and (when the cache
139+
// is stale) kicks off a detached background refresh. It is best-effort
140+
// and NEVER blocks the command — see maybeNotifyUpdate.
141+
PersistentPostRun: func(cmd *cobra.Command, args []string) {
142+
maybeNotifyUpdate(cmd.ErrOrStderr(), cmd.Name(), noUpdateCheck)
143+
},
136144
}
137145
root.SetVersionTemplate("civitai {{.Version}}\n")
138146

147+
// A persistent flag so every command honours --no-update-check, and the
148+
// post-run hook can read its resolved value.
149+
root.PersistentFlags().BoolVar(&noUpdateCheck, "no-update-check", false,
150+
"skip the background check for a newer release (also via CIVITAI_NO_UPDATE_CHECK)")
151+
139152
root.AddCommand(newAppCmd())
140153
root.AddCommand(newLoginCmd())
141154
root.AddCommand(newWhoAmICmd())
142155
root.AddCommand(newBuzzCmd())
143156
root.AddCommand(newVersionCmd())
157+
root.AddCommand(newUpgradeCmd())
144158
root.AddCommand(newCompletionCmd())
159+
root.AddCommand(newUpdateCheckCmd())
145160

146161
return root
147162
}

internal/cmd/spawn_unix.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//go:build !windows
2+
3+
package cmd
4+
5+
import (
6+
"os"
7+
"os/exec"
8+
"syscall"
9+
)
10+
11+
// detachAndStart launches cmd as a detached background process and returns
12+
// immediately WITHOUT waiting for it. On unix we put it in its own process
13+
// group (Setpgid) so it survives the parent exiting and isn't killed by a
14+
// signal sent to the parent's group. stdio is redirected to /dev/null so it
15+
// can never write to the user's terminal.
16+
//
17+
// The returned error reflects only the Start() (fork/exec) failure; we never
18+
// Wait on the child, so the parent returns instantly.
19+
func detachAndStart(cmd *exec.Cmd) error {
20+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
21+
devnull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
22+
if err == nil {
23+
cmd.Stdin = devnull
24+
cmd.Stdout = devnull
25+
cmd.Stderr = devnull
26+
}
27+
startErr := cmd.Start()
28+
// The child has inherited the fd; close our copy so we don't leak it.
29+
if devnull != nil {
30+
_ = devnull.Close()
31+
}
32+
// Deliberately NOT calling cmd.Wait() — fire and forget. The child reaps as
33+
// an orphan once the parent exits.
34+
return startErr
35+
}

internal/cmd/spawn_windows.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//go:build windows
2+
3+
package cmd
4+
5+
import (
6+
"os"
7+
"os/exec"
8+
"syscall"
9+
)
10+
11+
// detachAndStart launches cmd as a detached background process on Windows and
12+
// returns immediately WITHOUT waiting. CREATE_NEW_PROCESS_GROUP detaches the
13+
// child from the parent's console process group so it survives the parent
14+
// exiting; stdio is redirected to NUL so it can never write to the console.
15+
func detachAndStart(cmd *exec.Cmd) error {
16+
cmd.SysProcAttr = &syscall.SysProcAttr{
17+
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
18+
}
19+
devnull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
20+
if err == nil {
21+
cmd.Stdin = devnull
22+
cmd.Stdout = devnull
23+
cmd.Stderr = devnull
24+
}
25+
startErr := cmd.Start()
26+
if devnull != nil {
27+
_ = devnull.Close()
28+
}
29+
// Fire and forget — never Wait.
30+
return startErr
31+
}

internal/cmd/update_cache.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"time"
8+
9+
"github.com/civitai/cli/internal/config"
10+
)
11+
12+
// updateCacheName is the cache file (sibling of config.yaml) that backs the
13+
// non-blocking daily update notice. It records when we last checked GitHub, the
14+
// latest version we saw, and when we last actually showed the notice.
15+
const updateCacheName = "update-check.json"
16+
17+
// updateCacheTTL is how often the background refresh is allowed to hit GitHub.
18+
const updateCacheTTL = 24 * time.Hour
19+
20+
// updateNotifyTTL throttles how often the notice is shown — at most once a day
21+
// even while the user stays behind, so it's a gentle nudge, not nagware.
22+
const updateNotifyTTL = 24 * time.Hour
23+
24+
// updateCache is the on-disk JSON state for the cached update notice.
25+
type updateCache struct {
26+
LastCheck time.Time `json:"last_check"`
27+
LatestVersion string `json:"latest_version"`
28+
LastNotified time.Time `json:"last_notified"`
29+
}
30+
31+
// updateCachePath returns ~/.config/civitai/update-check.json (the same dir as
32+
// config.yaml). Returns an error only if the config dir can't be resolved.
33+
func updateCachePath() (string, error) {
34+
dir, err := config.Dir()
35+
if err != nil {
36+
return "", err
37+
}
38+
return filepath.Join(dir, updateCacheName), nil
39+
}
40+
41+
// readUpdateCache loads the cache. A missing or corrupt file is treated as an
42+
// empty cache with no error — the notice path must never fail a command.
43+
func readUpdateCache(path string) updateCache {
44+
b, err := os.ReadFile(path)
45+
if err != nil {
46+
return updateCache{}
47+
}
48+
var c updateCache
49+
if err := json.Unmarshal(b, &c); err != nil {
50+
return updateCache{} // corrupt => empty, fail-silent.
51+
}
52+
return c
53+
}
54+
55+
// writeUpdateCache persists the cache atomically (0600 temp + rename) into the
56+
// config dir. The dir is created if needed. Errors are returned to the caller,
57+
// but the only caller (the detached refresh) ignores them — a failed write just
58+
// means the next run refreshes again.
59+
func writeUpdateCache(path string, c updateCache) error {
60+
dir := filepath.Dir(path)
61+
if err := os.MkdirAll(dir, 0o700); err != nil {
62+
return err
63+
}
64+
b, err := json.Marshal(c)
65+
if err != nil {
66+
return err
67+
}
68+
tmp, err := os.CreateTemp(dir, ".update-check-*.json.tmp")
69+
if err != nil {
70+
return err
71+
}
72+
tmpName := tmp.Name()
73+
defer func() { _ = os.Remove(tmpName) }()
74+
if _, err := tmp.Write(b); err != nil {
75+
_ = tmp.Close()
76+
return err
77+
}
78+
if err := tmp.Close(); err != nil {
79+
return err
80+
}
81+
return os.Rename(tmpName, path)
82+
}
83+
84+
// cacheIsFresh reports whether the cached check is younger than the TTL, so no
85+
// background refresh is needed. A zero LastCheck (empty cache) is never fresh.
86+
func cacheIsFresh(c updateCache, now time.Time) bool {
87+
if c.LastCheck.IsZero() {
88+
return false
89+
}
90+
return now.Sub(c.LastCheck) < updateCacheTTL
91+
}
92+
93+
// noticeDecision describes what the post-run hook should do, derived purely from
94+
// the cache + current version + the clock (no I/O, so it's unit-testable).
95+
type noticeDecision struct {
96+
// show is true when a one-line "newer version" notice should be printed.
97+
show bool
98+
// notice is the text to print (only meaningful when show is true).
99+
notice string
100+
// refresh is true when a background GitHub refresh should be kicked off.
101+
refresh bool
102+
}
103+
104+
// decideNotice computes whether to show the notice and/or refresh the cache,
105+
// from the cached state and the resolved current version. Pure function.
106+
//
107+
// Rules:
108+
// - refresh when the cache is stale (older than updateCacheTTL or empty).
109+
// - show the notice only when: the cached latest parses, current parses,
110+
// latest > current, AND we haven't notified within updateNotifyTTL.
111+
func decideNotice(c updateCache, current string, now time.Time) noticeDecision {
112+
d := noticeDecision{refresh: !cacheIsFresh(c, now)}
113+
114+
if c.LatestVersion == "" || !isParseableVersion(c.LatestVersion) {
115+
return d // no usable cached value yet (e.g. first run) => no notice.
116+
}
117+
// Only nag when we KNOW the current version and it's genuinely behind.
118+
if !isParseableVersion(current) {
119+
return d
120+
}
121+
if compareVersions(current, c.LatestVersion) != -1 {
122+
return d // up to date or ahead.
123+
}
124+
// Throttle: at most one notice per updateNotifyTTL.
125+
if !c.LastNotified.IsZero() && now.Sub(c.LastNotified) < updateNotifyTTL {
126+
return d
127+
}
128+
d.show = true
129+
d.notice = formatUpdateNotice(c.LatestVersion, current)
130+
return d
131+
}
132+
133+
// formatUpdateNotice renders the single dim stderr line.
134+
func formatUpdateNotice(latest, current string) string {
135+
return "A new version of civitai is available: " + latest +
136+
" (you have " + current + "). Run 'civitai upgrade' to update."
137+
}

internal/cmd/update_check.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ var latestReleaseURL = "https://api.github.com/repos/civitai/cli/releases/latest
2323
// version` never hangs on a slow/offline network.
2424
const updateCheckTimeout = 2500 * time.Millisecond
2525

26+
// contextWithUpdateTimeout returns a context bounded by updateCheckTimeout for
27+
// the unauthenticated GitHub round-trip. Shared by the synchronous `version`
28+
// check and the detached background refresh.
29+
func contextWithUpdateTimeout() (context.Context, context.CancelFunc) {
30+
return context.WithTimeout(context.Background(), updateCheckTimeout)
31+
}
32+
2633
// updateCheckDisabled reports whether the GitHub update check should be skipped:
2734
// the --no-update-check flag (noFlag) or a non-empty CIVITAI_NO_UPDATE_CHECK env
2835
// var both opt out.

0 commit comments

Comments
 (0)