Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
module github.com/civitai/cli

go 1.25
go 1.25.0

require (
github.com/minio/selfupdate v0.6.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
golang.org/x/term v0.44.0
golang.org/x/text v0.14.0
gopkg.in/yaml.v3 v3.0.1
)

require (
aead.dev/minisign v0.2.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand All @@ -26,7 +29,8 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/sys v0.46.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)
28 changes: 26 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand All @@ -21,6 +23,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
Expand Down Expand Up @@ -65,12 +69,32 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
15 changes: 15 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ func isDevDefault(v string) bool {

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

// A persistent flag so every command honours --no-update-check, and the
// post-run hook can read its resolved value.
root.PersistentFlags().BoolVar(&noUpdateCheck, "no-update-check", false,
"skip the background check for a newer release (also via CIVITAI_NO_UPDATE_CHECK)")

root.AddCommand(newAppCmd())
root.AddCommand(newLoginCmd())
root.AddCommand(newWhoAmICmd())
root.AddCommand(newBuzzCmd())
root.AddCommand(newVersionCmd())
root.AddCommand(newUpgradeCmd())
root.AddCommand(newCompletionCmd())
root.AddCommand(newUpdateCheckCmd())

return root
}
35 changes: 35 additions & 0 deletions internal/cmd/spawn_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//go:build !windows

package cmd

import (
"os"
"os/exec"
"syscall"
)

// detachAndStart launches cmd as a detached background process and returns
// immediately WITHOUT waiting for it. On unix we put it in its own process
// group (Setpgid) so it survives the parent exiting and isn't killed by a
// signal sent to the parent's group. stdio is redirected to /dev/null so it
// can never write to the user's terminal.
//
// The returned error reflects only the Start() (fork/exec) failure; we never
// Wait on the child, so the parent returns instantly.
func detachAndStart(cmd *exec.Cmd) error {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
devnull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
if err == nil {
cmd.Stdin = devnull
cmd.Stdout = devnull
cmd.Stderr = devnull
}
startErr := cmd.Start()
// The child has inherited the fd; close our copy so we don't leak it.
if devnull != nil {
_ = devnull.Close()
}
// Deliberately NOT calling cmd.Wait() — fire and forget. The child reaps as
// an orphan once the parent exits.
return startErr
}
31 changes: 31 additions & 0 deletions internal/cmd/spawn_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//go:build windows

package cmd

import (
"os"
"os/exec"
"syscall"
)

// detachAndStart launches cmd as a detached background process on Windows and
// returns immediately WITHOUT waiting. CREATE_NEW_PROCESS_GROUP detaches the
// child from the parent's console process group so it survives the parent
// exiting; stdio is redirected to NUL so it can never write to the console.
func detachAndStart(cmd *exec.Cmd) error {
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
}
devnull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
if err == nil {
cmd.Stdin = devnull
cmd.Stdout = devnull
cmd.Stderr = devnull
}
startErr := cmd.Start()
if devnull != nil {
_ = devnull.Close()
}
// Fire and forget — never Wait.
return startErr
}
137 changes: 137 additions & 0 deletions internal/cmd/update_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package cmd

import (
"encoding/json"
"os"
"path/filepath"
"time"

"github.com/civitai/cli/internal/config"
)

// updateCacheName is the cache file (sibling of config.yaml) that backs the
// non-blocking daily update notice. It records when we last checked GitHub, the
// latest version we saw, and when we last actually showed the notice.
const updateCacheName = "update-check.json"

// updateCacheTTL is how often the background refresh is allowed to hit GitHub.
const updateCacheTTL = 24 * time.Hour

// updateNotifyTTL throttles how often the notice is shown — at most once a day
// even while the user stays behind, so it's a gentle nudge, not nagware.
const updateNotifyTTL = 24 * time.Hour

// updateCache is the on-disk JSON state for the cached update notice.
type updateCache struct {
LastCheck time.Time `json:"last_check"`
LatestVersion string `json:"latest_version"`
LastNotified time.Time `json:"last_notified"`
}

// updateCachePath returns ~/.config/civitai/update-check.json (the same dir as
// config.yaml). Returns an error only if the config dir can't be resolved.
func updateCachePath() (string, error) {
dir, err := config.Dir()
if err != nil {
return "", err
}
return filepath.Join(dir, updateCacheName), nil
}

// readUpdateCache loads the cache. A missing or corrupt file is treated as an
// empty cache with no error — the notice path must never fail a command.
func readUpdateCache(path string) updateCache {
b, err := os.ReadFile(path)
if err != nil {
return updateCache{}
}
var c updateCache
if err := json.Unmarshal(b, &c); err != nil {
return updateCache{} // corrupt => empty, fail-silent.
}
return c
}

// writeUpdateCache persists the cache atomically (0600 temp + rename) into the
// config dir. The dir is created if needed. Errors are returned to the caller,
// but the only caller (the detached refresh) ignores them — a failed write just
// means the next run refreshes again.
func writeUpdateCache(path string, c updateCache) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
b, err := json.Marshal(c)
if err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".update-check-*.json.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(b); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}

// cacheIsFresh reports whether the cached check is younger than the TTL, so no
// background refresh is needed. A zero LastCheck (empty cache) is never fresh.
func cacheIsFresh(c updateCache, now time.Time) bool {
if c.LastCheck.IsZero() {
return false
}
return now.Sub(c.LastCheck) < updateCacheTTL
}

// noticeDecision describes what the post-run hook should do, derived purely from
// the cache + current version + the clock (no I/O, so it's unit-testable).
type noticeDecision struct {
// show is true when a one-line "newer version" notice should be printed.
show bool
// notice is the text to print (only meaningful when show is true).
notice string
// refresh is true when a background GitHub refresh should be kicked off.
refresh bool
}

// decideNotice computes whether to show the notice and/or refresh the cache,
// from the cached state and the resolved current version. Pure function.
//
// Rules:
// - refresh when the cache is stale (older than updateCacheTTL or empty).
// - show the notice only when: the cached latest parses, current parses,
// latest > current, AND we haven't notified within updateNotifyTTL.
func decideNotice(c updateCache, current string, now time.Time) noticeDecision {
d := noticeDecision{refresh: !cacheIsFresh(c, now)}

if c.LatestVersion == "" || !isParseableVersion(c.LatestVersion) {
return d // no usable cached value yet (e.g. first run) => no notice.
}
// Only nag when we KNOW the current version and it's genuinely behind.
if !isParseableVersion(current) {
return d
}
if compareVersions(current, c.LatestVersion) != -1 {
return d // up to date or ahead.
}
// Throttle: at most one notice per updateNotifyTTL.
if !c.LastNotified.IsZero() && now.Sub(c.LastNotified) < updateNotifyTTL {
return d
}
d.show = true
d.notice = formatUpdateNotice(c.LatestVersion, current)
return d
}

// formatUpdateNotice renders the single dim stderr line.
func formatUpdateNotice(latest, current string) string {
return "A new version of civitai is available: " + latest +
" (you have " + current + "). Run 'civitai upgrade' to update."
}
7 changes: 7 additions & 0 deletions internal/cmd/update_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ var latestReleaseURL = "https://api.github.com/repos/civitai/cli/releases/latest
// version` never hangs on a slow/offline network.
const updateCheckTimeout = 2500 * time.Millisecond

// contextWithUpdateTimeout returns a context bounded by updateCheckTimeout for
// the unauthenticated GitHub round-trip. Shared by the synchronous `version`
// check and the detached background refresh.
func contextWithUpdateTimeout() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), updateCheckTimeout)
}

// updateCheckDisabled reports whether the GitHub update check should be skipped:
// the --no-update-check flag (noFlag) or a non-empty CIVITAI_NO_UPDATE_CHECK env
// var both opt out.
Expand Down
Loading
Loading