From 07dfe141ad8cb77ff3300978dcf8f0e81ad9aec9 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 29 Nov 2025 03:20:32 -0500 Subject: [PATCH 01/14] Create internal package --- cmd/install.go | 5 +- cmd/uninstall.go | 86 +++++++++---------- .../channels.go => internal/models/channel.go | 0 utils/api.go => internal/models/github.go | 0 {utils => internal/utils}/download.go | 0 {utils => internal/utils}/paths.go | 21 +++-- utils/exe.go | 25 ------ utils/kill.go | 39 --------- 8 files changed, 58 insertions(+), 118 deletions(-) rename utils/channels.go => internal/models/channel.go (100%) rename utils/api.go => internal/models/github.go (100%) rename {utils => internal/utils}/download.go (100%) rename {utils => internal/utils}/paths.go (89%) delete mode 100644 utils/exe.go delete mode 100644 utils/kill.go diff --git a/cmd/install.go b/cmd/install.go index acd4399..e8878e4 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -9,7 +9,8 @@ import ( "github.com/spf13/cobra" - utils "github.com/betterdiscord/cli/utils" + models "github.com/betterdiscord/cli/internal/models" + utils "github.com/betterdiscord/cli/internal/utils" ) func init() { @@ -65,7 +66,7 @@ var installCmd = &cobra.Command{ } // Get download URL from GitHub API - var apiData, err = utils.DownloadJSON[utils.Release]("https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest") + var apiData, err = utils.DownloadJSON[models.Release]("https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest") if err != nil { fmt.Println("Could not get API response") fmt.Println(err) diff --git a/cmd/uninstall.go b/cmd/uninstall.go index e3084dc..0bfeacf 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -8,57 +8,57 @@ import ( "github.com/spf13/cobra" - utils "github.com/betterdiscord/cli/utils" + utils "github.com/betterdiscord/cli/internal/utils" ) func init() { - rootCmd.AddCommand(uninstallCmd) + rootCmd.AddCommand(uninstallCmd) } var uninstallCmd = &cobra.Command{ - Use: "uninstall ", - Short: "Uninstalls BetterDiscord from your Discord", - Long: "This can uninstall BetterDiscord to multiple versions and paths of Discord at once. Options for channel are: stable, canary, ptb", - ValidArgs: []string{"canary", "stable", "ptb"}, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - Run: func(cmd *cobra.Command, args []string) { - var releaseChannel = args[0] - var corePath = utils.DiscordPath(releaseChannel) - var indString = "module.exports = require(\"./core.asar\");" + Use: "uninstall ", + Short: "Uninstalls BetterDiscord from your Discord", + Long: "This can uninstall BetterDiscord to multiple versions and paths of Discord at once. Options for channel are: stable, canary, ptb", + ValidArgs: []string{"canary", "stable", "ptb"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Run: func(cmd *cobra.Command, args []string) { + var releaseChannel = args[0] + var corePath = utils.DiscordPath(releaseChannel) + var indString = "module.exports = require(\"./core.asar\");" - if err := os.WriteFile(path.Join(corePath, "index.js"), []byte(indString), 0755); err != nil { - fmt.Println("Could not write index.js in discord_desktop_core!") - return - } + if err := os.WriteFile(path.Join(corePath, "index.js"), []byte(indString), 0755); err != nil { + fmt.Println("Could not write index.js in discord_desktop_core!") + return + } - var targetExe = "" - switch releaseChannel { - case "stable": - targetExe = "Discord.exe" - break - case "canary": - targetExe = "DiscordCanary.exe" - break - case "ptb": - targetExe = "DiscordPTB.exe" - break - default: - targetExe = "" - } + var targetExe = "" + switch releaseChannel { + case "stable": + targetExe = "Discord.exe" + break + case "canary": + targetExe = "DiscordCanary.exe" + break + case "ptb": + targetExe = "DiscordPTB.exe" + break + default: + targetExe = "" + } - // Kill Discord if it's running - var exe = utils.GetProcessExe(targetExe) - if len(exe) > 0 { - if err := utils.KillProcess(targetExe); err != nil { - fmt.Println("Could not kill Discord") - return - } - } + // Kill Discord if it's running + var exe = utils.GetProcessExe(targetExe) + if len(exe) > 0 { + if err := utils.KillProcess(targetExe); err != nil { + fmt.Println("Could not kill Discord") + return + } + } - // Launch Discord if we killed it - if len(exe) > 0 { - var cmd = exec.Command(exe) - cmd.Start() - } - }, + // Launch Discord if we killed it + if len(exe) > 0 { + var cmd = exec.Command(exe) + cmd.Start() + } + }, } diff --git a/utils/channels.go b/internal/models/channel.go similarity index 100% rename from utils/channels.go rename to internal/models/channel.go diff --git a/utils/api.go b/internal/models/github.go similarity index 100% rename from utils/api.go rename to internal/models/github.go diff --git a/utils/download.go b/internal/utils/download.go similarity index 100% rename from utils/download.go rename to internal/utils/download.go diff --git a/utils/paths.go b/internal/utils/paths.go similarity index 89% rename from utils/paths.go rename to internal/utils/paths.go index 590a5c7..75e6d62 100644 --- a/utils/paths.go +++ b/internal/utils/paths.go @@ -7,6 +7,8 @@ import ( "runtime" "sort" "strings" + + models "github.com/betterdiscord/cli/internal/models" ) var Roaming string @@ -33,7 +35,7 @@ func Exists(path string) bool { } func DiscordPath(channel string) string { - var channelName = GetChannelName(channel) + var channelName = models.GetChannelName(channel) switch op := runtime.GOOS; op { case "windows": @@ -51,14 +53,13 @@ func ValidatePath(proposed string) string { switch op := runtime.GOOS; op { case "windows": return validateWindows(proposed) - case "darwin","linux": + case "darwin", "linux": return validateMacLinux(proposed) default: return "" } } - func Filter[T any](source []T, filterFunc func(T) bool) (ret []T) { var returnArray = []T{} for _, s := range source { @@ -69,7 +70,6 @@ func Filter[T any](source []T, filterFunc func(T) bool) (ret []T) { return returnArray } - func validateWindows(proposed string) string { var finalPath = "" var selected = path.Base(proposed) @@ -80,7 +80,7 @@ func validateWindows(proposed string) string { if err != nil { return "" } - + var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) var versionDir = candidates[len(candidates)-1].Name() @@ -90,7 +90,9 @@ func validateWindows(proposed string) string { if err != nil { return "" } - candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") }) + candidates = Filter(dFiles, func(file fs.DirEntry) bool { + return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") + }) var coreWrap = candidates[len(candidates)-1].Name() finalPath = path.Join(proposed, versionDir, "modules", coreWrap, "discord_desktop_core") @@ -102,7 +104,9 @@ func validateWindows(proposed string) string { if err != nil { return "" } - var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") }) + var candidates = Filter(dFiles, func(file fs.DirEntry) bool { + return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") + }) var coreWrap = candidates[len(candidates)-1].Name() finalPath = path.Join(proposed, coreWrap, "discord_desktop_core") } @@ -119,7 +123,6 @@ func validateWindows(proposed string) string { return "" } - func validateMacLinux(proposed string) string { if strings.Contains(proposed, "/snap") { return "" @@ -133,7 +136,7 @@ func validateMacLinux(proposed string) string { if err != nil { return "" } - + var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) var versionDir = candidates[len(candidates)-1].Name() diff --git a/utils/exe.go b/utils/exe.go deleted file mode 100644 index a51e6e1..0000000 --- a/utils/exe.go +++ /dev/null @@ -1,25 +0,0 @@ -package utils - -import ( - "github.com/shirou/gopsutil/v3/process" -) - -func GetProcessExe(name string) string { - var exe = "" - processes, err := process.Processes() - if err != nil { - return exe - } - for _, p := range processes { - n, err := p.Name() - if err != nil { - continue - } - if n == name { - if len(exe) == 0 { - exe, _ = p.Exe() - } - } - } - return exe -} diff --git a/utils/kill.go b/utils/kill.go deleted file mode 100644 index 3b15aec..0000000 --- a/utils/kill.go +++ /dev/null @@ -1,39 +0,0 @@ -package utils - -import ( - "fmt" - - "github.com/shirou/gopsutil/v3/process" -) - -func KillProcess(name string) error { - processes, err := process.Processes() - - // If we can't even list processes, bail out - if err != nil { - return fmt.Errorf("Could not list processes") - } - - // Search for desired processe(s) - for _, p := range processes { - n, err := p.Name() - - // Ignore processes requiring Admin/Sudo - if err != nil { - continue - } - - // We found our target, kill it - if n == name { - var killErr = p.Kill() - - // We found it but can't kill it, bail out - if killErr != nil { - return killErr - } - } - } - - // If we got here, everything was killed without error - return nil -} From 377a90c1e5fe815fe478b4069b6041391854adc9 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 29 Nov 2025 03:45:12 -0500 Subject: [PATCH 02/14] Add installer modules --- go.mod | 11 ++- go.sum | 15 +++ internal/betterdiscord/download.go | 64 ++++++++++++ internal/betterdiscord/install.go | 97 ++++++++++++++++++ internal/betterdiscord/setup.go | 60 +++++++++++ internal/discord/assets/injection.js | 21 ++++ internal/discord/injection.go | 58 +++++++++++ internal/discord/install.go | 103 +++++++++++++++++++ internal/discord/paths.go | 100 +++++++++++++++++++ internal/discord/paths_darwin.go | 79 +++++++++++++++ internal/discord/paths_linux.go | 97 ++++++++++++++++++ internal/discord/paths_windows.go | 91 +++++++++++++++++ internal/discord/process.go | 127 ++++++++++++++++++++++++ internal/models/channel.go | 79 +++++++++++++-- internal/models/github.go | 4 +- internal/utils/download.go | 14 +-- internal/utils/paths.go | 143 --------------------------- 17 files changed, 997 insertions(+), 166 deletions(-) create mode 100644 internal/betterdiscord/download.go create mode 100644 internal/betterdiscord/install.go create mode 100644 internal/betterdiscord/setup.go create mode 100644 internal/discord/assets/injection.js create mode 100644 internal/discord/injection.go create mode 100644 internal/discord/install.go create mode 100644 internal/discord/paths.go create mode 100644 internal/discord/paths_darwin.go create mode 100644 internal/discord/paths_linux.go create mode 100644 internal/discord/paths_windows.go create mode 100644 internal/discord/process.go diff --git a/go.mod b/go.mod index 35487de..07882f9 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/betterdiscord/cli go 1.19 require ( - github.com/shirou/gopsutil/v3 v3.22.10 + github.com/shirou/gopsutil/v3 v3.24.5 github.com/spf13/cobra v1.6.1 ) @@ -12,9 +12,10 @@ require ( github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/tklauser/go-sysconf v0.3.10 // indirect - github.com/tklauser/numcpus v0.4.0 // indirect - github.com/yusufpapurcu/wmi v1.2.2 // indirect - golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.20.0 // indirect ) diff --git a/go.sum b/go.sum index 612f5b6..4e5abd9 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,10 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:Om github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil/v3 v3.22.10 h1:4KMHdfBRYXGF9skjDWiL4RA2N+E8dRdodU/bOZpPoVg= github.com/shirou/gopsutil/v3 v3.22.10/go.mod h1:QNza6r4YQoydyCfo6rH0blGfKahgibh4dQmV5xdFkQk= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -29,17 +33,28 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/tklauser/go-sysconf v0.3.10 h1:IJ1AZGZRWbY8T5Vfk04D9WOA5WSejdflXxP03OUqALw= github.com/tklauser/go-sysconf v0.3.10/go.mod h1:C8XykCvCb+Gn0oNCWPIlcb0RuglQTYaQ2hGm7jmxEFk= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.4.0 h1:E53Dm1HjH1/R2/aoCtXtPgzmElmn51aOkhCFSuZq//o= github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/betterdiscord/download.go b/internal/betterdiscord/download.go new file mode 100644 index 0000000..757fdc3 --- /dev/null +++ b/internal/betterdiscord/download.go @@ -0,0 +1,64 @@ +package betterdiscord + +import ( + "log" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func (i *BDInstall) download() error { + if i.hasDownloaded { + log.Printf("✅ Already downloaded to %s", i.asar) + return nil + } + + resp, err := utils.DownloadFile("https://betterdiscord.app/Download/betterdiscord.asar", i.asar) + if err == nil { + version := resp.Header.Get("x-bd-version") + log.Printf("✅ Downloaded BetterDiscord version %s from the official website", version) + i.hasDownloaded = true + return nil + } else { + log.Printf("❌ Failed to download BetterDiscord from official website") + log.Printf("❌ %s", err.Error()) + log.Printf("") + log.Printf("#### Falling back to GitHub...") + } + + // Get download URL from GitHub API + apiData, err := utils.DownloadJSON[models.GitHubRelease]("https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest") + if err != nil { + log.Printf("❌ Failed to get asset url from GitHub") + log.Printf("❌ %s", err.Error()) + return err + } + + var index = 0 + for i, asset := range apiData.Assets { + if asset.Name == "betterdiscord.asar" { + index = i + break + } + } + + var downloadUrl = apiData.Assets[index].URL + var version = apiData.TagName + + if downloadUrl != "" { + log.Printf("✅ Found BetterDiscord: %s", downloadUrl) + } + + // Download asar into the BD folder + _, err = utils.DownloadFile(downloadUrl, i.asar) + if err != nil { + log.Printf("❌ Failed to download BetterDiscord from GitHub") + log.Printf("❌ %s", err.Error()) + return err + } + + log.Printf("✅ Downloaded BetterDiscord version %s from GitHub", version) + i.hasDownloaded = true + + return nil +} diff --git a/internal/betterdiscord/install.go b/internal/betterdiscord/install.go new file mode 100644 index 0000000..81c5173 --- /dev/null +++ b/internal/betterdiscord/install.go @@ -0,0 +1,97 @@ +package betterdiscord + +import ( + "os" + "path/filepath" + "sync" + + "github.com/betterdiscord/cli/internal/models" +) + +type BDInstall struct { + root string + data string + asar string + plugins string + themes string + hasDownloaded bool +} + +// Root returns the root directory path of the BetterDiscord installation +func (i *BDInstall) Root() string { + return i.root +} + +// Data returns the data directory path +func (i *BDInstall) Data() string { + return i.data +} + +// Asar returns the path to the BetterDiscord asar file +func (i *BDInstall) Asar() string { + return i.asar +} + +// Plugins returns the plugins directory path +func (i *BDInstall) Plugins() string { + return i.plugins +} + +// Themes returns the themes directory path +func (i *BDInstall) Themes() string { + return i.themes +} + +// HasDownloaded returns whether BetterDiscord has been downloaded +func (i *BDInstall) HasDownloaded() bool { + return i.hasDownloaded +} + +// Download downloads the BetterDiscord asar file +func (i *BDInstall) Download() error { + return i.download() +} + +// Prepare creates all necessary directories for BetterDiscord +func (i *BDInstall) Prepare() error { + return i.prepare() +} + +// Repair disables plugins for a specific Discord channel +func (i *BDInstall) Repair(channel models.DiscordChannel) error { + return i.repair(channel) +} + +var lock = &sync.Mutex{} +var globalInstance *BDInstall + +func GetInstallation(base ...string) *BDInstall { + if len(base) == 0 { + if globalInstance != nil { + return globalInstance + } + + lock.Lock() + defer lock.Unlock() + if globalInstance != nil { + return globalInstance + } + + configDir, _ := os.UserConfigDir() + globalInstance = New(configDir) + + return globalInstance + } + + return New(filepath.Join(base[0], "BetterDiscord")) +} + +func New(root string) *BDInstall { + return &BDInstall{ + root: root, + data: filepath.Join(root, "data"), + asar: filepath.Join(root, "data", "betterdiscord.asar"), + plugins: filepath.Join(root, "plugins"), + themes: filepath.Join(root, "themes"), + } +} diff --git a/internal/betterdiscord/setup.go b/internal/betterdiscord/setup.go new file mode 100644 index 0000000..774843a --- /dev/null +++ b/internal/betterdiscord/setup.go @@ -0,0 +1,60 @@ +package betterdiscord + +import ( + "log" + "os" + "path/filepath" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func makeDirectory(folder string) error { + exists := utils.Exists(folder) + + if exists { + log.Printf("✅ Directory exists: %s", folder) + return nil + } + + if err := os.MkdirAll(folder, 0755); err != nil { + log.Printf("❌ Failed to create directory: %s", folder) + log.Printf("❌ %s", err.Error()) + return err + } + + log.Printf("✅ Directory created: %s", folder) + return nil +} + +func (i *BDInstall) prepare() error { + if err := makeDirectory(i.data); err != nil { + return err + } + if err := makeDirectory(i.plugins); err != nil { + return err + } + if err := makeDirectory(i.themes); err != nil { + return err + } + return nil +} + +func (i *BDInstall) repair(channel models.DiscordChannel) error { + channelFolder := filepath.Join(i.data, channel.String()) + pluginsJson := filepath.Join(channelFolder, "plugins.json") + + if !utils.Exists(pluginsJson) { + log.Printf("✅ No plugins enabled for %s", channel.Name()) + return nil + } + + if err := os.Remove(pluginsJson); err != nil { + log.Printf("❌ Unable to remove file %s", pluginsJson) + log.Printf("❌ %s", err.Error()) + return err + } + + log.Printf("✅ Plugins disabled for %s", channel.Name()) + return nil +} diff --git a/internal/discord/assets/injection.js b/internal/discord/assets/injection.js new file mode 100644 index 0000000..3d176be --- /dev/null +++ b/internal/discord/assets/injection.js @@ -0,0 +1,21 @@ +// BetterDiscord's Injection Script +const path = require("path"); +const electron = require("electron"); + +// Windows and macOS both use the fixed global BetterDiscord folder but +// Electron gives the postfixed version of userData, so go up a directory +let userConfig = path.join(electron.app.getPath("userData"), ".."); + +// If we're on Linux there are a couple cases to deal with +if (process.platform !== "win32" && process.platform !== "darwin") { + // Use || instead of ?? because a falsey value of "" is invalid per XDG spec + userConfig = process.env.XDG_CONFIG_HOME || path.join(process.env.HOME, ".config"); + + // HOST_XDG_CONFIG_HOME is set by flatpak, so use without validation if set + if (process.env.HOST_XDG_CONFIG_HOME) userConfig = process.env.HOST_XDG_CONFIG_HOME; +} + +require(path.join(userConfig, "BetterDiscord", "data", "betterdiscord.asar")); + +// Discord's Default Export +module.exports = require("./core.asar"); \ No newline at end of file diff --git a/internal/discord/injection.go b/internal/discord/injection.go new file mode 100644 index 0000000..734aa6f --- /dev/null +++ b/internal/discord/injection.go @@ -0,0 +1,58 @@ +package discord + +import ( + _ "embed" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/betterdiscord/cli/internal/betterdiscord" +) + +//go:embed assets/injection.js +var injectionScript string + +func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { + if discord.isFlatpak { + cmd := exec.Command("flatpak", "--user", "override", "com.discordapp."+discord.channel.Exe(), "--filesystem="+bd.Root()) + if err := cmd.Run(); err != nil { + log.Printf("❌ Could not give flatpak access to %s", bd.Root()) + log.Printf("❌ %s", err.Error()) + return err + } + } + + if err := os.WriteFile(filepath.Join(discord.corePath, "index.js"), []byte(injectionScript), 0755); err != nil { + log.Printf("❌ Unable to write index.js in %s", discord.corePath) + log.Printf("❌ %s", err.Error()) + return err + } + + log.Printf("✅ Injected into %s", discord.corePath) + return nil +} + +func (discord *DiscordInstall) uninject() error { + indexFile := filepath.Join(discord.corePath, "index.js") + + contents, err := os.ReadFile(indexFile) + + // First try to check the file, but if there's an issue we try to blindly overwrite below + if err == nil { + if !strings.Contains(strings.ToLower(string(contents)), "betterdiscord") { + log.Printf("✅ No injection found for %s", discord.channel.Name()) + return nil + } + } + + if err := os.WriteFile(indexFile, []byte(`module.exports = require("./core.asar");`), 0o644); err != nil { + log.Printf("❌ Unable to write file %s", indexFile) + log.Printf("❌ %s", err.Error()) + return err + } + log.Printf("✅ Removed from %s", discord.channel.Name()) + + return nil +} diff --git a/internal/discord/install.go b/internal/discord/install.go new file mode 100644 index 0000000..3cadf18 --- /dev/null +++ b/internal/discord/install.go @@ -0,0 +1,103 @@ +package discord + +import ( + "log" + "path/filepath" + + "github.com/betterdiscord/cli/internal/betterdiscord" + "github.com/betterdiscord/cli/internal/models" +) + +type DiscordInstall struct { + corePath string `json:"corePath"` + channel models.DiscordChannel `json:"channel"` + version string `json:"version"` + isFlatpak bool `json:"isFlatpak"` + isSnap bool `json:"isSnap"` +} + +func (discord *DiscordInstall) GetPath() string { + return discord.corePath +} + +// InstallBD installs BetterDiscord into this Discord installation +func (discord *DiscordInstall) InstallBD() error { + // Gets the global BetterDiscord install + bd := betterdiscord.GetInstallation() + + // Snaps get their own local BD install + if discord.isSnap { + bd = betterdiscord.GetInstallation(filepath.Clean(filepath.Join(discord.corePath, "..", "..", "..", ".."))) + } + + // Make BetterDiscord folders + log.Printf("## Preparing BetterDiscord...") + if err := bd.Prepare(); err != nil { + return err + } + log.Printf("✅ BetterDiscord prepared for install") + log.Printf("") + + // Download and write betterdiscord.asar + log.Printf("## Downloading BetterDiscord...") + if err := bd.Download(); err != nil { + return err + } + log.Printf("✅ BetterDiscord downloaded") + log.Printf("") + + // Write injection script to discord_desktop_core/index.js + log.Printf("## Injecting into Discord...") + if err := discord.inject(bd); err != nil { + return err + } + log.Printf("✅ Injection successful") + log.Printf("") + + // Terminate and restart Discord if possible + log.Printf("## Restarting %s...", discord.channel.Name()) + if err := discord.restart(); err != nil { + return err + } + log.Printf("") + + return nil +} + +// UninstallBD removes BetterDiscord from this Discord installation +func (discord *DiscordInstall) UninstallBD() error { + log.Printf("## Removing injection...") + if err := discord.uninject(); err != nil { + return err + } + log.Printf("") + + log.Printf("## Restarting %s...", discord.channel.Name()) + if err := discord.restart(); err != nil { + return err + } + log.Printf("") + + return nil +} + +// RepairBD repairs BetterDiscord for this Discord installation +func (discord *DiscordInstall) RepairBD() error { + if err := discord.UninstallBD(); err != nil { + return err + } + + // Gets the global BetterDiscord install + bd := betterdiscord.GetInstallation() + + // Snaps get their own local BD install + if discord.isSnap { + bd = betterdiscord.GetInstallation(filepath.Clean(filepath.Join(discord.corePath, "..", "..", "..", ".."))) + } + + if err := bd.Repair(discord.channel); err != nil { + return err + } + + return nil +} diff --git a/internal/discord/paths.go b/internal/discord/paths.go new file mode 100644 index 0000000..121c467 --- /dev/null +++ b/internal/discord/paths.go @@ -0,0 +1,100 @@ +package discord + +import ( + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/betterdiscord/cli/internal/models" +) + +var searchPaths []string +var versionRegex = regexp.MustCompile(`[0-9]+\.[0-9]+\.[0-9]+`) +var allDiscordInstalls map[models.DiscordChannel][]*DiscordInstall + +func GetAllInstalls() map[models.DiscordChannel][]*DiscordInstall { + var installs = map[models.DiscordChannel][]*DiscordInstall{} + + for _, path := range searchPaths { + if result := Validate(path); result != nil { + installs[result.channel] = append(installs[result.channel], result) + } + } + + sortInstalls() + + return installs +} + +func GetVersion(proposed string) string { + for _, folder := range strings.Split(proposed, string(filepath.Separator)) { + if version := versionRegex.FindString(folder); version != "" { + return version + } + } + return "" +} + +func GetChannel(proposed string) models.DiscordChannel { + for _, folder := range strings.Split(proposed, string(filepath.Separator)) { + for _, channel := range models.Channels { + if strings.ToLower(folder) == strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") { + return channel + } + } + } + return models.Stable +} + +func GetSuggestedPath(channel models.DiscordChannel) string { + if len(allDiscordInstalls[channel]) > 0 { + return allDiscordInstalls[channel][0].corePath + } + return "" +} + +func AddCustomPath(proposed string) *DiscordInstall { + result := Validate(proposed) + if result == nil { + return nil + } + + // Check if this already exists in our list and return reference + index := slices.IndexFunc(allDiscordInstalls[result.channel], func(d *DiscordInstall) bool { return d.corePath == result.corePath }) + if index >= 0 { + return allDiscordInstalls[result.channel][index] + } + + allDiscordInstalls[result.channel] = append(allDiscordInstalls[result.channel], result) + + sortInstalls() + + return result +} + +func ResolvePath(proposed string) *DiscordInstall { + for channel := range allDiscordInstalls { + index := slices.IndexFunc(allDiscordInstalls[channel], func(d *DiscordInstall) bool { return d.corePath == proposed }) + if index >= 0 { + return allDiscordInstalls[channel][index] + } + } + + // If it wasn't found as an existing install, try to add it + return AddCustomPath(proposed) +} + +func sortInstalls() { + for channel := range allDiscordInstalls { + slices.SortFunc(allDiscordInstalls[channel], func(a, b *DiscordInstall) int { + switch { + case a.version > b.version: + return -1 + case b.version > a.version: + return 1 + } + return 0 + }) + } +} diff --git a/internal/discord/paths_darwin.go b/internal/discord/paths_darwin.go new file mode 100644 index 0000000..a97886e --- /dev/null +++ b/internal/discord/paths_darwin.go @@ -0,0 +1,79 @@ +package discord + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func init() { + config, _ := os.UserConfigDir() + paths := []string{ + filepath.Join(config, "{channel}"), + } + + for _, channel := range models.Channels { + for _, path := range paths { + folder := strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") + searchPaths = append( + searchPaths, + strings.ReplaceAll(path, "{channel}", folder), + ) + } + } + + allDiscordInstalls = GetAllInstalls() +} + +/** + * Currently nearly the same as linux validation however + * it is kept separate in case of future changes to + * either system, it is likely that linux will require + * more advanced validation for snap and flatpak. + */ +func Validate(proposed string) *DiscordInstall { + var finalPath = "" + var selected = filepath.Base(proposed) + if strings.HasPrefix(selected, "discord") { + // Get version dir like 1.0.9002 + var dFiles, err = os.ReadDir(proposed) + if err != nil { + return nil + } + + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) + var versionDir = candidates[len(candidates)-1].Name() + finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core") + } + + if len(strings.Split(selected, ".")) == 3 { + finalPath = filepath.Join(proposed, "modules", "discord_desktop_core") + } + + if selected == "modules" { + finalPath = filepath.Join(proposed, "discord_desktop_core") + } + + if selected == "discord_desktop_core" { + finalPath = proposed + } + + // If the path and the asar exist, all good + if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { + return &DiscordInstall{ + corePath: finalPath, + channel: GetChannel(finalPath), + version: GetVersion(finalPath), + isFlatpak: false, + isSnap: false, + } + } + + return nil +} diff --git a/internal/discord/paths_linux.go b/internal/discord/paths_linux.go new file mode 100644 index 0000000..fd4990d --- /dev/null +++ b/internal/discord/paths_linux.go @@ -0,0 +1,97 @@ +package discord + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func init() { + config, _ := os.UserConfigDir() + home, _ := os.UserHomeDir() + paths := []string{ + // Native. Data is stored under `~/.config`. + // Example: `~/.config/discordcanary`. + // Core: `~/.config/discordcanary/0.0.90/modules/discord_desktop_core/core.asar`. + filepath.Join(config, "{channel}"), + + // Flatpak. These user data paths are universal for all Flatpak installations on all machines. + // Example: `.var/app/com.discordapp.DiscordCanary/config/discordcanary`. + // Core: `.var/app/com.discordapp.DiscordCanary/config/discordcanary/0.0.90/modules/discord_desktop_core/core.asar` + filepath.Join(home, ".var", "app", "com.discordapp.{CHANNEL}", "config", "{channel}"), + + // Snap. Just like with Flatpaks, these paths are universal for all Snap installations. + // Example: `snap/discord/current/.config/discord`. + // Example: `snap/discord-canary/current/.config/discordcanary`. + // Core: `snap/discord-canary/current/.config/discordcanary/0.0.90/modules/discord_desktop_core/core.asar`. + // NOTE: Snap user data always exists, even when the Snap isn't mounted/running. + filepath.Join(home, "snap", "{channel-}", "current", ".config", "{channel}"), + } + + for _, channel := range models.Channels { + for _, path := range paths { + upper := strings.ReplaceAll(channel.Name(), " ", "") + lower := strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") + dash := strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "-") + folder := strings.ReplaceAll(path, "{CHANNEL}", upper) + folder = strings.ReplaceAll(folder, "{channel}", lower) + folder = strings.ReplaceAll(folder, "{channel-}", dash) + searchPaths = append(searchPaths, folder) + } + } + + allDiscordInstalls = GetAllInstalls() +} + +/** + * Currently nearly the same as darwin validation however + * it is kept separate in case of future changes to + * either system, it is likely that linux will require + * more advanced validation for snap and flatpak. + */ +func Validate(proposed string) *DiscordInstall { + var finalPath = "" + var selected = filepath.Base(proposed) + if strings.HasPrefix(selected, "discord") { + // Get version dir like 1.0.9002 + var dFiles, err = os.ReadDir(proposed) + if err != nil { + return nil + } + + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) + var versionDir = candidates[len(candidates)-1].Name() + finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core") + } + + if len(strings.Split(selected, ".")) == 3 { + finalPath = filepath.Join(proposed, "modules", "discord_desktop_core") + } + + if selected == "modules" { + finalPath = filepath.Join(proposed, "discord_desktop_core") + } + + if selected == "discord_desktop_core" { + finalPath = proposed + } + + // If the path and the asar exist, all good + if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { + return &DiscordInstall{ + corePath: finalPath, + channel: GetChannel(finalPath), + version: GetVersion(finalPath), + isFlatpak: strings.Contains(finalPath, "com.discordapp."), + isSnap: strings.Contains(finalPath, "snap/"), + } + } + + return nil +} diff --git a/internal/discord/paths_windows.go b/internal/discord/paths_windows.go new file mode 100644 index 0000000..b2449bd --- /dev/null +++ b/internal/discord/paths_windows.go @@ -0,0 +1,91 @@ +package discord + +import ( + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/betterdiscord/cli/internal/models" + "github.com/betterdiscord/cli/internal/utils" +) + +func init() { + paths := []string{ + filepath.Join(os.Getenv("LOCALAPPDATA"), "{channel}"), + filepath.Join(os.Getenv("PROGRAMDATA"), os.Getenv("USERNAME"), "{channel}"), + } + + for _, channel := range models.Channels { + for _, path := range paths { + searchPaths = append( + searchPaths, + strings.ReplaceAll(path, "{channel}", strings.ReplaceAll(channel.Name(), " ", "")), + ) + } + } + + allDiscordInstalls = GetAllInstalls() +} + +func Validate(proposed string) *DiscordInstall { + var finalPath = "" + var selected = filepath.Base(proposed) + + if strings.HasPrefix(selected, "Discord") { + + // Get version dir like 1.0.9002 + var dFiles, err = os.ReadDir(proposed) + if err != nil { + return nil + } + + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) + var versionDir = candidates[len(candidates)-1].Name() + + // Get core wrap like discord_desktop_core-1 + dFiles, err = os.ReadDir(filepath.Join(proposed, versionDir, "modules")) + if err != nil { + return nil + } + candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { + return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") + }) + var coreWrap = candidates[len(candidates)-1].Name() + + finalPath = filepath.Join(proposed, versionDir, "modules", coreWrap, "discord_desktop_core") + } + + // Use a separate if statement because forcing same-line } else if { is gross + if strings.HasPrefix(selected, "app-") { + var dFiles, err = os.ReadDir(filepath.Join(proposed, "modules")) + if err != nil { + return nil + } + + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { + return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") + }) + var coreWrap = candidates[len(candidates)-1].Name() + finalPath = filepath.Join(proposed, "modules", coreWrap, "discord_desktop_core") + } + + if selected == "discord_desktop_core" { + finalPath = proposed + } + + // If the path and the asar exist, all good + if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { + return &DiscordInstall{ + corePath: finalPath, + channel: GetChannel(finalPath), + version: GetVersion(finalPath), + isFlatpak: false, + isSnap: false, + } + } + + return nil +} diff --git a/internal/discord/process.go b/internal/discord/process.go new file mode 100644 index 0000000..f5a8fc4 --- /dev/null +++ b/internal/discord/process.go @@ -0,0 +1,127 @@ +package discord + +import ( + "fmt" + "log" + "os" + "os/exec" + + "github.com/shirou/gopsutil/v3/process" +) + +func (discord *DiscordInstall) restart() error { + exeName := discord.getFullExe() + + if running, _ := discord.isRunning(); !running { + log.Printf("✅ %s not running", discord.channel.Name()) + return nil + } + + if err := discord.kill(); err != nil { + log.Printf("❌ Unable to restart %s, please do so manually!", discord.channel.Name()) + log.Printf("❌ %s", err.Error()) + return err + } + + // Use binary found in killing process + cmd := exec.Command(exeName) + if discord.isFlatpak { + cmd = exec.Command("flatpak", "run", "com.discordapp."+discord.channel.Exe()) + } else if discord.isSnap { + cmd = exec.Command("snap", "run", discord.channel.Exe()) + } + + // Set working directory to user home + cmd.Dir, _ = os.UserHomeDir() + + if err := cmd.Start(); err != nil { + log.Printf("❌ Unable to restart %s, please do so manually!", discord.channel.Name()) + log.Printf("❌ %s", err.Error()) + return err + } + log.Printf("✅ Restarted %s", discord.channel.Name()) + return nil +} + +func (discord *DiscordInstall) isRunning() (bool, error) { + name := discord.channel.Exe() + processes, err := process.Processes() + + // If we can't even list processes, bail out + if err != nil { + return false, fmt.Errorf("could not list processes") + } + + // Search for desired processe(s) + for _, p := range processes { + n, err := p.Name() + + // Ignore processes requiring Admin/Sudo + if err != nil { + continue + } + + // We found our target return + if n == name { + return true, nil + } + } + + // If we got here, process was not found + return false, nil +} + +func (discord *DiscordInstall) kill() error { + name := discord.channel.Exe() + processes, err := process.Processes() + + // If we can't even list processes, bail out + if err != nil { + return fmt.Errorf("could not list processes") + } + + // Search for desired processe(s) + for _, p := range processes { + n, err := p.Name() + + // Ignore processes requiring Admin/Sudo + if err != nil { + continue + } + + // We found our target, kill it + if n == name { + var killErr = p.Kill() + + // We found it but can't kill it, bail out + if killErr != nil { + return killErr + } + } + } + + // If we got here, everything was killed without error + return nil +} + +func (discord *DiscordInstall) getFullExe() string { + name := discord.channel.Exe() + + var exe = "" + processes, err := process.Processes() + if err != nil { + return exe + } + for _, p := range processes { + n, err := p.Name() + if err != nil { + continue + } + if n == name { + if len(exe) == 0 { + exe, _ = p.Exe() + } + } + } + return exe +} diff --git a/internal/models/channel.go b/internal/models/channel.go index 08b848c..34b4fc7 100644 --- a/internal/models/channel.go +++ b/internal/models/channel.go @@ -1,16 +1,77 @@ -package utils +package models -import "strings" +import ( + "runtime" + "strings" +) -func GetChannelName(channel string) string { - switch strings.ToLower(channel) { - case "stable": +// DiscordChannel represents a Discord release channel (Stable, PTB, Canary) +type DiscordChannel int + +const ( + Stable DiscordChannel = iota + Canary + PTB +) + +// All available Discord channels +var Channels = []DiscordChannel{Stable, Canary, PTB} + +// Used for logging, etc +func (channel DiscordChannel) String() string { + switch channel { + case Stable: + return "stable" + case Canary: + return "canary" + case PTB: + return "ptb" + } + return "" +} + +// Used for user display +func (channel DiscordChannel) Name() string { + switch channel { + case Stable: return "Discord" + case Canary: + return "Discord Canary" + case PTB: + return "Discord PTB" + } + return "" +} + +// Exe returns the executable name for the release channel +func (channel DiscordChannel) Exe() string { + name := channel.Name() + + if runtime.GOOS != "darwin" { + name = strings.ReplaceAll(name, " ", "") + } + + if runtime.GOOS == "windows" { + name = name + ".exe" + } + + return name +} + +// ParseChannel converts a string input to a DiscordChannel type +func ParseChannel(input string) DiscordChannel { + switch strings.ToLower(input) { + case "stable": + return Stable case "canary": - return "DiscordCanary" + return Canary case "ptb": - return "DiscordPTB" - default: - return "" + return PTB } + return Stable +} + +// Used by Wails for type serialization +func (channel DiscordChannel) TSName() string { + return strings.ToUpper(channel.String()) } diff --git a/internal/models/github.go b/internal/models/github.go index 4fb9c2f..a9b3c1a 100644 --- a/internal/models/github.go +++ b/internal/models/github.go @@ -1,10 +1,10 @@ -package utils +package models import ( "time" ) -type Release struct { +type GitHubRelease struct { URL string `json:"url"` AssetsURL string `json:"assets_url"` UploadURL string `json:"upload_url"` diff --git a/internal/utils/download.go b/internal/utils/download.go index 2297aa9..eb12f0a 100644 --- a/internal/utils/download.go +++ b/internal/utils/download.go @@ -13,19 +13,19 @@ var client = &http.Client{ Timeout: 10 * time.Second, } -func DownloadFile(url string, filepath string) (err error) { +func DownloadFile(url string, filepath string) (response *http.Response, err error) { // Create the file out, err := os.Create(filepath) if err != nil { - return err + return nil, err } defer out.Close() // Setup the request req, err := http.NewRequest("GET", url, nil) if err != nil { - return err + return nil, err } req.Header.Add("User-Agent", "BetterDiscord/cli") req.Header.Add("Accept", "application/octet-stream") @@ -33,22 +33,22 @@ func DownloadFile(url string, filepath string) (err error) { // Get the data resp, err := client.Do(req) if err != nil { - return err + return resp, err } defer resp.Body.Close() // Check server response if resp.StatusCode != http.StatusOK { - return fmt.Errorf("Bad status: %s", resp.Status) + return resp, fmt.Errorf("bad status code: %s", resp.Status) } // Writer the body to file _, err = io.Copy(out, resp.Body) if err != nil { - return err + return resp, err } - return nil + return resp, nil } func DownloadJSON[T any](url string) (T, error) { diff --git a/internal/utils/paths.go b/internal/utils/paths.go index 75e6d62..8d86ebb 100644 --- a/internal/utils/paths.go +++ b/internal/utils/paths.go @@ -1,65 +1,14 @@ package utils import ( - "io/fs" "os" - "path" - "runtime" - "sort" - "strings" - - models "github.com/betterdiscord/cli/internal/models" ) -var Roaming string -var BetterDiscord string -var Data string -var Plugins string -var Themes string - -func init() { - var configDir, err = os.UserConfigDir() - if err != nil { - return - } - Roaming = configDir - BetterDiscord = path.Join(configDir, "BetterDiscord") - Data = path.Join(BetterDiscord, "data") - Plugins = path.Join(BetterDiscord, "plugins") - Themes = path.Join(BetterDiscord, "themes") -} - func Exists(path string) bool { var _, err = os.Stat(path) return err == nil } -func DiscordPath(channel string) string { - var channelName = models.GetChannelName(channel) - - switch op := runtime.GOOS; op { - case "windows": - return ValidatePath(path.Join(os.Getenv("LOCALAPPDATA"), channelName)) - case "darwin": - return ValidatePath(path.Join("/", "Applications", channelName+".app")) - case "linux": - return ValidatePath(path.Join(Roaming, strings.ToLower(channelName))) - default: - return "" - } -} - -func ValidatePath(proposed string) string { - switch op := runtime.GOOS; op { - case "windows": - return validateWindows(proposed) - case "darwin", "linux": - return validateMacLinux(proposed) - default: - return "" - } -} - func Filter[T any](source []T, filterFunc func(T) bool) (ret []T) { var returnArray = []T{} for _, s := range source { @@ -69,95 +18,3 @@ func Filter[T any](source []T, filterFunc func(T) bool) (ret []T) { } return returnArray } - -func validateWindows(proposed string) string { - var finalPath = "" - var selected = path.Base(proposed) - if strings.HasPrefix(selected, "Discord") { - - // Get version dir like 1.0.9002 - var dFiles, err = os.ReadDir(proposed) - if err != nil { - return "" - } - - var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) - sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) - var versionDir = candidates[len(candidates)-1].Name() - - // Get core wrap like discord_desktop_core-1 - dFiles, err = os.ReadDir(path.Join(proposed, versionDir, "modules")) - if err != nil { - return "" - } - candidates = Filter(dFiles, func(file fs.DirEntry) bool { - return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") - }) - var coreWrap = candidates[len(candidates)-1].Name() - - finalPath = path.Join(proposed, versionDir, "modules", coreWrap, "discord_desktop_core") - } - - // Use a separate if statement because forcing same-line } else if { is gross - if strings.HasPrefix(proposed, "app-") { - var dFiles, err = os.ReadDir(path.Join(proposed, "modules")) - if err != nil { - return "" - } - var candidates = Filter(dFiles, func(file fs.DirEntry) bool { - return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") - }) - var coreWrap = candidates[len(candidates)-1].Name() - finalPath = path.Join(proposed, coreWrap, "discord_desktop_core") - } - - if selected == "discord_desktop_core" { - finalPath = proposed - } - - // If the path and the asar exist, all good - if Exists(finalPath) && Exists(path.Join(finalPath, "core.asar")) { - return finalPath - } - - return "" -} - -func validateMacLinux(proposed string) string { - if strings.Contains(proposed, "/snap") { - return "" - } - - var finalPath = "" - var selected = path.Base(proposed) - if strings.HasPrefix(selected, "discord") { - // Get version dir like 1.0.9002 - var dFiles, err = os.ReadDir(proposed) - if err != nil { - return "" - } - - var candidates = Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) - sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) - var versionDir = candidates[len(candidates)-1].Name() - finalPath = path.Join(proposed, versionDir, "modules", "discord_desktop_core") - } - - if len(strings.Split(selected, ".")) == 3 { - finalPath = path.Join(proposed, "modules", "discord_desktop_core") - } - - if selected == "modules" { - finalPath = path.Join(proposed, "discord_desktop_core") - } - - if selected == "discord_desktop_core" { - finalPath = proposed - } - - if Exists(finalPath) && Exists(path.Join(finalPath, "core.asar")) { - return finalPath - } - - return "" -} From bb4132c606a48812089e2a1cc93a0dcc9af8aef8 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 29 Nov 2025 23:50:27 -0500 Subject: [PATCH 03/14] Update commands --- cmd/install.go | 90 ++++--------------------------- cmd/uninstall.go | 46 ++++------------ internal/betterdiscord/install.go | 2 +- main.go | 3 ++ 4 files changed, 23 insertions(+), 118 deletions(-) diff --git a/cmd/install.go b/cmd/install.go index e8878e4..a7b808c 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -2,15 +2,12 @@ package cmd import ( "fmt" - "os" - "os/exec" "path" - "strings" "github.com/spf13/cobra" - models "github.com/betterdiscord/cli/internal/models" - utils "github.com/betterdiscord/cli/internal/utils" + "github.com/betterdiscord/cli/internal/discord" + "github.com/betterdiscord/cli/internal/models" ) func init() { @@ -25,88 +22,19 @@ var installCmd = &cobra.Command{ Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), Run: func(cmd *cobra.Command, args []string) { var releaseChannel = args[0] - var targetExe = "" - switch releaseChannel { - case "stable": - targetExe = "Discord.exe" - break - case "canary": - targetExe = "DiscordCanary.exe" - break - case "ptb": - targetExe = "DiscordPTB.exe" - break - default: - targetExe = "" - } - - // Kill Discord if it's running - var exe = utils.GetProcessExe(targetExe) - if len(exe) > 0 { - if err := utils.KillProcess(targetExe); err != nil { - fmt.Println("Could not kill Discord") - return - } - } - - // Make BD directories - if err := os.MkdirAll(utils.Data, 0755); err != nil { - fmt.Println("Could not create BetterDiscord folder") - return - } + var corePath = discord.GetSuggestedPath(models.ParseChannel(releaseChannel)) + var install = discord.ResolvePath(corePath) - if err := os.MkdirAll(utils.Plugins, 0755); err != nil { - fmt.Println("Could not create plugins folder") + if install == nil { + fmt.Printf("❌ Could not find a valid %s installation to install to.\n", releaseChannel) return } - if err := os.MkdirAll(utils.Themes, 0755); err != nil { - fmt.Println("Could not create theme folder") + if err := install.InstallBD(); err != nil { + fmt.Printf("❌ Installation failed: %s\n", err.Error()) return } - // Get download URL from GitHub API - var apiData, err = utils.DownloadJSON[models.Release]("https://api.github.com/repos/BetterDiscord/BetterDiscord/releases/latest") - if err != nil { - fmt.Println("Could not get API response") - fmt.Println(err) - return - } - - var index = 0 - for i, asset := range apiData.Assets { - if asset.Name == "betterdiscord.asar" { - index = i - break - } - } - - var downloadUrl = apiData.Assets[index].URL - - // Download asar into the BD folder - var asarPath = path.Join(utils.Data, "betterdiscord.asar") - err = utils.DownloadFile(downloadUrl, asarPath) - if err != nil { - fmt.Println("Could not download asar") - return - } - - // Inject shim loader - var corePath = utils.DiscordPath(releaseChannel) - - var indString = `require("` + asarPath + `");` - indString = strings.ReplaceAll(indString, `\`, "/") - indString = indString + "\nmodule.exports = require(\"./core.asar\");" - - if err := os.WriteFile(path.Join(corePath, "index.js"), []byte(indString), 0755); err != nil { - fmt.Println("Could not write index.js in discord_desktop_core!") - return - } - - // Launch Discord if we killed it - if len(exe) > 0 { - var cmd = exec.Command(exe) - cmd.Start() - } + fmt.Printf("✅ BetterDiscord installed to %s\n", path.Dir(install.GetPath())) }, } diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 0bfeacf..1b058ee 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -2,13 +2,10 @@ package cmd import ( "fmt" - "os" - "os/exec" - "path" + "github.com/betterdiscord/cli/internal/discord" + "github.com/betterdiscord/cli/internal/models" "github.com/spf13/cobra" - - utils "github.com/betterdiscord/cli/internal/utils" ) func init() { @@ -23,42 +20,19 @@ var uninstallCmd = &cobra.Command{ Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), Run: func(cmd *cobra.Command, args []string) { var releaseChannel = args[0] - var corePath = utils.DiscordPath(releaseChannel) - var indString = "module.exports = require(\"./core.asar\");" + var corePath = discord.GetSuggestedPath(models.ParseChannel(releaseChannel)) + var install = discord.ResolvePath(corePath) - if err := os.WriteFile(path.Join(corePath, "index.js"), []byte(indString), 0755); err != nil { - fmt.Println("Could not write index.js in discord_desktop_core!") + if install == nil { + fmt.Printf("❌ Could not find a valid %s installation to uninstall.\n", releaseChannel) return } - var targetExe = "" - switch releaseChannel { - case "stable": - targetExe = "Discord.exe" - break - case "canary": - targetExe = "DiscordCanary.exe" - break - case "ptb": - targetExe = "DiscordPTB.exe" - break - default: - targetExe = "" - } - - // Kill Discord if it's running - var exe = utils.GetProcessExe(targetExe) - if len(exe) > 0 { - if err := utils.KillProcess(targetExe); err != nil { - fmt.Println("Could not kill Discord") - return - } + if err := install.UninstallBD(); err != nil { + fmt.Printf("❌ Uninstallation failed: %s\n", err.Error()) + return } - // Launch Discord if we killed it - if len(exe) > 0 { - var cmd = exec.Command(exe) - cmd.Start() - } + fmt.Printf("✅ BetterDiscord uninstalled from %s\n", corePath) }, } diff --git a/internal/betterdiscord/install.go b/internal/betterdiscord/install.go index 81c5173..12e85b4 100644 --- a/internal/betterdiscord/install.go +++ b/internal/betterdiscord/install.go @@ -78,7 +78,7 @@ func GetInstallation(base ...string) *BDInstall { } configDir, _ := os.UserConfigDir() - globalInstance = New(configDir) + globalInstance = GetInstallation(configDir) return globalInstance } diff --git a/main.go b/main.go index fd486d0..f34436d 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,12 @@ package main import ( + "log" + "github.com/betterdiscord/cli/cmd" ) func main() { + log.SetFlags(0) cmd.Execute() } From 0bf795f3f7b415f0652325cdd2f14c58afd51b06 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sun, 30 Nov 2025 05:46:33 -0500 Subject: [PATCH 04/14] Updates tooling and some new commands - CI/CD workflows for testing, linting, and building - An updated release process using GoReleaser - New basic commands list, paths, reinstall, and repair --- .github/workflows/ci.yml | 84 ++++++++++ .github/workflows/release.yml | 43 +++++ .gitignore | 5 + .goreleaser.yaml | 106 +++++++++++-- README.md | 226 +++++++++++++++++++++++++++ Taskfile.yml | 285 ++++++++++++++++++++++++++++++++++ cmd/list.go | 34 ++++ cmd/paths.go | 31 ++++ cmd/reinstall.go | 42 +++++ cmd/repair.go | 35 +++++ cmd/root.go | 1 + 11 files changed, 883 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 README.md create mode 100644 Taskfile.yml create mode 100644 cmd/list.go create mode 100644 cmd/paths.go create mode 100644 cmd/reinstall.go create mode 100644 cmd/repair.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a2e9de --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + go-version: ['1.19', '1.20', '1.21'] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Run go vet + run: go vet ./... + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... + + - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.21' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.out + flags: unittests + name: codecov-umbrella + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + args: --timeout=5m + + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache: true + + - name: Build + run: go build -v ./... + + - name: Test build with GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: build --snapshot --clean diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ff4f03d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + packages: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.19' + cache: true + + - name: Run tests + run: go test -v -race ./... + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.gitignore b/.gitignore index b947077..4db8cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ node_modules/ dist/ +.task/ + +# Test coverage +coverage.out +coverage.html diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c9f8df7..0bec3fb 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,7 +1,17 @@ # yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# GoReleaser v2 configuration +version: 2 + +# Before hook - runs before the build +before: + hooks: + - go mod tidy + - go mod download builds: - - binary: bdcli + - id: bdcli + binary: bdcli + main: ./main.go env: - CGO_ENABLED=0 goos: @@ -13,22 +23,93 @@ builds: - arm64 - arm - '386' + goarm: + - '6' + - '7' ignore: - goos: darwin goarch: '386' + - goos: darwin + goarch: arm + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + mod_timestamp: '{{ .CommitTimestamp }}' archives: - - format: tar.gz - name_template: "{{.Binary}}_{{.Os}}_{{.Arch}}" + - id: default + format: tar.gz + name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" format_overrides: - - goos: windows - format: zip + - goos: windows + format: zip + files: + - LICENSE + - README.md + checksum: name_template: 'bdcli_checksums.txt' + algorithm: sha256 + snapshot: - name_template: "{{ incpatch .Version }}-next" + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + use: github + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - Merge pull request + - Merge branch + groups: + - title: 'Features' + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: 'Bug Fixes' + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: 'Performance Improvements' + regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' + order: 2 + - title: 'Others' + order: 999 + release: draft: true + replace_existing_draft: true + target_commitish: '{{ .Commit }}' + prerelease: auto + mode: replace + header: | + ## BetterDiscord CLI {{ .Tag }} + + Install with npm: `npm install -g @betterdiscord/cli@{{ .Version }}` + footer: | + **Full Changelog**: https://github.com/BetterDiscord/cli/compare/{{ .PreviousTag }}...{{ .Tag }} + +# NPM publishing via go-npm +nfpms: + - id: packages + package_name: betterdiscord-cli + vendor: BetterDiscord + homepage: https://betterdiscord.app/ + maintainer: BetterDiscord Team + description: A cross-platform CLI for managing BetterDiscord + license: Apache-2.0 + formats: + - deb + - rpm + - apk + bindir: /usr/bin + contents: + - src: LICENSE + dst: /usr/share/doc/betterdiscord-cli/LICENSE + chocolateys: - name: betterdiscordcli owners: BetterDiscord @@ -37,13 +118,20 @@ chocolateys: project_url: https://betterdiscord.app/ url_template: "https://github.com/BetterDiscord/cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}" icon_url: https://betterdiscord.app/resources/branding/logo_solid.png - copyright: 2023 BetterDiscord Limited + copyright: 2025 BetterDiscord Limited license_url: https://github.com/BetterDiscord/cli/blob/main/LICENSE project_source_url: https://github.com/BetterDiscord/cli docs_url: https://github.com/BetterDiscord/cli/wiki bug_tracker_url: https://github.com/BetterDiscord/cli/issues - tags: "betterdiscord cli" + tags: "betterdiscord cli discord" summary: A cross-platform CLI for managing BetterDiscord - description: A cross-platform CLI for managing BetterDiscord + description: | + A cross-platform CLI for managing BetterDiscord. + Provides commands to install, uninstall, and manage BetterDiscord on your system. release_notes: "https://github.com/BetterDiscord/cli/releases/tag/v{{ .Version }}" skip_publish: true + +# Git configuration +git: + ignore_tags: + - 'nightly' diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6673c3 --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +# BetterDiscord CLI + +[![Go Version](https://img.shields.io/github/go-mod/go-version/BetterDiscord/cli)](https://go.dev/) +[![Release](https://img.shields.io/github/v/release/BetterDiscord/cli)](https://github.com/BetterDiscord/cli/releases) +[![License](https://img.shields.io/github/license/BetterDiscord/cli)](LICENSE) +[![npm](https://img.shields.io/npm/v/@betterdiscord/cli)](https://www.npmjs.com/package/@betterdiscord/cli) + +A cross-platform command-line interface for installing, updating, and managing [BetterDiscord](https://betterdiscord.app/). + +## Features + +- 🚀 Easy installation and uninstallation of BetterDiscord +- 🔄 Support for multiple Discord channels (Stable, PTB, Canary) +- 🖥️ Cross-platform support (Windows, macOS, Linux) +- 📦 Available via npm for easy distribution +- ⚡ Fast and lightweight Go binary + +## Installation + +### Via npm (Recommended) + +```bash +npm install -g @betterdiscord/cli +``` + +### Via Go + +```bash +go install github.com/betterdiscord/cli@latest +``` + +### Download Binary + +Download the latest release for your platform from the [releases page](https://github.com/BetterDiscord/cli/releases). + +## Usage + +### Install BetterDiscord + +Install BetterDiscord to a specific Discord channel: + +```bash +bdcli install stable # Install to Discord Stable +bdcli install ptb # Install to Discord PTB +bdcli install canary # Install to Discord Canary +``` + +### Uninstall BetterDiscord + +Uninstall BetterDiscord from a specific Discord channel: + +```bash +bdcli uninstall stable # Uninstall from Discord Stable +bdcli uninstall ptb # Uninstall from Discord PTB +bdcli uninstall canary # Uninstall from Discord Canary +``` + +### Check Version + +```bash +bdcli version +``` + +### Help + +```bash +bdcli --help +bdcli --help +``` + +## Supported Platforms + +- **Windows** (x64, ARM64, x86) +- **macOS** (x64, ARM64/M1/M2) +- **Linux** (x64, ARM64, ARM) + +## Development + +### Prerequisites + +- [Go](https://go.dev/) 1.19 or higher +- [Task](https://taskfile.dev/) (optional, for task automation) +- [GoReleaser](https://goreleaser.com/) (for releases) + +### Setup + +Clone the repository and install dependencies: + +```bash +git clone https://github.com/BetterDiscord/cli.git +cd cli +task setup # Or: go mod download +``` + +### Available Tasks + +Run `task --list` to see all available tasks: + +```bash +# Development +task run # Run the CLI +task run:install # Test install command +task run:uninstall # Test uninstall command + +# Building +task build # Build for current platform +task build:all # Build for all platforms +task install # Install to $GOPATH/bin + +# Testing +task test # Run tests +task test:coverage # Run tests with coverage + +# Code Quality +task lint # Run linter +task fmt # Format code +task vet # Run go vet +task check # Run all checks + +# Release +task release:snapshot # Test release build +task release # Create release (requires tag) +``` + +### Running Locally + +```bash +# Run directly +go run main.go install stable + +# Or use Task +task run -- install stable +``` + +### Building + +```bash +# Build for current platform +task build + +# Build for all platforms +task build:all + +# Output will be in ./dist/ +``` + +### Testing + +```bash +# Run all tests +task test + +# Run with coverage +task test:coverage +``` + +### Releasing + +1. Create and push a new tag: + + ```bash + git tag -a v0.2.0 -m "Release v0.2.0" + git push origin v0.2.0 + ``` + +2. GitHub Actions will automatically build and create a draft release + +3. Edit the release notes and publish + +4. Publish to npm: + + ```bash + npm publish + ``` + +## Project Structure + +```py +. +├── cmd/ # Cobra commands +│ ├── install.go # Install command +│ ├── uninstall.go # Uninstall command +│ ├── version.go # Version command +│ └── root.go # Root command +├── internal/ # Internal packages +│ ├── betterdiscord/ # BetterDiscord installation logic +│ ├── discord/ # Discord path resolution and injection +│ ├── models/ # Data models +│ └── utils/ # Utility functions +├── main.go # Entry point +├── Taskfile.yml # Task automation +└── .goreleaser.yaml # Release configuration +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'feat: add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. + +## Links + +- [BetterDiscord Website](https://betterdiscord.app/) +- [BetterDiscord Documentation](https://docs.betterdiscord.app/) +- [Issue Tracker](https://github.com/BetterDiscord/cli/issues) +- [npm Package](https://www.npmjs.com/package/@betterdiscord/cli) + +## Acknowledgments + +Built with: + +- [Cobra](https://github.com/spf13/cobra) - CLI framework +- [GoReleaser](https://goreleaser.com/) - Release automation +- [Task](https://taskfile.dev/) - Task runner + +--- + +Made with ❤️ by the BetterDiscord Team diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..939d81f --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,285 @@ +# https://taskfile.dev +version: '3' + +vars: + BINARY_NAME: bdcli + MAIN_PACKAGE: ./main.go + BUILD_DIR: ./dist + GO_VERSION: '1.19' + +env: + CGO_ENABLED: 0 + GOOS: '{{OS}}' + GOARCH: '{{ARCH}}' + +tasks: + default: + desc: List all available tasks + cmds: + - task --list-all + silent: true + + # Development tasks + run: + desc: Run the CLI application + cmds: + - go run {{.MAIN_PACKAGE}} {{.CLI_ARGS}} + sources: + - '**/*.go' + - go.mod + - go.sum + + run:install: + desc: Run the install command + cmds: + - go run {{.MAIN_PACKAGE}} install {{.CLI_ARGS}} + + run:uninstall: + desc: Run the uninstall command + cmds: + - go run {{.MAIN_PACKAGE}} uninstall {{.CLI_ARGS}} + + run:version: + desc: Run the version command + cmds: + - go run {{.MAIN_PACKAGE}} version {{.CLI_ARGS}} + + # Build tasks + build: + desc: Build the binary for current platform + cmds: + - go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}{{if eq OS "windows"}}.exe{{end}} {{.MAIN_PACKAGE}} + sources: + - '**/*.go' + - go.mod + - go.sum + generates: + - '{{.BUILD_DIR}}/{{.BINARY_NAME}}{{if eq OS "windows"}}.exe{{end}}' + + build:all: + desc: Build binaries for all platforms using GoReleaser + cmds: + - goreleaser build --snapshot --clean + sources: + - '**/*.go' + - go.mod + - go.sum + - .goreleaser.yaml + + build:linux: + desc: Build for Linux (amd64 and arm64) + cmds: + - GOOS=linux GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-amd64 {{.MAIN_PACKAGE}} + - GOOS=linux GOARCH=arm64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-arm64 {{.MAIN_PACKAGE}} + + build:windows: + desc: Build for Windows (amd64 and arm64) + cmds: + - GOOS=windows GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-windows-amd64.exe {{.MAIN_PACKAGE}} + - GOOS=windows GOARCH=arm64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-windows-arm64.exe {{.MAIN_PACKAGE}} + + build:darwin: + desc: Build for macOS (amd64 and arm64) + cmds: + - GOOS=darwin GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-darwin-amd64 {{.MAIN_PACKAGE}} + - GOOS=darwin GOARCH=arm64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-darwin-arm64 {{.MAIN_PACKAGE}} + + install: + desc: Install the binary to $GOPATH/bin + deps: [build] + cmds: + - go install {{.MAIN_PACKAGE}} + + # Testing tasks + test: + desc: Run all tests + cmds: + - go test -v -race ./... + + test:coverage: + desc: Run tests with coverage + cmds: + - go test -v -race -coverprofile=coverage.out -covermode=atomic ./... + - go tool cover -html=coverage.out -o coverage.html + generates: + - coverage.out + - coverage.html + + test:bench: + desc: Run benchmark tests + cmds: + - go test -bench=. -benchmem ./... + + # Code quality tasks + lint: + desc: Run golangci-lint + cmds: + - golangci-lint run ./... + + lint:fix: + desc: Run golangci-lint with auto-fix + cmds: + - golangci-lint run --fix ./... + + fmt: + desc: Format Go code + cmds: + - go fmt ./... + - gofumpt -l -w . + preconditions: + - sh: command -v gofumpt + msg: 'gofumpt not installed. Run: go install mvdan.cc/gofumpt@latest' + + fmt:check: + desc: Check if code is formatted + cmds: + - | + UNFORMATTED=$(gofmt -l .) + if [ -n "$UNFORMATTED" ]; then + echo "The following files are not gofmt-formatted:" >&2 + echo "$UNFORMATTED" >&2 + exit 1 + fi + # Also check gofumpt stricter formatting if available + if command -v gofumpt >/dev/null 2>&1; then + STRICT=$(gofumpt -l .) + if [ -n "$STRICT" ]; then + echo "The following files need gofumpt formatting:" >&2 + echo "$STRICT" >&2 + echo "Run: gofumpt -l -w ." >&2 + exit 1 + fi + fi + echo "Formatting OK" + preconditions: + - sh: command -v gofmt + msg: 'gofmt not found' + + vet: + desc: Run go vet + cmds: + - go vet ./... + + # Dependency management + deps: + desc: Download Go dependencies + cmds: + - go mod download + + deps:tidy: + desc: Tidy Go dependencies + cmds: + - go mod tidy + + deps:verify: + desc: Verify Go dependencies + cmds: + - go mod verify + + deps:upgrade: + desc: Upgrade all Go dependencies + cmds: + - go get -u ./... + - go mod tidy + + # Release tasks + release: + desc: Create a new release (runs GoReleaser) + cmds: + - goreleaser release --clean + preconditions: + - sh: command -v goreleaser + msg: 'goreleaser not installed. See: https://goreleaser.com/install/' + - sh: git diff-index --quiet HEAD + msg: 'Git working directory is not clean. Commit or stash changes first.' + + release:snapshot: + desc: Create a snapshot release (no publish) + cmds: + - goreleaser release --snapshot --clean + + release:test: + desc: Test the release process without publishing + cmds: + - goreleaser release --skip=publish --clean + + release:npm: + desc: Publish to npm after GitHub release + cmds: + - npm publish + preconditions: + - sh: test -f package.json + msg: 'package.json not found' + + # Cleanup tasks + clean: + desc: Clean build artifacts and caches + cmds: + - rm -rf {{.BUILD_DIR}} + - rm -rf coverage.out coverage.html + - go clean -cache -testcache -modcache + + clean:build: + desc: Clean only build artifacts + cmds: + - rm -rf {{.BUILD_DIR}} + + # Setup and validation tasks + setup: + desc: Setup development environment + cmds: + - task: deps + - task: tools + + tools: + desc: Install development tools + cmds: + - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + - go install mvdan.cc/gofumpt@latest + - go install github.com/goreleaser/goreleaser/v2@latest + + check: + desc: Run all checks (fmt, vet, lint, test) + cmds: + - task: fmt:check + - task: vet + - task: lint + - task: test + + validate: + desc: Validate goreleaser configuration + cmds: + - goreleaser check + preconditions: + - sh: command -v goreleaser + msg: 'goreleaser not installed. See: https://goreleaser.com/install/' + + # CI/CD tasks + ci: + desc: Run CI checks (used in CI/CD pipelines) + cmds: + - task: deps:verify + - task: fmt:check + - task: vet + - task: test:coverage + - task: build + + # Documentation tasks + docs: + desc: Generate CLI documentation + cmds: + - go run {{.MAIN_PACKAGE}} --help > docs/CLI.md + - echo "CLI documentation generated in docs/CLI.md" + + # Info tasks + info: + desc: Display project information + cmds: + - echo "Binary Name:{{.BINARY_NAME}}" + - echo "Go Version:{{.GO_VERSION}}" + - echo "Current OS:{{OS}}" + - echo "Current Arch:{{ARCH}}" + - go version + - echo "Git Branch:" && git branch --show-current + - echo "Git Commit:" && git rev-parse --short HEAD + silent: true diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..d59ee11 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "fmt" + + "github.com/betterdiscord/cli/internal/discord" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(listCmd) +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List detected Discord installations", + Long: "Scans common locations and lists detected Discord installations grouped by channel.", + Run: func(cmd *cobra.Command, args []string) { + installs := discord.GetAllInstalls() + if len(installs) == 0 { + fmt.Println("No Discord installations detected.") + return + } + for channel, arr := range installs { + if len(arr) == 0 { + continue + } + fmt.Printf("%s:\n", channel.Name()) + for _, inst := range arr { + fmt.Printf(" - %s (version %s)\n", inst.GetPath(), discord.GetVersion(inst.GetPath())) + } + } + }, +} diff --git a/cmd/paths.go b/cmd/paths.go new file mode 100644 index 0000000..b741eba --- /dev/null +++ b/cmd/paths.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + + "github.com/betterdiscord/cli/internal/discord" + "github.com/betterdiscord/cli/internal/models" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(pathsCmd) +} + +var pathsCmd = &cobra.Command{ + Use: "paths", + Short: "Show suggested Discord install paths", + Long: "Displays the suggested core installation path per Discord channel detected on this system.", + Run: func(cmd *cobra.Command, args []string) { + channels := []models.DiscordChannel{models.Stable, models.PTB, models.Canary} + for _, ch := range channels { + p := discord.GetSuggestedPath(ch) + name := ch.Name() + if p == "" { + fmt.Printf("%s: (none detected)\n", name) + } else { + fmt.Printf("%s: %s\n", name, p) + } + } + }, +} diff --git a/cmd/reinstall.go b/cmd/reinstall.go new file mode 100644 index 0000000..af1ac32 --- /dev/null +++ b/cmd/reinstall.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + + "github.com/betterdiscord/cli/internal/discord" + "github.com/betterdiscord/cli/internal/models" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(reinstallCmd) +} + +var reinstallCmd = &cobra.Command{ + Use: "reinstall ", + Short: "Uninstall and then reinstall BetterDiscord", + Long: "Performs an uninstall followed by an install of BetterDiscord for the specified Discord channel.", + ValidArgs: []string{"canary", "stable", "ptb"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Run: func(cmd *cobra.Command, args []string) { + releaseChannel := args[0] + corePath := discord.GetSuggestedPath(models.ParseChannel(releaseChannel)) + inst := discord.ResolvePath(corePath) + if inst == nil { + fmt.Printf("❌ Could not find a valid %s installation to reinstall.\n", releaseChannel) + return + } + + if err := inst.UninstallBD(); err != nil { + fmt.Printf("❌ Uninstall failed: %s\n", err.Error()) + return + } + + if err := inst.InstallBD(); err != nil { + fmt.Printf("❌ Install failed: %s\n", err.Error()) + return + } + + fmt.Println("✅ BetterDiscord reinstalled successfully") + }, +} diff --git a/cmd/repair.go b/cmd/repair.go new file mode 100644 index 0000000..db6dc48 --- /dev/null +++ b/cmd/repair.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "fmt" + + "github.com/betterdiscord/cli/internal/discord" + "github.com/betterdiscord/cli/internal/models" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(repairCmd) +} + +var repairCmd = &cobra.Command{ + Use: "repair ", + Short: "Repairs the BetterDiscord installation", + Long: "Attempts to repair the BetterDiscord setup for the specified Discord channel (e.g., disables problematic plugins).", + ValidArgs: []string{"canary", "stable", "ptb"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Run: func(cmd *cobra.Command, args []string) { + releaseChannel := args[0] + corePath := discord.GetSuggestedPath(models.ParseChannel(releaseChannel)) + inst := discord.ResolvePath(corePath) + if inst == nil { + fmt.Printf("❌ Could not find a valid %s installation to repair.\n", releaseChannel) + return + } + if err := inst.RepairBD(); err != nil { + fmt.Printf("❌ Repair failed: %s\n", err.Error()) + return + } + fmt.Println("✅ Repair completed successfully") + }, +} diff --git a/cmd/root.go b/cmd/root.go index 57d70c6..b74e7df 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ var rootCmd = &cobra.Command{ Long: `A cross-platform CLI for installing, updating, and managing BetterDiscord.`, Run: func(cmd *cobra.Command, args []string) { // Do Stuff Here + fmt.Println("You should probably use a subcommand") }, } From 97b47b4c75ef4081ac750e6d2f06387a01b34131 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Fri, 5 Dec 2025 23:04:39 -0500 Subject: [PATCH 05/14] Rework un/install commands and install object --- .gitignore | 1 + cmd/install.go | 49 +++++++++++++++++++---------- cmd/list.go | 34 -------------------- cmd/paths.go | 31 ------------------ cmd/reinstall.go | 42 ------------------------- cmd/repair.go | 35 --------------------- cmd/uninstall.go | 52 +++++++++++++++++++++---------- go.sum | 26 ++-------------- internal/discord/injection.go | 27 +++++++++++----- internal/discord/install.go | 28 +++++++---------- internal/discord/paths.go | 16 +++++----- internal/discord/paths_darwin.go | 10 +++--- internal/discord/paths_linux.go | 10 +++--- internal/discord/paths_windows.go | 10 +++--- internal/discord/process.go | 22 ++++++------- 15 files changed, 137 insertions(+), 256 deletions(-) delete mode 100644 cmd/list.go delete mode 100644 cmd/paths.go delete mode 100644 cmd/reinstall.go delete mode 100644 cmd/repair.go diff --git a/.gitignore b/.gitignore index 4db8cbf..19fd4c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ .task/ +notes/ # Test coverage coverage.out diff --git a/cmd/install.go b/cmd/install.go index a7b808c..cb84d8c 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -11,30 +11,47 @@ import ( ) func init() { + installCmd.Flags().StringP("path", "p", "", "Path to a Discord installation") + installCmd.Flags().StringP("channel", "c", "stable", "Discord release channel (stable|ptb|canary)") rootCmd.AddCommand(installCmd) } var installCmd = &cobra.Command{ - Use: "install ", - Short: "Installs BetterDiscord to your Discord", - Long: "This can install BetterDiscord to multiple versions and paths of Discord at once. Options for channel are: stable, canary, ptb", - ValidArgs: []string{"canary", "stable", "ptb"}, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - Run: func(cmd *cobra.Command, args []string) { - var releaseChannel = args[0] - var corePath = discord.GetSuggestedPath(models.ParseChannel(releaseChannel)) - var install = discord.ResolvePath(corePath) - - if install == nil { - fmt.Printf("❌ Could not find a valid %s installation to install to.\n", releaseChannel) - return + Use: "install", + Short: "Installs BetterDiscord to your Discord", + Long: "Install BetterDiscord by specifying either --path to a Discord install or --channel to auto-detect (default: stable).", + RunE: func(cmd *cobra.Command, args []string) error { + pathFlag, _ := cmd.Flags().GetString("path") + channelFlag, _ := cmd.Flags().GetString("channel") + + pathProvided := pathFlag != "" + channelProvided := cmd.Flags().Changed("channel") + + if pathProvided && channelProvided { + return fmt.Errorf("--path and --channel are mutually exclusive") + } + + var install *discord.DiscordInstall + + if pathProvided { + install = discord.ResolvePath(pathFlag) + if install == nil { + return fmt.Errorf("could not find a valid Discord installation at %s", pathFlag) + } + } else { + channel := models.ParseChannel(channelFlag) + corePath := discord.GetSuggestedPath(channel) + install = discord.ResolvePath(corePath) + if install == nil { + return fmt.Errorf("could not find a valid %s installation to install to", channelFlag) + } } if err := install.InstallBD(); err != nil { - fmt.Printf("❌ Installation failed: %s\n", err.Error()) - return + return fmt.Errorf("installation failed: %w", err) } - fmt.Printf("✅ BetterDiscord installed to %s\n", path.Dir(install.GetPath())) + fmt.Printf("✅ BetterDiscord installed to %s\n", path.Dir(install.CorePath)) + return nil }, } diff --git a/cmd/list.go b/cmd/list.go deleted file mode 100644 index d59ee11..0000000 --- a/cmd/list.go +++ /dev/null @@ -1,34 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/betterdiscord/cli/internal/discord" - "github.com/spf13/cobra" -) - -func init() { - rootCmd.AddCommand(listCmd) -} - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List detected Discord installations", - Long: "Scans common locations and lists detected Discord installations grouped by channel.", - Run: func(cmd *cobra.Command, args []string) { - installs := discord.GetAllInstalls() - if len(installs) == 0 { - fmt.Println("No Discord installations detected.") - return - } - for channel, arr := range installs { - if len(arr) == 0 { - continue - } - fmt.Printf("%s:\n", channel.Name()) - for _, inst := range arr { - fmt.Printf(" - %s (version %s)\n", inst.GetPath(), discord.GetVersion(inst.GetPath())) - } - } - }, -} diff --git a/cmd/paths.go b/cmd/paths.go deleted file mode 100644 index b741eba..0000000 --- a/cmd/paths.go +++ /dev/null @@ -1,31 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/betterdiscord/cli/internal/discord" - "github.com/betterdiscord/cli/internal/models" - "github.com/spf13/cobra" -) - -func init() { - rootCmd.AddCommand(pathsCmd) -} - -var pathsCmd = &cobra.Command{ - Use: "paths", - Short: "Show suggested Discord install paths", - Long: "Displays the suggested core installation path per Discord channel detected on this system.", - Run: func(cmd *cobra.Command, args []string) { - channels := []models.DiscordChannel{models.Stable, models.PTB, models.Canary} - for _, ch := range channels { - p := discord.GetSuggestedPath(ch) - name := ch.Name() - if p == "" { - fmt.Printf("%s: (none detected)\n", name) - } else { - fmt.Printf("%s: %s\n", name, p) - } - } - }, -} diff --git a/cmd/reinstall.go b/cmd/reinstall.go deleted file mode 100644 index af1ac32..0000000 --- a/cmd/reinstall.go +++ /dev/null @@ -1,42 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/betterdiscord/cli/internal/discord" - "github.com/betterdiscord/cli/internal/models" - "github.com/spf13/cobra" -) - -func init() { - rootCmd.AddCommand(reinstallCmd) -} - -var reinstallCmd = &cobra.Command{ - Use: "reinstall ", - Short: "Uninstall and then reinstall BetterDiscord", - Long: "Performs an uninstall followed by an install of BetterDiscord for the specified Discord channel.", - ValidArgs: []string{"canary", "stable", "ptb"}, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - Run: func(cmd *cobra.Command, args []string) { - releaseChannel := args[0] - corePath := discord.GetSuggestedPath(models.ParseChannel(releaseChannel)) - inst := discord.ResolvePath(corePath) - if inst == nil { - fmt.Printf("❌ Could not find a valid %s installation to reinstall.\n", releaseChannel) - return - } - - if err := inst.UninstallBD(); err != nil { - fmt.Printf("❌ Uninstall failed: %s\n", err.Error()) - return - } - - if err := inst.InstallBD(); err != nil { - fmt.Printf("❌ Install failed: %s\n", err.Error()) - return - } - - fmt.Println("✅ BetterDiscord reinstalled successfully") - }, -} diff --git a/cmd/repair.go b/cmd/repair.go deleted file mode 100644 index db6dc48..0000000 --- a/cmd/repair.go +++ /dev/null @@ -1,35 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/betterdiscord/cli/internal/discord" - "github.com/betterdiscord/cli/internal/models" - "github.com/spf13/cobra" -) - -func init() { - rootCmd.AddCommand(repairCmd) -} - -var repairCmd = &cobra.Command{ - Use: "repair ", - Short: "Repairs the BetterDiscord installation", - Long: "Attempts to repair the BetterDiscord setup for the specified Discord channel (e.g., disables problematic plugins).", - ValidArgs: []string{"canary", "stable", "ptb"}, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - Run: func(cmd *cobra.Command, args []string) { - releaseChannel := args[0] - corePath := discord.GetSuggestedPath(models.ParseChannel(releaseChannel)) - inst := discord.ResolvePath(corePath) - if inst == nil { - fmt.Printf("❌ Could not find a valid %s installation to repair.\n", releaseChannel) - return - } - if err := inst.RepairBD(); err != nil { - fmt.Printf("❌ Repair failed: %s\n", err.Error()) - return - } - fmt.Println("✅ Repair completed successfully") - }, -} diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 1b058ee..a578c7c 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "path" "github.com/betterdiscord/cli/internal/discord" "github.com/betterdiscord/cli/internal/models" @@ -9,30 +10,49 @@ import ( ) func init() { + uninstallCmd.Flags().StringP("path", "p", "", "Path to a Discord installation") + uninstallCmd.Flags().StringP("channel", "c", "stable", "Discord release channel (stable|ptb|canary)") rootCmd.AddCommand(uninstallCmd) } var uninstallCmd = &cobra.Command{ - Use: "uninstall ", - Short: "Uninstalls BetterDiscord from your Discord", - Long: "This can uninstall BetterDiscord to multiple versions and paths of Discord at once. Options for channel are: stable, canary, ptb", - ValidArgs: []string{"canary", "stable", "ptb"}, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - Run: func(cmd *cobra.Command, args []string) { - var releaseChannel = args[0] - var corePath = discord.GetSuggestedPath(models.ParseChannel(releaseChannel)) - var install = discord.ResolvePath(corePath) - - if install == nil { - fmt.Printf("❌ Could not find a valid %s installation to uninstall.\n", releaseChannel) - return + Use: "uninstall", + Short: "Uninstalls BetterDiscord from your Discord", + Long: "Uninstall BetterDiscord by specifying either --path to a Discord install or --channel to auto-detect (default: stable).", + RunE: func(cmd *cobra.Command, args []string) error { + pathFlag, _ := cmd.Flags().GetString("path") + channelFlag, _ := cmd.Flags().GetString("channel") + + pathProvided := pathFlag != "" + channelProvided := cmd.Flags().Changed("channel") + + if pathProvided && channelProvided { + return fmt.Errorf("--path and --channel are mutually exclusive") + } + + var install *discord.DiscordInstall + var resolvedPath string + + if pathProvided { + resolvedPath = pathFlag + install = discord.ResolvePath(pathFlag) + if install == nil { + return fmt.Errorf("could not find a valid Discord installation at %s", pathFlag) + } + } else { + channel := models.ParseChannel(channelFlag) + resolvedPath = discord.GetSuggestedPath(channel) + install = discord.ResolvePath(resolvedPath) + if install == nil { + return fmt.Errorf("could not find a valid %s installation to uninstall", channelFlag) + } } if err := install.UninstallBD(); err != nil { - fmt.Printf("❌ Uninstallation failed: %s\n", err.Error()) - return + return fmt.Errorf("uninstallation failed: %w", err) } - fmt.Printf("✅ BetterDiscord uninstalled from %s\n", corePath) + fmt.Printf("✅ BetterDiscord uninstalled from %s\n", path.Dir(install.CorePath)) + return nil }, } diff --git a/go.sum b/go.sum index 4e5abd9..0e4e7c9 100644 --- a/go.sum +++ b/go.sum @@ -1,62 +1,40 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shirou/gopsutil/v3 v3.22.10 h1:4KMHdfBRYXGF9skjDWiL4RA2N+E8dRdodU/bOZpPoVg= -github.com/shirou/gopsutil/v3 v3.22.10/go.mod h1:QNza6r4YQoydyCfo6rH0blGfKahgibh4dQmV5xdFkQk= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/tklauser/go-sysconf v0.3.10 h1:IJ1AZGZRWbY8T5Vfk04D9WOA5WSejdflXxP03OUqALw= -github.com/tklauser/go-sysconf v0.3.10/go.mod h1:C8XykCvCb+Gn0oNCWPIlcb0RuglQTYaQ2hGm7jmxEFk= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.4.0 h1:E53Dm1HjH1/R2/aoCtXtPgzmElmn51aOkhCFSuZq//o= -github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= -github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/discord/injection.go b/internal/discord/injection.go index 734aa6f..bea517b 100644 --- a/internal/discord/injection.go +++ b/internal/discord/injection.go @@ -15,8 +15,8 @@ import ( var injectionScript string func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { - if discord.isFlatpak { - cmd := exec.Command("flatpak", "--user", "override", "com.discordapp."+discord.channel.Exe(), "--filesystem="+bd.Root()) + if discord.IsFlatpak { + cmd := exec.Command("flatpak", "--user", "override", "com.discordapp."+discord.Channel.Exe(), "--filesystem="+bd.Root()) if err := cmd.Run(); err != nil { log.Printf("❌ Could not give flatpak access to %s", bd.Root()) log.Printf("❌ %s", err.Error()) @@ -24,25 +24,25 @@ func (discord *DiscordInstall) inject(bd *betterdiscord.BDInstall) error { } } - if err := os.WriteFile(filepath.Join(discord.corePath, "index.js"), []byte(injectionScript), 0755); err != nil { - log.Printf("❌ Unable to write index.js in %s", discord.corePath) + if err := os.WriteFile(filepath.Join(discord.CorePath, "index.js"), []byte(injectionScript), 0755); err != nil { + log.Printf("❌ Unable to write index.js in %s", discord.CorePath) log.Printf("❌ %s", err.Error()) return err } - log.Printf("✅ Injected into %s", discord.corePath) + log.Printf("✅ Injected into %s", discord.CorePath) return nil } func (discord *DiscordInstall) uninject() error { - indexFile := filepath.Join(discord.corePath, "index.js") + indexFile := filepath.Join(discord.CorePath, "index.js") contents, err := os.ReadFile(indexFile) // First try to check the file, but if there's an issue we try to blindly overwrite below if err == nil { if !strings.Contains(strings.ToLower(string(contents)), "betterdiscord") { - log.Printf("✅ No injection found for %s", discord.channel.Name()) + log.Printf("✅ No injection found for %s", discord.Channel.Name()) return nil } } @@ -52,7 +52,18 @@ func (discord *DiscordInstall) uninject() error { log.Printf("❌ %s", err.Error()) return err } - log.Printf("✅ Removed from %s", discord.channel.Name()) + log.Printf("✅ Removed from %s", discord.Channel.Name()) return nil } + +// TODO: consider putting this in the betterdiscord package +func (discord *DiscordInstall) IsInjected() bool { + indexFile := filepath.Join(discord.CorePath, "index.js") + contents, err := os.ReadFile(indexFile) + if err != nil { + return false + } + lower := strings.ToLower(string(contents)) + return strings.Contains(lower, "betterdiscord") +} diff --git a/internal/discord/install.go b/internal/discord/install.go index 3cadf18..4126862 100644 --- a/internal/discord/install.go +++ b/internal/discord/install.go @@ -9,15 +9,11 @@ import ( ) type DiscordInstall struct { - corePath string `json:"corePath"` - channel models.DiscordChannel `json:"channel"` - version string `json:"version"` - isFlatpak bool `json:"isFlatpak"` - isSnap bool `json:"isSnap"` -} - -func (discord *DiscordInstall) GetPath() string { - return discord.corePath + CorePath string `json:"corePath"` + Channel models.DiscordChannel `json:"channel"` + Version string `json:"version"` + IsFlatpak bool `json:"isFlatpak"` + IsSnap bool `json:"isSnap"` } // InstallBD installs BetterDiscord into this Discord installation @@ -26,8 +22,8 @@ func (discord *DiscordInstall) InstallBD() error { bd := betterdiscord.GetInstallation() // Snaps get their own local BD install - if discord.isSnap { - bd = betterdiscord.GetInstallation(filepath.Clean(filepath.Join(discord.corePath, "..", "..", "..", ".."))) + if discord.IsSnap { + bd = betterdiscord.GetInstallation(filepath.Clean(filepath.Join(discord.CorePath, "..", "..", "..", ".."))) } // Make BetterDiscord folders @@ -55,7 +51,7 @@ func (discord *DiscordInstall) InstallBD() error { log.Printf("") // Terminate and restart Discord if possible - log.Printf("## Restarting %s...", discord.channel.Name()) + log.Printf("## Restarting %s...", discord.Channel.Name()) if err := discord.restart(); err != nil { return err } @@ -72,7 +68,7 @@ func (discord *DiscordInstall) UninstallBD() error { } log.Printf("") - log.Printf("## Restarting %s...", discord.channel.Name()) + log.Printf("## Restarting %s...", discord.Channel.Name()) if err := discord.restart(); err != nil { return err } @@ -91,11 +87,11 @@ func (discord *DiscordInstall) RepairBD() error { bd := betterdiscord.GetInstallation() // Snaps get their own local BD install - if discord.isSnap { - bd = betterdiscord.GetInstallation(filepath.Clean(filepath.Join(discord.corePath, "..", "..", "..", ".."))) + if discord.IsSnap { + bd = betterdiscord.GetInstallation(filepath.Clean(filepath.Join(discord.CorePath, "..", "..", "..", ".."))) } - if err := bd.Repair(discord.channel); err != nil { + if err := bd.Repair(discord.Channel); err != nil { return err } diff --git a/internal/discord/paths.go b/internal/discord/paths.go index 121c467..b31deed 100644 --- a/internal/discord/paths.go +++ b/internal/discord/paths.go @@ -18,7 +18,7 @@ func GetAllInstalls() map[models.DiscordChannel][]*DiscordInstall { for _, path := range searchPaths { if result := Validate(path); result != nil { - installs[result.channel] = append(installs[result.channel], result) + installs[result.Channel] = append(installs[result.Channel], result) } } @@ -49,7 +49,7 @@ func GetChannel(proposed string) models.DiscordChannel { func GetSuggestedPath(channel models.DiscordChannel) string { if len(allDiscordInstalls[channel]) > 0 { - return allDiscordInstalls[channel][0].corePath + return allDiscordInstalls[channel][0].CorePath } return "" } @@ -61,12 +61,12 @@ func AddCustomPath(proposed string) *DiscordInstall { } // Check if this already exists in our list and return reference - index := slices.IndexFunc(allDiscordInstalls[result.channel], func(d *DiscordInstall) bool { return d.corePath == result.corePath }) + index := slices.IndexFunc(allDiscordInstalls[result.Channel], func(d *DiscordInstall) bool { return d.CorePath == result.CorePath }) if index >= 0 { - return allDiscordInstalls[result.channel][index] + return allDiscordInstalls[result.Channel][index] } - allDiscordInstalls[result.channel] = append(allDiscordInstalls[result.channel], result) + allDiscordInstalls[result.Channel] = append(allDiscordInstalls[result.Channel], result) sortInstalls() @@ -75,7 +75,7 @@ func AddCustomPath(proposed string) *DiscordInstall { func ResolvePath(proposed string) *DiscordInstall { for channel := range allDiscordInstalls { - index := slices.IndexFunc(allDiscordInstalls[channel], func(d *DiscordInstall) bool { return d.corePath == proposed }) + index := slices.IndexFunc(allDiscordInstalls[channel], func(d *DiscordInstall) bool { return d.CorePath == proposed }) if index >= 0 { return allDiscordInstalls[channel][index] } @@ -89,9 +89,9 @@ func sortInstalls() { for channel := range allDiscordInstalls { slices.SortFunc(allDiscordInstalls[channel], func(a, b *DiscordInstall) int { switch { - case a.version > b.version: + case a.Version > b.Version: return -1 - case b.version > a.version: + case b.Version > a.Version: return 1 } return 0 diff --git a/internal/discord/paths_darwin.go b/internal/discord/paths_darwin.go index a97886e..e6f9756 100644 --- a/internal/discord/paths_darwin.go +++ b/internal/discord/paths_darwin.go @@ -67,11 +67,11 @@ func Validate(proposed string) *DiscordInstall { // If the path and the asar exist, all good if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { return &DiscordInstall{ - corePath: finalPath, - channel: GetChannel(finalPath), - version: GetVersion(finalPath), - isFlatpak: false, - isSnap: false, + CorePath: finalPath, + Channel: GetChannel(finalPath), + Version: GetVersion(finalPath), + IsFlatpak: false, + IsSnap: false, } } diff --git a/internal/discord/paths_linux.go b/internal/discord/paths_linux.go index fd4990d..1d3587c 100644 --- a/internal/discord/paths_linux.go +++ b/internal/discord/paths_linux.go @@ -85,11 +85,11 @@ func Validate(proposed string) *DiscordInstall { // If the path and the asar exist, all good if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { return &DiscordInstall{ - corePath: finalPath, - channel: GetChannel(finalPath), - version: GetVersion(finalPath), - isFlatpak: strings.Contains(finalPath, "com.discordapp."), - isSnap: strings.Contains(finalPath, "snap/"), + CorePath: finalPath, + Channel: GetChannel(finalPath), + Version: GetVersion(finalPath), + IsFlatpak: strings.Contains(finalPath, "com.discordapp."), + IsSnap: strings.Contains(finalPath, "snap/"), } } diff --git a/internal/discord/paths_windows.go b/internal/discord/paths_windows.go index b2449bd..90fda56 100644 --- a/internal/discord/paths_windows.go +++ b/internal/discord/paths_windows.go @@ -79,11 +79,11 @@ func Validate(proposed string) *DiscordInstall { // If the path and the asar exist, all good if utils.Exists(finalPath) && utils.Exists(filepath.Join(finalPath, "core.asar")) { return &DiscordInstall{ - corePath: finalPath, - channel: GetChannel(finalPath), - version: GetVersion(finalPath), - isFlatpak: false, - isSnap: false, + CorePath: finalPath, + Channel: GetChannel(finalPath), + Version: GetVersion(finalPath), + IsFlatpak: false, + IsSnap: false, } } diff --git a/internal/discord/process.go b/internal/discord/process.go index f5a8fc4..cd1bea3 100644 --- a/internal/discord/process.go +++ b/internal/discord/process.go @@ -13,38 +13,38 @@ func (discord *DiscordInstall) restart() error { exeName := discord.getFullExe() if running, _ := discord.isRunning(); !running { - log.Printf("✅ %s not running", discord.channel.Name()) + log.Printf("✅ %s not running", discord.Channel.Name()) return nil } if err := discord.kill(); err != nil { - log.Printf("❌ Unable to restart %s, please do so manually!", discord.channel.Name()) + log.Printf("❌ Unable to restart %s, please do so manually!", discord.Channel.Name()) log.Printf("❌ %s", err.Error()) return err } // Use binary found in killing process cmd := exec.Command(exeName) - if discord.isFlatpak { - cmd = exec.Command("flatpak", "run", "com.discordapp."+discord.channel.Exe()) - } else if discord.isSnap { - cmd = exec.Command("snap", "run", discord.channel.Exe()) + if discord.IsFlatpak { + cmd = exec.Command("flatpak", "run", "com.discordapp."+discord.Channel.Exe()) + } else if discord.IsSnap { + cmd = exec.Command("snap", "run", discord.Channel.Exe()) } // Set working directory to user home cmd.Dir, _ = os.UserHomeDir() if err := cmd.Start(); err != nil { - log.Printf("❌ Unable to restart %s, please do so manually!", discord.channel.Name()) + log.Printf("❌ Unable to restart %s, please do so manually!", discord.Channel.Name()) log.Printf("❌ %s", err.Error()) return err } - log.Printf("✅ Restarted %s", discord.channel.Name()) + log.Printf("✅ Restarted %s", discord.Channel.Name()) return nil } func (discord *DiscordInstall) isRunning() (bool, error) { - name := discord.channel.Exe() + name := discord.Channel.Exe() processes, err := process.Processes() // If we can't even list processes, bail out @@ -72,7 +72,7 @@ func (discord *DiscordInstall) isRunning() (bool, error) { } func (discord *DiscordInstall) kill() error { - name := discord.channel.Exe() + name := discord.Channel.Exe() processes, err := process.Processes() // If we can't even list processes, bail out @@ -105,7 +105,7 @@ func (discord *DiscordInstall) kill() error { } func (discord *DiscordInstall) getFullExe() string { - name := discord.channel.Exe() + name := discord.Channel.Exe() var exe = "" processes, err := process.Processes() From 257333c1a30b6f7db819562e33e382641f8bdaac Mon Sep 17 00:00:00 2001 From: Zerebos Date: Fri, 13 Feb 2026 17:18:54 -0500 Subject: [PATCH 06/14] Add WSL support --- internal/betterdiscord/install.go | 7 +++++++ internal/discord/paths_linux.go | 12 +++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/betterdiscord/install.go b/internal/betterdiscord/install.go index 12e85b4..d60b219 100644 --- a/internal/betterdiscord/install.go +++ b/internal/betterdiscord/install.go @@ -77,7 +77,14 @@ func GetInstallation(base ...string) *BDInstall { return globalInstance } + // Default to user config directory configDir, _ := os.UserConfigDir() + + // Handle WSL with Windows home directory + if (os.Getenv("WSL_DISTRO_NAME") != "" && os.Getenv("WIN_HOME") != "") { + configDir = filepath.Join(os.Getenv("WIN_HOME"), "AppData", "Roaming") + } + globalInstance = GetInstallation(configDir) return globalInstance diff --git a/internal/discord/paths_linux.go b/internal/discord/paths_linux.go index 1d3587c..92af61e 100644 --- a/internal/discord/paths_linux.go +++ b/internal/discord/paths_linux.go @@ -31,6 +31,11 @@ func init() { // Core: `snap/discord-canary/current/.config/discordcanary/0.0.90/modules/discord_desktop_core/core.asar`. // NOTE: Snap user data always exists, even when the Snap isn't mounted/running. filepath.Join(home, "snap", "{channel-}", "current", ".config", "{channel}"), + + // WSL. Data is stored under the Windows user's AppData folder. + // Example: `/mnt/c/Users/Username/AppData/Local/DiscordCanary`. + // Core: `/mnt/c/Users/Username/AppData/Local/DiscordCanary/app-1.0.9218/modules/discord_desktop_core-1/discord_desktop_core core.asar`. + filepath.Join(os.Getenv("WIN_HOME"), "AppData", "Local", "{CHANNEL}"), } for _, channel := range models.Channels { @@ -57,7 +62,7 @@ func init() { func Validate(proposed string) *DiscordInstall { var finalPath = "" var selected = filepath.Base(proposed) - if strings.HasPrefix(selected, "discord") { + if strings.HasPrefix(strings.ToLower(selected), "discord") { // Get version dir like 1.0.9002 var dFiles, err = os.ReadDir(proposed) if err != nil { @@ -68,6 +73,11 @@ func Validate(proposed string) *DiscordInstall { sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) var versionDir = candidates[len(candidates)-1].Name() finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core") + + // WSL installs have an extra layer + if (os.Getenv("WSL_DISTRO_NAME") != "" && os.Getenv("WIN_HOME") != "") && strings.Contains(proposed, "AppData") { + finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core-1", "discord_desktop_core") + } } if len(strings.Split(selected, ".")) == 3 { From e18046934c8f720e676204bed3cdcd18e809cca7 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 14 Feb 2026 20:20:10 -0500 Subject: [PATCH 07/14] Update releasing setup --- .github/workflows/ci.yml | 8 +-- .goreleaser.yaml | 113 +++++++++++++++++++++++++-------------- 2 files changed, 78 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a2e9de..6b577a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - go-version: ['1.19', '1.20', '1.21'] + go-version: ['1.26'] runs-on: ${{ matrix.os }} steps: - name: Checkout @@ -36,7 +36,7 @@ jobs: run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... - name: Upload coverage to Codecov - if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.21' + if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.26' uses: codecov/codecov-action@v4 with: file: ./coverage.out @@ -52,7 +52,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.26' cache: true - name: Run golangci-lint @@ -70,7 +70,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.26' cache: true - name: Build diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0bec3fb..a379889 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,6 +2,8 @@ # GoReleaser v2 configuration version: 2 +project_name: bdcli + # Before hook - runs before the build before: hooks: @@ -21,16 +23,6 @@ builds: goarch: - amd64 - arm64 - - arm - - '386' - goarm: - - '6' - - '7' - ignore: - - goos: darwin - goarch: '386' - - goos: darwin - goarch: arm ldflags: - -s -w - -X main.version={{.Version}} @@ -38,24 +30,32 @@ builds: - -X main.date={{.Date}} mod_timestamp: '{{ .CommitTimestamp }}' +# Our binaries are small, so we can skip UPX compression +# to save time and avoid potential issues with antivirus software. +# upx: + # - enabled: true + # compress: best + # lzma: true + # brute: true + archives: - id: default - format: tar.gz - name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" + formats: ["tar.gz"] + # name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' format_overrides: - goos: windows - format: zip + formats: ["zip"] files: - LICENSE - README.md checksum: - name_template: 'bdcli_checksums.txt' - algorithm: sha256 + name_template: "bdcli_checksums.txt" snapshot: version_template: "{{ incpatch .Version }}-next" + changelog: sort: asc use: github @@ -73,12 +73,10 @@ changelog: - title: 'Bug Fixes' regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' order: 1 - - title: 'Performance Improvements' - regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' - order: 2 - title: 'Others' order: 999 + release: draft: true replace_existing_draft: true @@ -88,27 +86,64 @@ release: header: | ## BetterDiscord CLI {{ .Tag }} - Install with npm: `npm install -g @betterdiscord/cli@{{ .Version }}` + Install on Windows with winget: `winget install betterdiscord.cli` + Install on Mac/Linux with brew: `brew install betterdiscord/tap/bdcli` footer: | **Full Changelog**: https://github.com/BetterDiscord/cli/compare/{{ .PreviousTag }}...{{ .Tag }} -# NPM publishing via go-npm -nfpms: - - id: packages - package_name: betterdiscord-cli - vendor: BetterDiscord - homepage: https://betterdiscord.app/ - maintainer: BetterDiscord Team - description: A cross-platform CLI for managing BetterDiscord - license: Apache-2.0 - formats: - - deb - - rpm - - apk - bindir: /usr/bin - contents: - - src: LICENSE - dst: /usr/share/doc/betterdiscord-cli/LICENSE + --- + + Released by [GoReleaser](https://github.com/goreleaser/goreleaser). + + +homebrew_casks: + - name: bdcli + description: "A cross-platform CLI for managing BetterDiscord." + homepage: "https://betterdiscord.app/" + license: "Apache-2.0" + binaries: [bdcli] + commit_author: + name: zerebos + email: 6865942+zerebos@users.noreply.github.com + repository: + owner: BetterDiscord + name: homebrew-tap + branch: bdcli-{{ .Tag }}-{{ .Now.Format "20060102150405"}} + token: "{{ .Env.GH_PAT }}" + + +winget: + - name: BetterDiscord CLI + package_identifier: betterdiscord.cli + author: BetterDiscord + release_notes: "{{ .Changelog }}" + release_notes_url: https://github.com/BetterDiscord/cli/releases/tag/{{ .Tag }} + publisher: "BetterDiscord" + publisher_url: "https://github.com/BetterDiscord" + publisher_support_url: "https://github.com/BetterDiscord/cli/issues" + license: "Apache-2.0" + license_url: "https://github.com/BetterDiscord/cli/blob/main/LICENSE" + + short_description: "A cross-platform CLI for managing BetterDiscord." + homepage: "https://betterdiscord.app/" + tags: [cli, discord, utility, betterdiscord] + + url_template: "https://github.com/BetterDiscord/cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + commit_author: + name: zerebos + email: 6865942+zerebos@users.noreply.github.com + repository: + owner: betterdiscord + name: winget-pkgs + branch: bdcli-{{ .Tag }}-{{ .Now.Format "20060102150405"}} + token: "{{ .Env.GH_PAT }}" + pull_request: + enabled: true + base: + owner: microsoft + name: winget-pkgs + branch: master + draft: false chocolateys: - name: betterdiscordcli @@ -121,7 +156,7 @@ chocolateys: copyright: 2025 BetterDiscord Limited license_url: https://github.com/BetterDiscord/cli/blob/main/LICENSE project_source_url: https://github.com/BetterDiscord/cli - docs_url: https://github.com/BetterDiscord/cli/wiki + docs_url: https://docs.betterdiscord.app/ bug_tracker_url: https://github.com/BetterDiscord/cli/issues tags: "betterdiscord cli discord" summary: A cross-platform CLI for managing BetterDiscord @@ -129,9 +164,9 @@ chocolateys: A cross-platform CLI for managing BetterDiscord. Provides commands to install, uninstall, and manage BetterDiscord on your system. release_notes: "https://github.com/BetterDiscord/cli/releases/tag/v{{ .Version }}" - skip_publish: true -# Git configuration + + git: ignore_tags: - 'nightly' From 98cf394016774ba6db73efd693357838884c9368 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 14 Feb 2026 22:16:50 -0500 Subject: [PATCH 08/14] More tooling updates --- .github/workflows/ci.yml | 61 ++------ .github/workflows/release.yml | 26 ++-- .goreleaser.yaml | 42 ++--- Taskfile.yml | 249 +++++++++++++----------------- cmd/completion.go | 31 ++++ cmd/root.go | 2 +- cmd/uninstall.go | 4 +- internal/betterdiscord/install.go | 2 +- internal/models/github.go | 10 +- internal/utils/download.go | 6 +- package-lock.json | 31 ---- package.json | 64 ++++---- 12 files changed, 230 insertions(+), 298 deletions(-) create mode 100644 cmd/completion.go delete mode 100644 package-lock.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b577a6..7de76f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,77 +8,38 @@ on: jobs: test: - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - go-version: ['1.26'] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: ${{ matrix.go-version }} + go-version: '1.26' cache: true - - name: Download dependencies - run: go mod download - - - name: Verify dependencies - run: go mod verify + - name: Install Task + uses: go-task/setup-task@v1 - - name: Run go vet - run: go vet ./... - - - name: Run tests - run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... - - - name: Upload coverage to Codecov - if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.26' - uses: codecov/codecov-action@v4 - with: - file: ./coverage.out - flags: unittests - name: codecov-umbrella + - name: Run sanity checks and tests + run: task ci lint: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: '1.26' cache: true - name: Run golangci-lint - uses: golangci/golangci-lint-action@v4 + uses: golangci/golangci-lint-action@v9 with: version: latest args: --timeout=5m - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.26' - cache: true - - - name: Build - run: go build -v ./... - - - name: Test build with GoReleaser - uses: goreleaser/goreleaser-action@v6 - with: - distribution: goreleaser - version: latest - args: build --snapshot --clean diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff4f03d..bbf12e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,7 @@ on: - 'v*' permissions: + id-token: write # Required for NPM OIDC contents: write packages: write @@ -14,18 +15,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: '1.19' + go-version: '1.26' cache: true - - name: Run tests - run: go test -v -race ./... + - name: Install Task + uses: go-task/setup-task@v1 + + - name: Run sanity checks and tests + run: task ci - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 @@ -35,9 +39,13 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_PAT: ${{ secrets.GH_PAT }} - - name: Upload artifacts - uses: actions/upload-artifact@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 with: - name: dist - path: dist/ + node-version: '24' + registry-url: 'https://registry.npmjs.org' + + - name: Publish to npm + run: npm publish diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a379889..c3b5032 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -27,7 +27,7 @@ builds: - -s -w - -X main.version={{.Version}} - -X main.commit={{.Commit}} - - -X main.date={{.Date}} + - -X main.date={{.CommitTimestamp}} mod_timestamp: '{{ .CommitTimestamp }}' # Our binaries are small, so we can skip UPX compression @@ -145,25 +145,27 @@ winget: branch: master draft: false -chocolateys: - - name: betterdiscordcli - owners: BetterDiscord - title: BetterDiscord CLI - authors: BetterDiscord - project_url: https://betterdiscord.app/ - url_template: "https://github.com/BetterDiscord/cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}" - icon_url: https://betterdiscord.app/resources/branding/logo_solid.png - copyright: 2025 BetterDiscord Limited - license_url: https://github.com/BetterDiscord/cli/blob/main/LICENSE - project_source_url: https://github.com/BetterDiscord/cli - docs_url: https://docs.betterdiscord.app/ - bug_tracker_url: https://github.com/BetterDiscord/cli/issues - tags: "betterdiscord cli discord" - summary: A cross-platform CLI for managing BetterDiscord - description: | - A cross-platform CLI for managing BetterDiscord. - Provides commands to install, uninstall, and manage BetterDiscord on your system. - release_notes: "https://github.com/BetterDiscord/cli/releases/tag/v{{ .Version }}" +# Can't do this on linux or macOS, so we'll just skip it for now. +# We can always add it back later if we want to support it. +# chocolateys: +# - name: betterdiscordcli +# owners: BetterDiscord +# title: BetterDiscord CLI +# authors: BetterDiscord +# project_url: https://betterdiscord.app/ +# url_template: "https://github.com/BetterDiscord/cli/releases/download/{{ .Tag }}/{{ .ArtifactName }}" +# icon_url: https://betterdiscord.app/resources/branding/logo_solid.png +# copyright: 2025 BetterDiscord Limited +# license_url: https://github.com/BetterDiscord/cli/blob/main/LICENSE +# project_source_url: https://github.com/BetterDiscord/cli +# docs_url: https://docs.betterdiscord.app/ +# bug_tracker_url: https://github.com/BetterDiscord/cli/issues +# tags: "betterdiscord cli discord" +# summary: A cross-platform CLI for managing BetterDiscord +# description: | +# A cross-platform CLI for managing BetterDiscord. +# Provides commands to install, uninstall, and manage BetterDiscord on your system. +# release_notes: "https://github.com/BetterDiscord/cli/releases/tag/v{{ .Version }}" diff --git a/Taskfile.yml b/Taskfile.yml index 939d81f..bbf4d5f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -5,12 +5,14 @@ vars: BINARY_NAME: bdcli MAIN_PACKAGE: ./main.go BUILD_DIR: ./dist - GO_VERSION: '1.19' + GO_VERSION: '1.26' + VERSION: + sh: git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-dev" + COMMIT: + sh: git rev-parse --short HEAD 2>/dev/null || echo "none" + BUILD_DATE: + sh: date -u +%Y-%m-%dT%H:%M:%SZ -env: - CGO_ENABLED: 0 - GOOS: '{{OS}}' - GOARCH: '{{ARCH}}' tasks: default: @@ -22,33 +24,18 @@ tasks: # Development tasks run: desc: Run the CLI application + vars: + LDFLAGS: '-X main.version={{.VERSION}}-dev -X main.commit={{.COMMIT}} -X main.date={{.BUILD_DATE}}' cmds: - go run {{.MAIN_PACKAGE}} {{.CLI_ARGS}} - sources: - - '**/*.go' - - go.mod - - go.sum - - run:install: - desc: Run the install command - cmds: - - go run {{.MAIN_PACKAGE}} install {{.CLI_ARGS}} - - run:uninstall: - desc: Run the uninstall command - cmds: - - go run {{.MAIN_PACKAGE}} uninstall {{.CLI_ARGS}} - - run:version: - desc: Run the version command - cmds: - - go run {{.MAIN_PACKAGE}} version {{.CLI_ARGS}} # Build tasks build: desc: Build the binary for current platform + vars: + LDFLAGS: '-X main.version={{.VERSION}}-dev -X main.commit={{.COMMIT}} -X main.date={{.BUILD_DATE}}' cmds: - - go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}{{if eq OS "windows"}}.exe{{end}} {{.MAIN_PACKAGE}} + - go build -ldflags "{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.BINARY_NAME}}{{if eq OS "windows"}}.exe{{end}} {{.MAIN_PACKAGE}} sources: - '**/*.go' - go.mod @@ -59,56 +46,70 @@ tasks: build:all: desc: Build binaries for all platforms using GoReleaser cmds: - - goreleaser build --snapshot --clean + - goreleaser build --snapshot --clean --skip=publish sources: - '**/*.go' - go.mod - go.sum - .goreleaser.yaml - build:linux: - desc: Build for Linux (amd64 and arm64) + # Testing tasks + test: + desc: Run all tests cmds: - - GOOS=linux GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-amd64 {{.MAIN_PACKAGE}} - - GOOS=linux GOARCH=arm64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-arm64 {{.MAIN_PACKAGE}} + - go test ./... - build:windows: - desc: Build for Windows (amd64 and arm64) + test:verbose: + desc: Run all tests with verbose output cmds: - - GOOS=windows GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-windows-amd64.exe {{.MAIN_PACKAGE}} - - GOOS=windows GOARCH=arm64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-windows-arm64.exe {{.MAIN_PACKAGE}} + - go test -v ./... - build:darwin: - desc: Build for macOS (amd64 and arm64) + # Benchmarking tasks + bench: + desc: Run all benchmarks cmds: - - GOOS=darwin GOARCH=amd64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-darwin-amd64 {{.MAIN_PACKAGE}} - - GOOS=darwin GOARCH=arm64 go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-darwin-arm64 {{.MAIN_PACKAGE}} + - go test -bench=. -benchmem ./... - install: - desc: Install the binary to $GOPATH/bin - deps: [build] + bench:cpu: + desc: Run benchmarks with CPU profiling cmds: - - go install {{.MAIN_PACKAGE}} + - go test -bench=. -benchmem -cpuprofile=debug/cpu.prof + - echo "CPU profile saved to debug/cpu.prof" + - "echo 'View with: go tool pprof debug/cpu.prof'" - # Testing tasks - test: - desc: Run all tests + bench:mem: + desc: Run benchmarks with memory profiling cmds: - - go test -v -race ./... + - go test -bench=. -benchmem -memprofile=debug/mem.prof + - echo "Memory profile saved to debug/mem.prof" + - "echo 'View with: go tool pprof debug/mem.prof'" - test:coverage: - desc: Run tests with coverage + # Coverage tasks + coverage: + desc: Run tests with coverage report cmds: - - go test -v -race -coverprofile=coverage.out -covermode=atomic ./... - - go tool cover -html=coverage.out -o coverage.html - generates: - - coverage.out - - coverage.html + - go test -cover ./... - test:bench: - desc: Run benchmark tests + coverage:html: + desc: Generate HTML coverage report cmds: - - go test -bench=. -benchmem ./... + - go test -coverprofile=debug/coverage.out ./... + - go tool cover -html=debug/coverage.out -o debug/coverage.html + - echo "Coverage report generated at debug/coverage.html" + + coverage:func: + desc: Show coverage by function + cmds: + - go test -coverprofile=debug/coverage.out ./... + - go tool cover -func=debug/coverage.out + + coverage:detailed: + desc: Run coverage with detailed output and open HTML report + cmds: + - go test -coverprofile=debug/coverage.out -covermode=atomic ./... + - go tool cover -func=debug/coverage.out + - go tool cover -html=debug/coverage.out -o debug/coverage.html + - echo "Coverage report generated at debug/coverage.html" # Code quality tasks lint: @@ -121,67 +122,21 @@ tasks: cmds: - golangci-lint run --fix ./... + fix: + desc: Fix and modernize all Go files + cmds: + - go fix ./... + fmt: - desc: Format Go code + desc: Format all Go files cmds: - go fmt ./... - - gofumpt -l -w . - preconditions: - - sh: command -v gofumpt - msg: 'gofumpt not installed. Run: go install mvdan.cc/gofumpt@latest' - - fmt:check: - desc: Check if code is formatted - cmds: - - | - UNFORMATTED=$(gofmt -l .) - if [ -n "$UNFORMATTED" ]; then - echo "The following files are not gofmt-formatted:" >&2 - echo "$UNFORMATTED" >&2 - exit 1 - fi - # Also check gofumpt stricter formatting if available - if command -v gofumpt >/dev/null 2>&1; then - STRICT=$(gofumpt -l .) - if [ -n "$STRICT" ]; then - echo "The following files need gofumpt formatting:" >&2 - echo "$STRICT" >&2 - echo "Run: gofumpt -l -w ." >&2 - exit 1 - fi - fi - echo "Formatting OK" - preconditions: - - sh: command -v gofmt - msg: 'gofmt not found' vet: desc: Run go vet cmds: - go vet ./... - # Dependency management - deps: - desc: Download Go dependencies - cmds: - - go mod download - - deps:tidy: - desc: Tidy Go dependencies - cmds: - - go mod tidy - - deps:verify: - desc: Verify Go dependencies - cmds: - - go mod verify - - deps:upgrade: - desc: Upgrade all Go dependencies - cmds: - - go get -u ./... - - go mod tidy - # Release tasks release: desc: Create a new release (runs GoReleaser) @@ -196,7 +151,7 @@ tasks: release:snapshot: desc: Create a snapshot release (no publish) cmds: - - goreleaser release --snapshot --clean + - goreleaser release --snapshot --clean --skip=publish release:test: desc: Test the release process without publishing @@ -211,18 +166,29 @@ tasks: - sh: test -f package.json msg: 'package.json not found' - # Cleanup tasks - clean: - desc: Clean build artifacts and caches + # CI/CD tasks + ci: + desc: Run CI checks (used in CI/CD pipelines) cmds: - - rm -rf {{.BUILD_DIR}} - - rm -rf coverage.out coverage.html - - go clean -cache -testcache -modcache + - task: deps + - task: check + - task: test:coverage + - task: build - clean:build: - desc: Clean only build artifacts + # Dependency management + deps: + desc: Tidy, download, and verify Go dependencies cmds: - - rm -rf {{.BUILD_DIR}} + - go mod tidy + - go mod download + - go mod verify + + deps:upgrade: + desc: Upgrade all Go dependencies + cmds: + - go get -u ./... + - go mod tidy + - go mod verify # Setup and validation tasks setup: @@ -235,13 +201,13 @@ tasks: desc: Install development tools cmds: - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - - go install mvdan.cc/gofumpt@latest - go install github.com/goreleaser/goreleaser/v2@latest check: - desc: Run all checks (fmt, vet, lint, test) + desc: Run all checks (fix, fmt, vet, lint, test) cmds: - - task: fmt:check + - task: fix + - task: fmt - task: vet - task: lint - task: test @@ -254,32 +220,25 @@ tasks: - sh: command -v goreleaser msg: 'goreleaser not installed. See: https://goreleaser.com/install/' - # CI/CD tasks - ci: - desc: Run CI checks (used in CI/CD pipelines) + # Clean tasks + clean: + desc: Clean all generated files cmds: - - task: deps:verify - - task: fmt:check - - task: vet - - task: test:coverage - - task: build + - task: clean:build + - task: clean:coverage + - task: clean:profiles - # Documentation tasks - docs: - desc: Generate CLI documentation + clean:build: + desc: Remove build directory cmds: - - go run {{.MAIN_PACKAGE}} --help > docs/CLI.md - - echo "CLI documentation generated in docs/CLI.md" + - rm -rf {{.BUILD_DIR}} - # Info tasks - info: - desc: Display project information + clean:coverage: + desc: Remove coverage files cmds: - - echo "Binary Name:{{.BINARY_NAME}}" - - echo "Go Version:{{.GO_VERSION}}" - - echo "Current OS:{{OS}}" - - echo "Current Arch:{{ARCH}}" - - go version - - echo "Git Branch:" && git branch --show-current - - echo "Git Commit:" && git rev-parse --short HEAD - silent: true + - rm -f debug/coverage.out debug/coverage.html + + clean:profiles: + desc: Remove profiling files + cmds: + - rm -f debug/cpu.prof debug/mem.prof diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..9c627b1 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(completionCmd) +} + +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completions", + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return rootCmd.GenBashCompletion(os.Stdout) + case "zsh": + return rootCmd.GenZshCompletion(os.Stdout) + case "fish": + return rootCmd.GenFishCompletion(os.Stdout, true) + case "powershell": + return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout) + } + return nil + }, +} diff --git a/cmd/root.go b/cmd/root.go index b74e7df..1096285 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,7 +14,7 @@ func init() { var rootCmd = &cobra.Command{ Use: "bdcli", Short: "CLI for managing BetterDiscord", - Long: `A cross-platform CLI for installing, updating, and managing BetterDiscord.`, + Long: `A cross-platform CLI for installing, updating, and managing BetterDiscord.`, Run: func(cmd *cobra.Command, args []string) { // Do Stuff Here fmt.Println("You should probably use a subcommand") diff --git a/cmd/uninstall.go b/cmd/uninstall.go index a578c7c..3456681 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -31,17 +31,15 @@ var uninstallCmd = &cobra.Command{ } var install *discord.DiscordInstall - var resolvedPath string if pathProvided { - resolvedPath = pathFlag install = discord.ResolvePath(pathFlag) if install == nil { return fmt.Errorf("could not find a valid Discord installation at %s", pathFlag) } } else { channel := models.ParseChannel(channelFlag) - resolvedPath = discord.GetSuggestedPath(channel) + resolvedPath := discord.GetSuggestedPath(channel) install = discord.ResolvePath(resolvedPath) if install == nil { return fmt.Errorf("could not find a valid %s installation to uninstall", channelFlag) diff --git a/internal/betterdiscord/install.go b/internal/betterdiscord/install.go index d60b219..9e2fbb9 100644 --- a/internal/betterdiscord/install.go +++ b/internal/betterdiscord/install.go @@ -81,7 +81,7 @@ func GetInstallation(base ...string) *BDInstall { configDir, _ := os.UserConfigDir() // Handle WSL with Windows home directory - if (os.Getenv("WSL_DISTRO_NAME") != "" && os.Getenv("WIN_HOME") != "") { + if os.Getenv("WSL_DISTRO_NAME") != "" && os.Getenv("WIN_HOME") != "" { configDir = filepath.Join(os.Getenv("WIN_HOME"), "AppData", "Roaming") } diff --git a/internal/models/github.go b/internal/models/github.go index a9b3c1a..6ef2529 100644 --- a/internal/models/github.go +++ b/internal/models/github.go @@ -39,11 +39,11 @@ type GitHubRelease struct { CreatedAt time.Time `json:"created_at"` PublishedAt time.Time `json:"published_at"` Assets []struct { - URL string `json:"url"` - ID int `json:"id"` - NodeID string `json:"node_id"` - Name string `json:"name"` - Label interface{} `json:"label"` + URL string `json:"url"` + ID int `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + Label any `json:"label"` Uploader struct { Login string `json:"login"` ID int `json:"id"` diff --git a/internal/utils/download.go b/internal/utils/download.go index eb12f0a..afbc72c 100644 --- a/internal/utils/download.go +++ b/internal/utils/download.go @@ -73,7 +73,11 @@ func DownloadJSON[T any](url string) (T, error) { return data, fmt.Errorf("Bad status: %s", resp.Status) } - json.NewDecoder(resp.Body).Decode(&data) + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(&data) + if err != nil { + return data, err + } return data, nil } diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 6d86453..0000000 --- a/package-lock.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@betterdiscord/cli", - "version": "0.1.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "@betterdiscord/cli", - "version": "0.1.0", - "license": "Apache-2.0", - "dependencies": { - "@go-task/go-npm": "^0.1.17" - } - }, - "node_modules/@go-task/go-npm": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/@go-task/go-npm/-/go-npm-0.1.17.tgz", - "integrity": "sha512-j+xydQWrAxsqLYjweok1fWzDmBAA1g/gmFbPyG8kRI/d/+rzXGGLlro8zdS6mJ3Is+8BrIy2ZBmQkoONhowh7A==", - "bin": { - "go-npm": "bin/index.js" - } - } - }, - "dependencies": { - "@go-task/go-npm": { - "version": "0.1.17", - "resolved": "https://registry.npmjs.org/@go-task/go-npm/-/go-npm-0.1.17.tgz", - "integrity": "sha512-j+xydQWrAxsqLYjweok1fWzDmBAA1g/gmFbPyG8kRI/d/+rzXGGLlro8zdS6mJ3Is+8BrIy2ZBmQkoONhowh7A==" - } - } -} diff --git a/package.json b/package.json index 2a50de9..f9dee95 100644 --- a/package.json +++ b/package.json @@ -1,32 +1,32 @@ -{ - "name": "@betterdiscord/cli", - "version": "0.1.0", - "description": "A cross-platform CLI for managing BetterDiscord", - "main": "index.js", - "publishConfig": { - "access": "public" - }, - "files": [], - "scripts": { - "postinstall": "go-npm install", - "preuninstall": "go-npm uninstall" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/BetterDiscord/cli.git" - }, - "author": "BetterDiscord Team", - "license": "Apache-2.0", - "bugs": { - "url": "https://github.com/BetterDiscord/cli/issues" - }, - "homepage": "https://github.com/BetterDiscord/cli#readme", - "dependencies": { - "@go-task/go-npm": "^0.1.17" - }, - "goBinary": { - "name": "bdcli", - "path": "./bin", - "url": "https://github.com/BetterDiscord/cli/releases/download/v{{version}}/bdcli_{{platform}}_{{arch}}{{archive_ext}}" - } -} +{ + "name": "@betterdiscord/cli", + "version": "0.1.0", + "description": "A cross-platform CLI for managing BetterDiscord", + "main": "index.js", + "publishConfig": { + "access": "public" + }, + "files": [], + "scripts": { + "postinstall": "go-npm install", + "preuninstall": "go-npm uninstall" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/BetterDiscord/cli.git" + }, + "author": "BetterDiscord Team", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/BetterDiscord/cli/issues" + }, + "homepage": "https://github.com/BetterDiscord/cli#readme", + "dependencies": { + "@go-task/go-npm": "^0.2.0" + }, + "goBinary": { + "name": "bdcli", + "path": "./bin", + "url": "https://github.com/BetterDiscord/cli/releases/download/v{{version}}/bdcli_{{platform}}_{{arch}}{{archive_ext}}" + } +} From be2425cc2813b3c50e9fb5525047c73b21fad859 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 14 Feb 2026 22:32:27 -0500 Subject: [PATCH 09/14] fix: check errors on download --- Taskfile.yml | 4 +-- go.mod | 22 +++++++-------- go.sum | 57 +++++++++++++++++++++----------------- internal/discord/paths.go | 4 +-- internal/utils/download.go | 20 ++++++++++--- 5 files changed, 62 insertions(+), 45 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index bbf4d5f..1805187 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -200,8 +200,8 @@ tasks: tools: desc: Install development tools cmds: - - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - - go install github.com/goreleaser/goreleaser/v2@latest + - brew install golangci-lint + - brew install goreleaser check: desc: Run all checks (fix, fmt, vet, lint, test) diff --git a/go.mod b/go.mod index 07882f9..a087e18 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,21 @@ module github.com/betterdiscord/cli -go 1.19 +go 1.24.0 require ( github.com/shirou/gopsutil/v3 v3.24.5 - github.com/spf13/cobra v1.6.1 + github.com/spf13/cobra v1.10.2 ) require ( - github.com/go-ole/go-ole v1.2.6 // indirect - github.com/inconshreveable/mousetrap v1.0.1 // indirect - github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/tklauser/go-sysconf v0.3.12 // indirect - github.com/tklauser/numcpus v0.6.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shoenig/go-m1cpu v0.1.7 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/sys v0.20.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 0e4e7c9..8cc13f0 100644 --- a/go.sum +++ b/go.sum @@ -1,40 +1,45 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= -github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= -github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0= +github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= +github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/discord/paths.go b/internal/discord/paths.go index b31deed..f688530 100644 --- a/internal/discord/paths.go +++ b/internal/discord/paths.go @@ -28,7 +28,7 @@ func GetAllInstalls() map[models.DiscordChannel][]*DiscordInstall { } func GetVersion(proposed string) string { - for _, folder := range strings.Split(proposed, string(filepath.Separator)) { + for folder := range strings.SplitSeq(proposed, string(filepath.Separator)) { if version := versionRegex.FindString(folder); version != "" { return version } @@ -37,7 +37,7 @@ func GetVersion(proposed string) string { } func GetChannel(proposed string) models.DiscordChannel { - for _, folder := range strings.Split(proposed, string(filepath.Separator)) { + for folder := range strings.SplitSeq(proposed, string(filepath.Separator)) { for _, channel := range models.Channels { if strings.ToLower(folder) == strings.ReplaceAll(strings.ToLower(channel.Name()), " ", "") { return channel diff --git a/internal/utils/download.go b/internal/utils/download.go index afbc72c..dc0a6f1 100644 --- a/internal/utils/download.go +++ b/internal/utils/download.go @@ -20,7 +20,11 @@ func DownloadFile(url string, filepath string) (response *http.Response, err err if err != nil { return nil, err } - defer out.Close() + defer func() { + if cerr := out.Close(); cerr != nil && err == nil { + err = cerr + } + }() // Setup the request req, err := http.NewRequest("GET", url, nil) @@ -35,7 +39,11 @@ func DownloadFile(url string, filepath string) (response *http.Response, err err if err != nil { return resp, err } - defer resp.Body.Close() + defer func() { + if cerr := resp.Body.Close(); cerr != nil && err == nil { + err = cerr + } + }() // Check server response if resp.StatusCode != http.StatusOK { @@ -66,11 +74,15 @@ func DownloadJSON[T any](url string) (T, error) { if err != nil { return data, err } - defer resp.Body.Close() + defer func() { + if cerr := resp.Body.Close(); cerr != nil && err == nil { + err = cerr + } + }() // Check server response if resp.StatusCode != http.StatusOK { - return data, fmt.Errorf("Bad status: %s", resp.Status) + return data, fmt.Errorf("bad status: %s", resp.Status) } decoder := json.NewDecoder(resp.Body) From 7ddd5e5c8d34a7789f326e134bf5c6700cddfbcc Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 14 Feb 2026 22:40:26 -0500 Subject: [PATCH 10/14] fix: fix ci doing double linting --- .github/workflows/ci.yml | 14 +------------- Taskfile.yml | 4 +++- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7de76f9..cd6f03c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: branches: [main] jobs: - test: + test-and-lint: runs-on: ubuntu-latest steps: - name: Checkout @@ -25,18 +25,6 @@ jobs: - name: Run sanity checks and tests run: task ci - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: '1.26' - cache: true - - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: diff --git a/Taskfile.yml b/Taskfile.yml index 1805187..a11d25a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -171,7 +171,9 @@ tasks: desc: Run CI checks (used in CI/CD pipelines) cmds: - task: deps - - task: check + - task: fix + - task: fmt + - task: vet - task: test:coverage - task: build From 5ab80e7843c917715ee2ea734337f3c70fbf1fad Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 14 Feb 2026 22:43:52 -0500 Subject: [PATCH 11/14] Go tooling is absurd --- Taskfile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Taskfile.yml b/Taskfile.yml index a11d25a..4154ec4 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -174,7 +174,7 @@ tasks: - task: fix - task: fmt - task: vet - - task: test:coverage + - task: coverage - task: build # Dependency management From fb66969fbbcee0e998a2c30b5336e95a2c451eca Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sun, 15 Feb 2026 00:21:28 -0500 Subject: [PATCH 12/14] feat: add testing and completions --- .gitignore | 2 + .goreleaser.yaml | 23 +- README.md | 100 ++++++-- internal/betterdiscord/download.go | 12 +- internal/betterdiscord/install_test.go | 322 +++++++++++++++++++++++++ internal/discord/paths_darwin.go | 3 + internal/discord/paths_linux.go | 3 + internal/discord/paths_test.go | 298 +++++++++++++++++++++++ internal/discord/paths_windows.go | 11 +- internal/discord/process.go | 11 +- internal/models/channel_test.go | 251 +++++++++++++++++++ internal/utils/download_test.go | 230 ++++++++++++++++++ internal/utils/paths.go | 2 +- internal/utils/paths_test.go | 147 +++++++++++ scripts/completions.sh | 7 + 15 files changed, 1388 insertions(+), 34 deletions(-) create mode 100644 internal/betterdiscord/install_test.go create mode 100644 internal/discord/paths_test.go create mode 100644 internal/models/channel_test.go create mode 100644 internal/utils/download_test.go create mode 100644 internal/utils/paths_test.go create mode 100644 scripts/completions.sh diff --git a/.gitignore b/.gitignore index 19fd4c5..d39318b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ dist/ .task/ notes/ +debug/ +completions/ # Test coverage coverage.out diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c3b5032..8df1668 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,6 +9,7 @@ before: hooks: - go mod tidy - go mod download + - sh ./scripts/completions.sh builds: - id: bdcli @@ -39,15 +40,23 @@ builds: # brute: true archives: - - id: default - formats: ["tar.gz"] + - formats: ["tar.gz"] # name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' format_overrides: - goos: windows formats: ["zip"] + builds_info: + mtime: "{{ .CommitDate }}" files: - - LICENSE - - README.md + - src: README.md + info: + mtime: "{{ .CommitDate }}" + - src: LICENSE + info: + mtime: "{{ .CommitDate }}" + - src: completions/* + info: + mtime: "{{ .CommitDate }}" checksum: name_template: "bdcli_checksums.txt" @@ -97,11 +106,15 @@ release: homebrew_casks: - - name: bdcli + - name: cli description: "A cross-platform CLI for managing BetterDiscord." homepage: "https://betterdiscord.app/" license: "Apache-2.0" binaries: [bdcli] + completions: + bash: "completions/bdcli.bash" + zsh: "completions/bdcli.zsh" + fish: "completions/bdcli.fish" commit_author: name: zerebos email: 6865942+zerebos@users.noreply.github.com diff --git a/README.md b/README.md index b6673c3..1372c34 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,18 @@ npm install -g @betterdiscord/cli go install github.com/betterdiscord/cli@latest ``` +### Via winget (Windows) + +```bash +winget install betterdiscord.cli +``` + +### Via Homebrew/Linuxbrew + +```bash +brew install betterdiscord/tap/bdcli +``` + ### Download Binary Download the latest release for your platform from the [releases page](https://github.com/BetterDiscord/cli/releases). @@ -40,9 +52,15 @@ Download the latest release for your platform from the [releases page](https://g Install BetterDiscord to a specific Discord channel: ```bash -bdcli install stable # Install to Discord Stable -bdcli install ptb # Install to Discord PTB -bdcli install canary # Install to Discord Canary +bdcli install --channel stable # Install to Discord Stable +bdcli install --channel ptb # Install to Discord PTB +bdcli install --channel canary # Install to Discord Canary +``` + +Install BetterDiscord by providing a Discord install path: + +```bash +bdcli install --path /path/to/Discord ``` ### Uninstall BetterDiscord @@ -50,9 +68,15 @@ bdcli install canary # Install to Discord Canary Uninstall BetterDiscord from a specific Discord channel: ```bash -bdcli uninstall stable # Uninstall from Discord Stable -bdcli uninstall ptb # Uninstall from Discord PTB -bdcli uninstall canary # Uninstall from Discord Canary +bdcli uninstall --channel stable # Uninstall from Discord Stable +bdcli uninstall --channel ptb # Uninstall from Discord PTB +bdcli uninstall --channel canary # Uninstall from Discord Canary +``` + +Uninstall BetterDiscord by providing a Discord install path: + +```bash +bdcli uninstall --path /path/to/Discord ``` ### Check Version @@ -61,11 +85,41 @@ bdcli uninstall canary # Uninstall from Discord Canary bdcli version ``` +### Shell Completions + +```bash +bdcli completion bash +bdcli completion zsh +bdcli completion fish +``` + ### Help ```bash bdcli --help -bdcli --help +bdcli [command] --help +``` + +### CLI Help Output + +``` +A cross-platform CLI for installing, updating, and managing BetterDiscord. + +Usage: + bdcli [flags] + bdcli [command] + +Available Commands: + completion Generate shell completions + help Help about any command + install Installs BetterDiscord to your Discord + uninstall Uninstalls BetterDiscord from your Discord + version Print the version number + +Flags: + -h, --help help for bdcli + +Use "bdcli [command] --help" for more information about a command. ``` ## Supported Platforms @@ -78,7 +132,7 @@ bdcli --help ### Prerequisites -- [Go](https://go.dev/) 1.19 or higher +- [Go](https://go.dev/) 1.26 or higher - [Task](https://taskfile.dev/) (optional, for task automation) - [GoReleaser](https://goreleaser.com/) (for releases) @@ -94,32 +148,34 @@ task setup # Or: go mod download ### Available Tasks -Run `task --list` to see all available tasks: +Run `task --list-all` to see all available tasks: ```bash # Development -task run # Run the CLI -task run:install # Test install command -task run:uninstall # Test uninstall command +task run # Run the CLI (pass args with: task run -- install stable) # Building -task build # Build for current platform -task build:all # Build for all platforms -task install # Install to $GOPATH/bin +task build # Build for current platform +task build:all # Build for all platforms (GoReleaser) # Testing -task test # Run tests -task test:coverage # Run tests with coverage +task test # Run tests +task test:verbose # Run tests with verbose output +task coverage # Run tests with coverage summary +task coverage:html # Generate HTML coverage report # Code Quality -task lint # Run linter -task fmt # Format code -task vet # Run go vet -task check # Run all checks +task fmt # Format Go files +task vet # Run go vet +task lint # Run golangci-lint +task check # Run fix, fmt, vet, lint, test # Release task release:snapshot # Test release build task release # Create release (requires tag) + +# Cleaning +task clean # Remove build and debug artifacts ``` ### Running Locally @@ -151,7 +207,7 @@ task build:all task test # Run with coverage -task test:coverage +task coverage ``` ### Releasing diff --git a/internal/betterdiscord/download.go b/internal/betterdiscord/download.go index 757fdc3..9d8e1f2 100644 --- a/internal/betterdiscord/download.go +++ b/internal/betterdiscord/download.go @@ -1,6 +1,7 @@ package betterdiscord import ( + "fmt" "log" "github.com/betterdiscord/cli/internal/models" @@ -34,14 +35,19 @@ func (i *BDInstall) download() error { return err } - var index = 0 - for i, asset := range apiData.Assets { + var index = -1 + for idx, asset := range apiData.Assets { if asset.Name == "betterdiscord.asar" { - index = i + index = idx break } } + if index == -1 { + log.Printf("❌ Failed to find the BetterDiscord asar on GitHub") + return fmt.Errorf("failed to find betterdiscord.asar asset in GitHub release") + } + var downloadUrl = apiData.Assets[index].URL var version = apiData.TagName diff --git a/internal/betterdiscord/install_test.go b/internal/betterdiscord/install_test.go new file mode 100644 index 0000000..8e0dfe9 --- /dev/null +++ b/internal/betterdiscord/install_test.go @@ -0,0 +1,322 @@ +package betterdiscord + +import ( + "os" + "path/filepath" + "testing" + + "github.com/betterdiscord/cli/internal/models" +) + +func TestNew(t *testing.T) { + rootPath := "/test/root/BetterDiscord" + install := New(rootPath) + + if install.Root() != rootPath { + t.Errorf("Root() = %s, expected %s", install.Root(), rootPath) + } + + expectedData := filepath.Join(rootPath, "data") + if install.Data() != expectedData { + t.Errorf("Data() = %s, expected %s", install.Data(), expectedData) + } + + expectedAsar := filepath.Join(rootPath, "data", "betterdiscord.asar") + if install.Asar() != expectedAsar { + t.Errorf("Asar() = %s, expected %s", install.Asar(), expectedAsar) + } + + expectedPlugins := filepath.Join(rootPath, "plugins") + if install.Plugins() != expectedPlugins { + t.Errorf("Plugins() = %s, expected %s", install.Plugins(), expectedPlugins) + } + + expectedThemes := filepath.Join(rootPath, "themes") + if install.Themes() != expectedThemes { + t.Errorf("Themes() = %s, expected %s", install.Themes(), expectedThemes) + } + + if install.HasDownloaded() { + t.Error("HasDownloaded() should be false for new install") + } +} + +func TestBDInstall_GettersSetters(t *testing.T) { + rootPath := "/test/path" + install := New(rootPath) + + // Test all getters return expected paths + tests := []struct { + name string + getter func() string + expected string + }{ + {"Root", install.Root, rootPath}, + {"Data", install.Data, filepath.Join(rootPath, "data")}, + {"Asar", install.Asar, filepath.Join(rootPath, "data", "betterdiscord.asar")}, + {"Plugins", install.Plugins, filepath.Join(rootPath, "plugins")}, + {"Themes", install.Themes, filepath.Join(rootPath, "themes")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.getter() + if result != tt.expected { + t.Errorf("%s() = %s, expected %s", tt.name, result, tt.expected) + } + }) + } +} + +func TestBDInstall_Prepare(t *testing.T) { + // Create temporary directory for testing + tmpDir := t.TempDir() + bdRoot := filepath.Join(tmpDir, "BetterDiscord") + + install := New(bdRoot) + + // Prepare should create all necessary directories + err := install.Prepare() + if err != nil { + t.Fatalf("Prepare() failed: %v", err) + } + + // Verify directories were created + dirs := []string{ + install.Data(), + install.Plugins(), + install.Themes(), + } + + for _, dir := range dirs { + if _, err := os.Stat(dir); os.IsNotExist(err) { + t.Errorf("Directory not created: %s", dir) + } + } +} + +func TestBDInstall_Prepare_AlreadyExists(t *testing.T) { + // Create temporary directory with existing structure + tmpDir := t.TempDir() + bdRoot := filepath.Join(tmpDir, "BetterDiscord") + + install := New(bdRoot) + + // Create directories manually first + os.MkdirAll(install.Data(), 0755) + os.MkdirAll(install.Plugins(), 0755) + os.MkdirAll(install.Themes(), 0755) + + // Prepare should succeed even if directories already exist + err := install.Prepare() + if err != nil { + t.Fatalf("Prepare() failed when directories already exist: %v", err) + } + + // Verify directories still exist + dirs := []string{ + install.Data(), + install.Plugins(), + install.Themes(), + } + + for _, dir := range dirs { + if _, err := os.Stat(dir); os.IsNotExist(err) { + t.Errorf("Directory should exist: %s", dir) + } + } +} + +func TestBDInstall_Repair(t *testing.T) { + // Create temporary directory for testing + tmpDir := t.TempDir() + bdRoot := filepath.Join(tmpDir, "BetterDiscord") + + install := New(bdRoot) + + // Create the data directory and a test plugins.json file + channelFolder := filepath.Join(install.Data(), models.Stable.String()) + os.MkdirAll(channelFolder, 0755) + + pluginsJson := filepath.Join(channelFolder, "plugins.json") + err := os.WriteFile(pluginsJson, []byte(`{"test": "data"}`), 0644) + if err != nil { + t.Fatalf("Failed to create test plugins.json: %v", err) + } + + // Verify file exists before repair + if _, err := os.Stat(pluginsJson); os.IsNotExist(err) { + t.Fatal("plugins.json should exist before repair") + } + + // Run repair + err = install.Repair(models.Stable) + if err != nil { + t.Fatalf("Repair() failed: %v", err) + } + + // Verify file was removed + if _, err := os.Stat(pluginsJson); !os.IsNotExist(err) { + t.Error("plugins.json should be removed after repair") + } +} + +func TestBDInstall_Repair_NoPluginsFile(t *testing.T) { + // Create temporary directory for testing + tmpDir := t.TempDir() + bdRoot := filepath.Join(tmpDir, "BetterDiscord") + + install := New(bdRoot) + + // Don't create any files - repair should succeed without error + err := install.Repair(models.Stable) + if err != nil { + t.Fatalf("Repair() should succeed when plugins.json doesn't exist: %v", err) + } +} + +func TestBDInstall_Repair_MultipleChannels(t *testing.T) { + // Create temporary directory for testing + tmpDir := t.TempDir() + bdRoot := filepath.Join(tmpDir, "BetterDiscord") + + install := New(bdRoot) + + // Create plugins.json for multiple channels + channels := []models.DiscordChannel{models.Stable, models.Canary, models.PTB} + pluginsFiles := make(map[models.DiscordChannel]string) + + for _, channel := range channels { + channelFolder := filepath.Join(install.Data(), channel.String()) + os.MkdirAll(channelFolder, 0755) + + pluginsJson := filepath.Join(channelFolder, "plugins.json") + os.WriteFile(pluginsJson, []byte(`{}`), 0644) + pluginsFiles[channel] = pluginsJson + } + + // Repair only Stable channel + err := install.Repair(models.Stable) + if err != nil { + t.Fatalf("Repair(Stable) failed: %v", err) + } + + // Verify only Stable's plugins.json was removed + if _, err := os.Stat(pluginsFiles[models.Stable]); !os.IsNotExist(err) { + t.Error("Stable plugins.json should be removed") + } + + // Verify other channels' files still exist + if _, err := os.Stat(pluginsFiles[models.Canary]); os.IsNotExist(err) { + t.Error("Canary plugins.json should still exist") + } + if _, err := os.Stat(pluginsFiles[models.PTB]); os.IsNotExist(err) { + t.Error("PTB plugins.json should still exist") + } +} + +func TestGetInstallation_WithBase(t *testing.T) { + basePath := "/test/config" + install := GetInstallation(basePath) + + expectedRoot := filepath.Join(basePath, "BetterDiscord") + if install.Root() != expectedRoot { + t.Errorf("GetInstallation(%s).Root() = %s, expected %s", basePath, install.Root(), expectedRoot) + } +} + +func TestGetInstallation_Singleton(t *testing.T) { + // Reset the global instance to ensure clean test + // Note: In a real test, you might want to add a reset function + // For now, we'll just test that multiple calls work + + install1 := GetInstallation() + install2 := GetInstallation() + + // Both should return the same instance (singleton pattern) + if install1 != install2 { + t.Error("GetInstallation() should return the same instance (singleton)") + } + + // Both should have the same root path + if install1.Root() != install2.Root() { + t.Errorf("Singleton instances have different roots: %s vs %s", install1.Root(), install2.Root()) + } +} + +func TestMakeDirectory(t *testing.T) { + tmpDir := t.TempDir() + testDir := filepath.Join(tmpDir, "test", "nested", "directory") + + // Make the directory + err := makeDirectory(testDir) + if err != nil { + t.Fatalf("makeDirectory() failed: %v", err) + } + + // Verify it exists + if _, err := os.Stat(testDir); os.IsNotExist(err) { + t.Error("Directory was not created") + } + + // Test making the same directory again (should not error) + err = makeDirectory(testDir) + if err != nil { + t.Errorf("makeDirectory() should succeed when directory already exists: %v", err) + } +} + +func TestBDInstall_HasDownloaded(t *testing.T) { + install := New("/test/path") + + // Initially should be false + if install.HasDownloaded() { + t.Error("HasDownloaded() should initially be false") + } + + // After setting hasDownloaded (internal state) + install.hasDownloaded = true + if !install.HasDownloaded() { + t.Error("HasDownloaded() should be true after download") + } +} + +func TestBDInstall_PathStructure(t *testing.T) { + // Test with different root paths + tests := []struct { + name string + rootPath string + }{ + {"Unix absolute path", "/home/user/.config/BetterDiscord"}, + {"Windows absolute path", "C:\\Users\\User\\AppData\\Roaming\\BetterDiscord"}, + {"Relative path", "BetterDiscord"}, + {"Path with spaces", "/path with spaces/BetterDiscord"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + install := New(tt.rootPath) + + // Verify all paths are constructed correctly relative to root + if install.Root() != tt.rootPath { + t.Errorf("Root() incorrect") + } + + if install.Data() != filepath.Join(tt.rootPath, "data") { + t.Errorf("Data() path incorrect: %s", install.Data()) + } + + if install.Asar() != filepath.Join(tt.rootPath, "data", "betterdiscord.asar") { + t.Errorf("Asar() path incorrect: %s", install.Asar()) + } + + if install.Plugins() != filepath.Join(tt.rootPath, "plugins") { + t.Errorf("Plugins() path incorrect: %s", install.Plugins()) + } + + if install.Themes() != filepath.Join(tt.rootPath, "themes") { + t.Errorf("Themes() path incorrect: %s", install.Themes()) + } + }) + } +} diff --git a/internal/discord/paths_darwin.go b/internal/discord/paths_darwin.go index e6f9756..e21f4be 100644 --- a/internal/discord/paths_darwin.go +++ b/internal/discord/paths_darwin.go @@ -47,6 +47,9 @@ func Validate(proposed string) *DiscordInstall { } var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) + if len(candidates) == 0 { + return nil + } sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) var versionDir = candidates[len(candidates)-1].Name() finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core") diff --git a/internal/discord/paths_linux.go b/internal/discord/paths_linux.go index 92af61e..ded7083 100644 --- a/internal/discord/paths_linux.go +++ b/internal/discord/paths_linux.go @@ -70,6 +70,9 @@ func Validate(proposed string) *DiscordInstall { } var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) + if len(candidates) == 0 { + return nil + } sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) var versionDir = candidates[len(candidates)-1].Name() finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core") diff --git a/internal/discord/paths_test.go b/internal/discord/paths_test.go new file mode 100644 index 0000000..6a252cd --- /dev/null +++ b/internal/discord/paths_test.go @@ -0,0 +1,298 @@ +package discord + +import ( + "path/filepath" + "testing" + + "github.com/betterdiscord/cli/internal/models" +) + +func TestGetVersion(t *testing.T) { + tests := []struct { + name string + path string + expected string + }{ + { + name: "Version in middle of path", + path: "/usr/share/discord/0.0.35/modules", + expected: "0.0.35", + }, + { + name: "Version at end of path", + path: "/home/user/.config/discord/0.0.36", + expected: "0.0.36", + }, + { + name: "Multiple versions (should return first)", + path: "/usr/share/1.2.3/discord/0.0.35/modules", + expected: "1.2.3", + }, + { + name: "No version in path", + path: "/usr/share/discord/modules", + expected: "", + }, + { + name: "Windows-style path with version", + path: "C:\\Users\\User\\AppData\\Local\\Discord\\app-1.0.9012", + expected: "1.0.9012", + }, + { + name: "Version with many digits", + path: "/opt/discord/123.456.789/core", + expected: "123.456.789", + }, + { + name: "Empty path", + path: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetVersion(tt.path) + if result != tt.expected { + t.Errorf("GetVersion(%s) = %s, expected %s", tt.path, result, tt.expected) + } + }) + } +} + +func TestGetChannel(t *testing.T) { + tests := []struct { + name string + path string + expected models.DiscordChannel + }{ + { + name: "Stable in path (lowercase)", + path: "/usr/share/discord/modules", + expected: models.Stable, + }, + { + name: "Canary in path (lowercase)", + path: "/usr/share/discordcanary/modules", + expected: models.Canary, + }, + { + name: "PTB in path (lowercase)", + path: "/usr/share/discordptb/modules", + expected: models.PTB, + }, + + { + name: "DiscordCanary without space", + path: "/home/user/.config/DiscordCanary/modules", + expected: models.Canary, + }, + { + name: "DiscordPTB without space", + path: "/home/user/.config/DiscordPTB/modules", + expected: models.PTB, + }, + { + name: "No channel identifier defaults to Stable", + path: "/some/random/path/modules", + expected: models.Stable, + }, + { + name: "Multiple Discord mentions (first wins)", + path: filepath.Join("discordcanary", "discord", "modules"), + expected: models.Canary, + }, + { + name: "Empty path defaults to Stable", + path: "", + expected: models.Stable, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetChannel(tt.path) + if result != tt.expected { + t.Errorf("GetChannel(%s) = %v (%s), expected %v (%s)", + tt.path, result, result.String(), tt.expected, tt.expected.String()) + } + }) + } +} + +func TestGetChannel_CaseInsensitive(t *testing.T) { + tests := []struct { + path string + expected models.DiscordChannel + }{ + {"/usr/share/DISCORD/modules", models.Stable}, + {"/usr/share/Discord/modules", models.Stable}, + {"/usr/share/DISCORDCANARY/modules", models.Canary}, + {"/usr/share/DiscordCanary/modules", models.Canary}, + {"/usr/share/DISCORDPTB/modules", models.PTB}, + {"/usr/share/DiscordPTB/modules", models.PTB}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := GetChannel(tt.path) + if result != tt.expected { + t.Errorf("GetChannel(%s) = %v, expected %v", tt.path, result, tt.expected) + } + }) + } +} + +func TestGetSuggestedPath(t *testing.T) { + // Reset allDiscordInstalls for testing + allDiscordInstalls = make(map[models.DiscordChannel][]*DiscordInstall) + + // Test empty installs + result := GetSuggestedPath(models.Stable) + if result != "" { + t.Errorf("GetSuggestedPath with no installs should return empty string, got %s", result) + } + + // Add some test installs + allDiscordInstalls[models.Stable] = []*DiscordInstall{ + {CorePath: "/usr/share/discord/0.0.35", Version: "0.0.35"}, + {CorePath: "/usr/share/discord/0.0.34", Version: "0.0.34"}, + } + + allDiscordInstalls[models.Canary] = []*DiscordInstall{ + {CorePath: "/usr/share/discord-canary/0.0.200", Version: "0.0.200"}, + } + + // Test that it returns the first install + stableResult := GetSuggestedPath(models.Stable) + if stableResult != "/usr/share/discord/0.0.35" { + t.Errorf("GetSuggestedPath(Stable) = %s, expected /usr/share/discord/0.0.35", stableResult) + } + + canaryResult := GetSuggestedPath(models.Canary) + if canaryResult != "/usr/share/discord-canary/0.0.200" { + t.Errorf("GetSuggestedPath(Canary) = %s, expected /usr/share/discord-canary/0.0.200", canaryResult) + } + + // Test channel with no installs + ptbResult := GetSuggestedPath(models.PTB) + if ptbResult != "" { + t.Errorf("GetSuggestedPath(PTB) with no PTB installs should return empty string, got %s", ptbResult) + } +} + +func TestAddCustomPath(t *testing.T) { + // This test is limited because Validate() depends on OS-specific paths + // We're mainly testing the logic around adding and deduplication + + // Reset for testing + allDiscordInstalls = make(map[models.DiscordChannel][]*DiscordInstall) + + // Test with invalid path (will return nil since Validate will fail) + result := AddCustomPath("/nonexistent/invalid/path") + if result != nil { + t.Error("AddCustomPath with invalid path should return nil") + } + + // Further testing would require mocking the Validate function + // or setting up actual Discord installation directories +} + +func TestResolvePath(t *testing.T) { + // Reset for testing + allDiscordInstalls = make(map[models.DiscordChannel][]*DiscordInstall) + + // Add a test install + testInstall := &DiscordInstall{ + CorePath: "/test/discord/path", + Channel: models.Stable, + Version: "1.0.0", + } + allDiscordInstalls[models.Stable] = []*DiscordInstall{testInstall} + + // Test resolving existing path + result := ResolvePath("/test/discord/path") + if result != testInstall { + t.Error("ResolvePath should return the existing install") + } + + // Test resolving non-existent path (will try AddCustomPath and likely return nil) + result2 := ResolvePath("/nonexistent/path") + if result2 != nil { + // This might succeed or fail depending on whether Validate passes + // In most test environments, it should return nil + t.Log("ResolvePath returned non-nil for non-existent path (may be valid in some environments)") + } +} + +func TestSortInstalls(t *testing.T) { + // Reset for testing + allDiscordInstalls = make(map[models.DiscordChannel][]*DiscordInstall) + + // Add unsorted installs + allDiscordInstalls[models.Stable] = []*DiscordInstall{ + {CorePath: "/path1", Version: "0.0.34", Channel: models.Stable}, + {CorePath: "/path2", Version: "0.0.36", Channel: models.Stable}, + {CorePath: "/path3", Version: "0.0.35", Channel: models.Stable}, + } + + // Sort them + sortInstalls() + + // Verify sorted in descending order by version + installs := allDiscordInstalls[models.Stable] + if len(installs) != 3 { + t.Fatalf("Expected 3 installs, got %d", len(installs)) + } + + if installs[0].Version != "0.0.36" { + t.Errorf("First install should have version 0.0.36, got %s", installs[0].Version) + } + if installs[1].Version != "0.0.35" { + t.Errorf("Second install should have version 0.0.35, got %s", installs[1].Version) + } + if installs[2].Version != "0.0.34" { + t.Errorf("Third install should have version 0.0.34, got %s", installs[2].Version) + } +} + +func TestSortInstalls_MultipleChannels(t *testing.T) { + // Reset for testing + allDiscordInstalls = make(map[models.DiscordChannel][]*DiscordInstall) + + // Add unsorted installs for multiple channels + allDiscordInstalls[models.Stable] = []*DiscordInstall{ + {CorePath: "/stable1", Version: "1.0.0", Channel: models.Stable}, + {CorePath: "/stable2", Version: "1.0.2", Channel: models.Stable}, + } + + allDiscordInstalls[models.Canary] = []*DiscordInstall{ + {CorePath: "/canary1", Version: "0.0.100", Channel: models.Canary}, + {CorePath: "/canary2", Version: "0.0.150", Channel: models.Canary}, + {CorePath: "/canary3", Version: "0.0.125", Channel: models.Canary}, + } + + // Sort them + sortInstalls() + + // Verify Stable channel is sorted + stableInstalls := allDiscordInstalls[models.Stable] + if stableInstalls[0].Version != "1.0.2" { + t.Errorf("Stable: First version should be 1.0.2, got %s", stableInstalls[0].Version) + } + if stableInstalls[1].Version != "1.0.0" { + t.Errorf("Stable: Second version should be 1.0.0, got %s", stableInstalls[1].Version) + } + + // Verify Canary channel is sorted + canaryInstalls := allDiscordInstalls[models.Canary] + if canaryInstalls[0].Version != "0.0.150" { + t.Errorf("Canary: First version should be 0.0.150, got %s", canaryInstalls[0].Version) + } + if canaryInstalls[1].Version != "0.0.125" { + t.Errorf("Canary: Second version should be 0.0.125, got %s", canaryInstalls[1].Version) + } + if canaryInstalls[2].Version != "0.0.100" { + t.Errorf("Canary: Third version should be 0.0.100, got %s", canaryInstalls[2].Version) + } +} diff --git a/internal/discord/paths_windows.go b/internal/discord/paths_windows.go index 90fda56..22086ac 100644 --- a/internal/discord/paths_windows.go +++ b/internal/discord/paths_windows.go @@ -41,7 +41,10 @@ func Validate(proposed string) *DiscordInstall { return nil } - var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && len(strings.Split(file.Name(), ".")) == 3 }) + var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) + if len(candidates) == 0 { + return nil + } sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) var versionDir = candidates[len(candidates)-1].Name() @@ -53,6 +56,9 @@ func Validate(proposed string) *DiscordInstall { candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") }) + if len(candidates) == 0 { + return nil + } var coreWrap = candidates[len(candidates)-1].Name() finalPath = filepath.Join(proposed, versionDir, "modules", coreWrap, "discord_desktop_core") @@ -68,6 +74,9 @@ func Validate(proposed string) *DiscordInstall { var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") }) + if len(candidates) == 0 { + return nil + } var coreWrap = candidates[len(candidates)-1].Name() finalPath = filepath.Join(proposed, "modules", coreWrap, "discord_desktop_core") } diff --git a/internal/discord/process.go b/internal/discord/process.go index cd1bea3..288455a 100644 --- a/internal/discord/process.go +++ b/internal/discord/process.go @@ -23,12 +23,19 @@ func (discord *DiscordInstall) restart() error { return err } - // Use binary found in killing process - cmd := exec.Command(exeName) + // Determine command based on installation type + var cmd *exec.Cmd if discord.IsFlatpak { cmd = exec.Command("flatpak", "run", "com.discordapp."+discord.Channel.Exe()) } else if discord.IsSnap { cmd = exec.Command("snap", "run", discord.Channel.Exe()) + } else { + // Use binary found in killing process for non-Flatpak/Snap installs + if exeName == "" { + log.Printf("❌ Unable to restart %s, please do so manually!", discord.Channel.Name()) + return fmt.Errorf("could not determine executable path for %s", discord.Channel.Name()) + } + cmd = exec.Command(exeName) } // Set working directory to user home diff --git a/internal/models/channel_test.go b/internal/models/channel_test.go new file mode 100644 index 0000000..351f308 --- /dev/null +++ b/internal/models/channel_test.go @@ -0,0 +1,251 @@ +package models + +import ( + "runtime" + "testing" +) + +func TestDiscordChannel_String(t *testing.T) { + tests := []struct { + channel DiscordChannel + expected string + }{ + {Stable, "stable"}, + {Canary, "canary"}, + {PTB, "ptb"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.channel.String() + if result != tt.expected { + t.Errorf("String() = %s, expected %s", result, tt.expected) + } + }) + } +} + +func TestDiscordChannel_Name(t *testing.T) { + tests := []struct { + channel DiscordChannel + expected string + }{ + {Stable, "Discord"}, + {Canary, "Discord Canary"}, + {PTB, "Discord PTB"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.channel.Name() + if result != tt.expected { + t.Errorf("Name() = %s, expected %s", result, tt.expected) + } + }) + } +} + +func TestDiscordChannel_Exe(t *testing.T) { + tests := []struct { + name string + channel DiscordChannel + goos string + expected string + }{ + { + name: "Stable on Linux", + channel: Stable, + goos: "linux", + expected: "Discord", + }, + { + name: "Canary on Linux", + channel: Canary, + goos: "linux", + expected: "DiscordCanary", + }, + { + name: "PTB on Linux", + channel: PTB, + goos: "linux", + expected: "DiscordPTB", + }, + { + name: "Stable on Darwin", + channel: Stable, + goos: "darwin", + expected: "Discord", + }, + { + name: "Canary on Darwin", + channel: Canary, + goos: "darwin", + expected: "Discord Canary", + }, + { + name: "PTB on Darwin", + channel: PTB, + goos: "darwin", + expected: "Discord PTB", + }, + { + name: "Stable on Windows", + channel: Stable, + goos: "windows", + expected: "Discord.exe", + }, + { + name: "Canary on Windows", + channel: Canary, + goos: "windows", + expected: "DiscordCanary.exe", + }, + { + name: "PTB on Windows", + channel: PTB, + goos: "windows", + expected: "DiscordPTB.exe", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: This test will check the actual runtime.GOOS + // In a real test environment, you might want to mock this + // For now, we'll just test the actual platform + if runtime.GOOS != tt.goos { + t.Skipf("Skipping test for %s on %s", tt.goos, runtime.GOOS) + } + + result := tt.channel.Exe() + if result != tt.expected { + t.Errorf("Exe() = %s, expected %s", result, tt.expected) + } + }) + } +} + +func TestDiscordChannel_Exe_CurrentPlatform(t *testing.T) { + // Test that Exe() returns something reasonable for the current platform + channels := []DiscordChannel{Stable, Canary, PTB} + + for _, channel := range channels { + result := channel.Exe() + + // Basic validation + if result == "" { + t.Errorf("Exe() returned empty string for %s", channel.Name()) + } + + // Platform-specific checks + switch runtime.GOOS { + case "windows": + if result[len(result)-4:] != ".exe" { + t.Errorf("Exe() on Windows should end with .exe, got %s", result) + } + case "darwin": + // On macOS, names should contain spaces for Canary and PTB + if channel == Canary && result != "Discord Canary" { + t.Errorf("Exe() on macOS for Canary = %s, expected 'Discord Canary'", result) + } + if channel == PTB && result != "Discord PTB" { + t.Errorf("Exe() on macOS for PTB = %s, expected 'Discord PTB'", result) + } + default: + // On Linux, names should not contain spaces + if channel == Canary && result != "DiscordCanary" { + t.Errorf("Exe() on Linux for Canary = %s, expected 'DiscordCanary'", result) + } + if channel == PTB && result != "DiscordPTB" { + t.Errorf("Exe() on Linux for PTB = %s, expected 'DiscordPTB'", result) + } + } + } +} + +func TestParseChannel(t *testing.T) { + tests := []struct { + input string + expected DiscordChannel + }{ + {"stable", Stable}, + {"Stable", Stable}, + {"STABLE", Stable}, + {"canary", Canary}, + {"Canary", Canary}, + {"CANARY", Canary}, + {"ptb", PTB}, + {"PTB", PTB}, + {"Ptb", PTB}, + {"invalid", Stable}, // Default to Stable + {"", Stable}, // Default to Stable + {"discord", Stable}, // Unknown input defaults to Stable + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := ParseChannel(tt.input) + if result != tt.expected { + t.Errorf("ParseChannel(%s) = %v, expected %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestDiscordChannel_TSName(t *testing.T) { + tests := []struct { + channel DiscordChannel + expected string + }{ + {Stable, "STABLE"}, + {Canary, "CANARY"}, + {PTB, "PTB"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.channel.TSName() + if result != tt.expected { + t.Errorf("TSName() = %s, expected %s", result, tt.expected) + } + }) + } +} + +func TestChannelsConstant(t *testing.T) { + // Verify that Channels contains all expected channels + if len(Channels) != 3 { + t.Errorf("Channels should contain 3 channels, got %d", len(Channels)) + } + + expectedChannels := []DiscordChannel{Stable, Canary, PTB} + for i, expected := range expectedChannels { + if Channels[i] != expected { + t.Errorf("Channels[%d] = %v, expected %v", i, Channels[i], expected) + } + } +} + +func TestDiscordChannel_EnumValues(t *testing.T) { + // Test that the enum values are distinct + if Stable == Canary { + t.Error("Stable and Canary should have different values") + } + if Stable == PTB { + t.Error("Stable and PTB should have different values") + } + if Canary == PTB { + t.Error("Canary and PTB should have different values") + } + + // Test that values are sequential starting from 0 + if Stable != 0 { + t.Errorf("Stable should be 0, got %d", Stable) + } + if Canary != 1 { + t.Errorf("Canary should be 1, got %d", Canary) + } + if PTB != 2 { + t.Errorf("PTB should be 2, got %d", PTB) + } +} diff --git a/internal/utils/download_test.go b/internal/utils/download_test.go new file mode 100644 index 0000000..81430f2 --- /dev/null +++ b/internal/utils/download_test.go @@ -0,0 +1,230 @@ +package utils + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +func TestDownloadFile(t *testing.T) { + // Create a test server + testData := []byte("test file content") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request headers + if r.Header.Get("User-Agent") != "BetterDiscord/cli" { + t.Errorf("Expected User-Agent header 'BetterDiscord/cli', got '%s'", r.Header.Get("User-Agent")) + } + if r.Header.Get("Accept") != "application/octet-stream" { + t.Errorf("Expected Accept header 'application/octet-stream', got '%s'", r.Header.Get("Accept")) + } + + w.WriteHeader(http.StatusOK) + w.Write(testData) + })) + defer server.Close() + + // Create temp directory for test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "downloaded.txt") + + // Download the file + resp, err := DownloadFile(server.URL, testFile) + if err != nil { + t.Fatalf("DownloadFile() failed: %v", err) + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode) + } + + // Verify file was created + if !Exists(testFile) { + t.Errorf("Downloaded file does not exist: %s", testFile) + } + + // Verify file content + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read downloaded file: %v", err) + } + + if string(content) != string(testData) { + t.Errorf("File content mismatch. Expected '%s', got '%s'", string(testData), string(content)) + } +} + +func TestDownloadFile_BadStatusCode(t *testing.T) { + // Create a test server that returns 404 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "downloaded.txt") + + _, err := DownloadFile(server.URL, testFile) + if err == nil { + t.Error("DownloadFile() should have returned an error for 404 status") + } +} + +func TestDownloadFile_InvalidURL(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "downloaded.txt") + + _, err := DownloadFile("http://invalid.test.nonexistent.domain", testFile) + if err == nil { + t.Error("DownloadFile() should have returned an error for invalid URL") + } +} + +func TestDownloadFile_InvalidPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("test")) + })) + defer server.Close() + + // Try to write to an invalid path (directory doesn't exist) + invalidPath := "/nonexistent/directory/file.txt" + _, err := DownloadFile(server.URL, invalidPath) + if err == nil { + t.Error("DownloadFile() should have returned an error for invalid file path") + } +} + +func TestDownloadJSON(t *testing.T) { + // Define a test struct + type TestData struct { + Name string `json:"name"` + Value int `json:"value"` + } + + expectedData := TestData{ + Name: "test", + Value: 42, + } + + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request headers + if r.Header.Get("User-Agent") != "BetterDiscord/cli" { + t.Errorf("Expected User-Agent header 'BetterDiscord/cli', got '%s'", r.Header.Get("User-Agent")) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(expectedData) + })) + defer server.Close() + + // Download and parse JSON + result, err := DownloadJSON[TestData](server.URL) + if err != nil { + t.Fatalf("DownloadJSON() failed: %v", err) + } + + if result.Name != expectedData.Name { + t.Errorf("Expected Name '%s', got '%s'", expectedData.Name, result.Name) + } + + if result.Value != expectedData.Value { + t.Errorf("Expected Value %d, got %d", expectedData.Value, result.Value) + } +} + +func TestDownloadJSON_BadStatusCode(t *testing.T) { + type TestData struct { + Name string `json:"name"` + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + _, err := DownloadJSON[TestData](server.URL) + if err == nil { + t.Error("DownloadJSON() should have returned an error for 500 status") + } +} + +func TestDownloadJSON_InvalidJSON(t *testing.T) { + type TestData struct { + Name string `json:"name"` + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("invalid json {")) + })) + defer server.Close() + + _, err := DownloadJSON[TestData](server.URL) + if err == nil { + t.Error("DownloadJSON() should have returned an error for invalid JSON") + } +} + +func TestDownloadJSON_ComplexStruct(t *testing.T) { + type NestedData struct { + ID int `json:"id"` + Name string `json:"name"` + } + + type ComplexData struct { + Items []NestedData `json:"items"` + Timestamp time.Time `json:"timestamp"` + Active bool `json:"active"` + } + + now := time.Now().UTC().Round(time.Second) + expectedData := ComplexData{ + Items: []NestedData{ + {ID: 1, Name: "first"}, + {ID: 2, Name: "second"}, + }, + Timestamp: now, + Active: true, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(expectedData) + })) + defer server.Close() + + result, err := DownloadJSON[ComplexData](server.URL) + if err != nil { + t.Fatalf("DownloadJSON() failed: %v", err) + } + + if len(result.Items) != len(expectedData.Items) { + t.Errorf("Expected %d items, got %d", len(expectedData.Items), len(result.Items)) + } + + if result.Active != expectedData.Active { + t.Errorf("Expected Active %v, got %v", expectedData.Active, result.Active) + } + + if !result.Timestamp.Equal(expectedData.Timestamp) { + t.Errorf("Expected Timestamp %v, got %v", expectedData.Timestamp, result.Timestamp) + } +} + +func TestDownloadJSON_InvalidURL(t *testing.T) { + type TestData struct { + Name string `json:"name"` + } + + _, err := DownloadJSON[TestData]("http://invalid.test.nonexistent.domain") + if err == nil { + t.Error("DownloadJSON() should have returned an error for invalid URL") + } +} diff --git a/internal/utils/paths.go b/internal/utils/paths.go index 8d86ebb..085b53b 100644 --- a/internal/utils/paths.go +++ b/internal/utils/paths.go @@ -13,7 +13,7 @@ func Filter[T any](source []T, filterFunc func(T) bool) (ret []T) { var returnArray = []T{} for _, s := range source { if filterFunc(s) { - returnArray = append(ret, s) + returnArray = append(returnArray, s) } } return returnArray diff --git a/internal/utils/paths_test.go b/internal/utils/paths_test.go new file mode 100644 index 0000000..e8bae75 --- /dev/null +++ b/internal/utils/paths_test.go @@ -0,0 +1,147 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExists(t *testing.T) { + // Create a temporary file for testing + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "testfile.txt") + + // Test non-existent file + if Exists(tmpFile) { + t.Errorf("Exists() returned true for non-existent file: %s", tmpFile) + } + + // Create the file + if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Test existing file + if !Exists(tmpFile) { + t.Errorf("Exists() returned false for existing file: %s", tmpFile) + } + + // Test directory + if !Exists(tmpDir) { + t.Errorf("Exists() returned false for existing directory: %s", tmpDir) + } + + // Test non-existent directory + nonExistentDir := filepath.Join(tmpDir, "nonexistent", "directory") + if Exists(nonExistentDir) { + t.Errorf("Exists() returned true for non-existent directory: %s", nonExistentDir) + } +} + +func TestFilter(t *testing.T) { + tests := []struct { + name string + input []int + filterFunc func(int) bool + expected []int + }{ + { + name: "Filter even numbers", + input: []int{1, 2, 3, 4, 5, 6}, + filterFunc: func(n int) bool { return n%2 == 0 }, + expected: []int{2, 4, 6}, + }, + { + name: "Filter numbers greater than 5", + input: []int{1, 3, 5, 7, 9, 11}, + filterFunc: func(n int) bool { return n > 5 }, + expected: []int{7, 9, 11}, + }, + { + name: "Filter all items (return empty)", + input: []int{1, 2, 3}, + filterFunc: func(n int) bool { return false }, + expected: []int{}, + }, + { + name: "Filter none (return all)", + input: []int{1, 2, 3}, + filterFunc: func(n int) bool { return true }, + expected: []int{1, 2, 3}, + }, + { + name: "Empty input", + input: []int{}, + filterFunc: func(n int) bool { return true }, + expected: []int{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Filter(tt.input, tt.filterFunc) + + if len(result) != len(tt.expected) { + t.Errorf("Filter() returned slice of length %d, expected %d", len(result), len(tt.expected)) + return + } + + for i, v := range result { + if v != tt.expected[i] { + t.Errorf("Filter() result[%d] = %v, expected %v", i, v, tt.expected[i]) + } + } + }) + } +} + +func TestFilterWithStrings(t *testing.T) { + input := []string{"apple", "banana", "cherry", "date"} + filterFunc := func(s string) bool { + return len(s) > 5 + } + expected := []string{"banana", "cherry"} + + result := Filter(input, filterFunc) + + if len(result) != len(expected) { + t.Errorf("Filter() returned slice of length %d, expected %d", len(result), len(expected)) + return + } + + for i, v := range result { + if v != expected[i] { + t.Errorf("Filter() result[%d] = %v, expected %v", i, v, expected[i]) + } + } +} + +func TestFilterWithStructs(t *testing.T) { + type Person struct { + Name string + Age int + } + + input := []Person{ + {Name: "Alice", Age: 25}, + {Name: "Bob", Age: 17}, + {Name: "Charlie", Age: 30}, + {Name: "Diana", Age: 16}, + } + + // Filter adults (age >= 18) + filterFunc := func(p Person) bool { + return p.Age >= 18 + } + + result := Filter(input, filterFunc) + + if len(result) != 2 { + t.Errorf("Filter() returned %d persons, expected 2 adults", len(result)) + return + } + + if result[0].Name != "Alice" || result[1].Name != "Charlie" { + t.Errorf("Filter() returned unexpected persons: %v", result) + } +} diff --git a/scripts/completions.sh b/scripts/completions.sh new file mode 100644 index 0000000..9b0d070 --- /dev/null +++ b/scripts/completions.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e +rm -rf completions +mkdir completions +for sh in bash zsh fish; do + go run main.go completion "$sh" >"completions/bdcli.$sh" +done \ No newline at end of file From c73b3a63c6ee4aa2292eaf69b822e27e3d61f3d2 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sun, 15 Feb 2026 00:28:41 -0500 Subject: [PATCH 13/14] More linting --- internal/betterdiscord/install_test.go | 12 ++++++------ internal/discord/paths_darwin.go | 4 ++-- internal/discord/paths_linux.go | 2 +- internal/discord/paths_windows.go | 12 ++++++------ internal/discord/process.go | 14 +++++++------- internal/utils/download_test.go | 16 ++++++++-------- 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/internal/betterdiscord/install_test.go b/internal/betterdiscord/install_test.go index 8e0dfe9..9704982 100644 --- a/internal/betterdiscord/install_test.go +++ b/internal/betterdiscord/install_test.go @@ -103,9 +103,9 @@ func TestBDInstall_Prepare_AlreadyExists(t *testing.T) { install := New(bdRoot) // Create directories manually first - os.MkdirAll(install.Data(), 0755) - os.MkdirAll(install.Plugins(), 0755) - os.MkdirAll(install.Themes(), 0755) + os.MkdirAll(install.Data(), 0755) //nolint:errcheck + os.MkdirAll(install.Plugins(), 0755) //nolint:errcheck + os.MkdirAll(install.Themes(), 0755) //nolint:errcheck // Prepare should succeed even if directories already exist err := install.Prepare() @@ -136,7 +136,7 @@ func TestBDInstall_Repair(t *testing.T) { // Create the data directory and a test plugins.json file channelFolder := filepath.Join(install.Data(), models.Stable.String()) - os.MkdirAll(channelFolder, 0755) + os.MkdirAll(channelFolder, 0755) //nolint:errcheck pluginsJson := filepath.Join(channelFolder, "plugins.json") err := os.WriteFile(pluginsJson, []byte(`{"test": "data"}`), 0644) @@ -188,10 +188,10 @@ func TestBDInstall_Repair_MultipleChannels(t *testing.T) { for _, channel := range channels { channelFolder := filepath.Join(install.Data(), channel.String()) - os.MkdirAll(channelFolder, 0755) + os.MkdirAll(channelFolder, 0755) //nolint:errcheck pluginsJson := filepath.Join(channelFolder, "plugins.json") - os.WriteFile(pluginsJson, []byte(`{}`), 0644) + os.WriteFile(pluginsJson, []byte(`{}`), 0644) //nolint:errcheck pluginsFiles[channel] = pluginsJson } diff --git a/internal/discord/paths_darwin.go b/internal/discord/paths_darwin.go index e21f4be..217ad3a 100644 --- a/internal/discord/paths_darwin.go +++ b/internal/discord/paths_darwin.go @@ -48,8 +48,8 @@ func Validate(proposed string) *DiscordInstall { var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) if len(candidates) == 0 { - return nil - } + return nil + } sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) var versionDir = candidates[len(candidates)-1].Name() finalPath = filepath.Join(proposed, versionDir, "modules", "discord_desktop_core") diff --git a/internal/discord/paths_linux.go b/internal/discord/paths_linux.go index ded7083..dc31b27 100644 --- a/internal/discord/paths_linux.go +++ b/internal/discord/paths_linux.go @@ -71,7 +71,7 @@ func Validate(proposed string) *DiscordInstall { var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) if len(candidates) == 0 { - return nil + return nil } sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) var versionDir = candidates[len(candidates)-1].Name() diff --git a/internal/discord/paths_windows.go b/internal/discord/paths_windows.go index 22086ac..0160637 100644 --- a/internal/discord/paths_windows.go +++ b/internal/discord/paths_windows.go @@ -43,8 +43,8 @@ func Validate(proposed string) *DiscordInstall { var candidates = utils.Filter(dFiles, func(file fs.DirEntry) bool { return file.IsDir() && versionRegex.MatchString(file.Name()) }) if len(candidates) == 0 { - return nil - } + return nil + } sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) var versionDir = candidates[len(candidates)-1].Name() @@ -57,8 +57,8 @@ func Validate(proposed string) *DiscordInstall { return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") }) if len(candidates) == 0 { - return nil - } + return nil + } var coreWrap = candidates[len(candidates)-1].Name() finalPath = filepath.Join(proposed, versionDir, "modules", coreWrap, "discord_desktop_core") @@ -75,8 +75,8 @@ func Validate(proposed string) *DiscordInstall { return file.IsDir() && strings.HasPrefix(file.Name(), "discord_desktop_core") }) if len(candidates) == 0 { - return nil - } + return nil + } var coreWrap = candidates[len(candidates)-1].Name() finalPath = filepath.Join(proposed, "modules", coreWrap, "discord_desktop_core") } diff --git a/internal/discord/process.go b/internal/discord/process.go index 288455a..63facbd 100644 --- a/internal/discord/process.go +++ b/internal/discord/process.go @@ -24,18 +24,18 @@ func (discord *DiscordInstall) restart() error { } // Determine command based on installation type - var cmd *exec.Cmd + var cmd *exec.Cmd if discord.IsFlatpak { cmd = exec.Command("flatpak", "run", "com.discordapp."+discord.Channel.Exe()) } else if discord.IsSnap { cmd = exec.Command("snap", "run", discord.Channel.Exe()) } else { - // Use binary found in killing process for non-Flatpak/Snap installs - if exeName == "" { - log.Printf("❌ Unable to restart %s, please do so manually!", discord.Channel.Name()) - return fmt.Errorf("could not determine executable path for %s", discord.Channel.Name()) - } - cmd = exec.Command(exeName) + // Use binary found in killing process for non-Flatpak/Snap installs + if exeName == "" { + log.Printf("❌ Unable to restart %s, please do so manually!", discord.Channel.Name()) + return fmt.Errorf("could not determine executable path for %s", discord.Channel.Name()) + } + cmd = exec.Command(exeName) } // Set working directory to user home diff --git a/internal/utils/download_test.go b/internal/utils/download_test.go index 81430f2..c67f348 100644 --- a/internal/utils/download_test.go +++ b/internal/utils/download_test.go @@ -23,7 +23,7 @@ func TestDownloadFile(t *testing.T) { } w.WriteHeader(http.StatusOK) - w.Write(testData) + w.Write(testData) //nolint:errcheck })) defer server.Close() @@ -86,7 +86,7 @@ func TestDownloadFile_InvalidURL(t *testing.T) { func TestDownloadFile_InvalidPath(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("test")) + w.Write([]byte("test")) //nolint:errcheck })) defer server.Close() @@ -119,7 +119,7 @@ func TestDownloadJSON(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(expectedData) + json.NewEncoder(w).Encode(expectedData) //nolint:errcheck })) defer server.Close() @@ -161,7 +161,7 @@ func TestDownloadJSON_InvalidJSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("invalid json {")) + w.Write([]byte("invalid json {")) //nolint:errcheck })) defer server.Close() @@ -178,9 +178,9 @@ func TestDownloadJSON_ComplexStruct(t *testing.T) { } type ComplexData struct { - Items []NestedData `json:"items"` - Timestamp time.Time `json:"timestamp"` - Active bool `json:"active"` + Items []NestedData `json:"items"` + Timestamp time.Time `json:"timestamp"` + Active bool `json:"active"` } now := time.Now().UTC().Round(time.Second) @@ -196,7 +196,7 @@ func TestDownloadJSON_ComplexStruct(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(expectedData) + json.NewEncoder(w).Encode(expectedData) //nolint:errcheck })) defer server.Close() From 80ad67896a7605b0fd71206c985c9c9a44e4a591 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sun, 15 Feb 2026 00:50:31 -0500 Subject: [PATCH 14/14] fix: properly get all installs --- Taskfile.yml | 1 - internal/discord/paths.go | 6 +++--- internal/discord/process.go | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 4154ec4..56a4156 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -5,7 +5,6 @@ vars: BINARY_NAME: bdcli MAIN_PACKAGE: ./main.go BUILD_DIR: ./dist - GO_VERSION: '1.26' VERSION: sh: git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-dev" COMMIT: diff --git a/internal/discord/paths.go b/internal/discord/paths.go index f688530..870af2b 100644 --- a/internal/discord/paths.go +++ b/internal/discord/paths.go @@ -14,17 +14,17 @@ var versionRegex = regexp.MustCompile(`[0-9]+\.[0-9]+\.[0-9]+`) var allDiscordInstalls map[models.DiscordChannel][]*DiscordInstall func GetAllInstalls() map[models.DiscordChannel][]*DiscordInstall { - var installs = map[models.DiscordChannel][]*DiscordInstall{} + allDiscordInstalls = map[models.DiscordChannel][]*DiscordInstall{} for _, path := range searchPaths { if result := Validate(path); result != nil { - installs[result.Channel] = append(installs[result.Channel], result) + allDiscordInstalls[result.Channel] = append(allDiscordInstalls[result.Channel], result) } } sortInstalls() - return installs + return allDiscordInstalls } func GetVersion(proposed string) string { diff --git a/internal/discord/process.go b/internal/discord/process.go index 63facbd..435195f 100644 --- a/internal/discord/process.go +++ b/internal/discord/process.go @@ -59,7 +59,7 @@ func (discord *DiscordInstall) isRunning() (bool, error) { return false, fmt.Errorf("could not list processes") } - // Search for desired processe(s) + // Search for desired process(es) for _, p := range processes { n, err := p.Name() @@ -87,7 +87,7 @@ func (discord *DiscordInstall) kill() error { return fmt.Errorf("could not list processes") } - // Search for desired processe(s) + // Search for desired process(es) for _, p := range processes { n, err := p.Name()