mirror of
https://github.com/chenasraf/sofmani.git
synced 2026-05-18 01:29:02 +00:00
feat: github release installer
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
sofmani
|
||||
sofmani.log
|
||||
test.yml
|
||||
|
||||
@@ -201,6 +201,10 @@ For a full list with all the supported options, see [the docs](./docs/installer-
|
||||
- If `name` is a full git URL (https or SSH), the repository is cloned directly. If it is a
|
||||
repository path, e.g. `chenasraf/sofmani`, GitHub is assumed.
|
||||
|
||||
- **`github-release`**
|
||||
|
||||
- Downloads a GitHub release asset. Optionally untar/unzip the downloaded file.
|
||||
|
||||
- **`manifest`**
|
||||
|
||||
- Installs an entire manifest from a local or remote file.
|
||||
|
||||
@@ -31,18 +31,19 @@ type InstallerData struct {
|
||||
type InstallerType string
|
||||
|
||||
const (
|
||||
InstallerTypeGroup InstallerType = "group"
|
||||
InstallerTypeShell InstallerType = "shell"
|
||||
InstallerTypeBrew InstallerType = "brew"
|
||||
InstallerTypeApt InstallerType = "apt"
|
||||
InstallerTypeApk InstallerType = "apk"
|
||||
InstallerTypeGit InstallerType = "git"
|
||||
InstallerTypeRsync InstallerType = "rsync"
|
||||
InstallerTypeNpm InstallerType = "npm"
|
||||
InstallerTypePnpm InstallerType = "pnpm"
|
||||
InstallerTypeYarn InstallerType = "yarn"
|
||||
InstallerTypePipx InstallerType = "pipx"
|
||||
InstallerTypeManifest InstallerType = "manifest"
|
||||
InstallerTypeGroup InstallerType = "group"
|
||||
InstallerTypeShell InstallerType = "shell"
|
||||
InstallerTypeBrew InstallerType = "brew"
|
||||
InstallerTypeApt InstallerType = "apt"
|
||||
InstallerTypeApk InstallerType = "apk"
|
||||
InstallerTypeGit InstallerType = "git"
|
||||
InstallerTypeGitHubRelease InstallerType = "github-release"
|
||||
InstallerTypeRsync InstallerType = "rsync"
|
||||
InstallerTypeNpm InstallerType = "npm"
|
||||
InstallerTypePnpm InstallerType = "pnpm"
|
||||
InstallerTypeYarn InstallerType = "yarn"
|
||||
InstallerTypePipx InstallerType = "pipx"
|
||||
InstallerTypeManifest InstallerType = "manifest"
|
||||
)
|
||||
|
||||
func (i *InstallerData) Environ() []string {
|
||||
|
||||
@@ -140,6 +140,38 @@ These fields are shared by all installer types. Some fields may vary in behavior
|
||||
- `opts.destination`: The local directory to clone the repository to.
|
||||
- `opts.ref`: The branch, tag, or commit to checkout after cloning.
|
||||
|
||||
- **`github-release`**
|
||||
|
||||
- **Description**: Downloads a GitHub release asset. Optionally untar/unzip the downloaded file.
|
||||
- **Options**:
|
||||
|
||||
- `opts.repository`: The repository to download from. Should be in the format:
|
||||
`user/repository-name`
|
||||
- `opts.destination`: The target directory to extract the files to.
|
||||
- `opts.strategy`: The download strategy. Can be one of: `tar`, `zip`, `none` (default)
|
||||
- `none` - the release file is not compressed, and should be copied directly
|
||||
- `tar` - the release file is a tar file, and should be extracted
|
||||
- `zip` - the release file is a zip file, and should be extracted
|
||||
- `opts.download_filename`: The filename of the release asset to download.
|
||||
|
||||
This should either be a string, or a map of platforms to filenames.
|
||||
|
||||
You can use `{tag}` and `{version}` to replace the relevant tokens in the filename:
|
||||
|
||||
- `{tag}` - will be replaced by the full tag name, e.g. `v1.0.0`
|
||||
- `{version}` - will be replaced by the version, e.g. `1.0.0`
|
||||
|
||||
Examples:
|
||||
|
||||
```yaml
|
||||
download_filename: myapp_{tag}_linux.tar.gz # will output: myapp_v1.0.0_linux.tar.gz
|
||||
download_filename: myapp_{version}_linux.tar.gz # will output: myapp_1.0.0_linux.tar.gz
|
||||
download_filename:
|
||||
macos: myapp_{tag}_macos.tar.gz
|
||||
linux: myapp_{tag}_linux.tar.gz
|
||||
windows: myapp_{tag}_windows.zip
|
||||
```
|
||||
|
||||
- **`manifest`**
|
||||
|
||||
- **Description**: Installs an entire manifest from a local or remote file.
|
||||
@@ -228,6 +260,19 @@ install:
|
||||
destination: ~/.gitignore-templates
|
||||
```
|
||||
|
||||
### github-release
|
||||
|
||||
```yaml
|
||||
install:
|
||||
- name: lazygit
|
||||
type: github-release
|
||||
opts:
|
||||
repository: jesseduffield/lazygit
|
||||
strategy: tar
|
||||
destination: /usr/local/bin
|
||||
download_filename: lazygit_{tag}_Linux_x86_64.tar.gz
|
||||
```
|
||||
|
||||
### shell
|
||||
|
||||
```yaml
|
||||
|
||||
335
installer/github_release_installer.go
Normal file
335
installer/github_release_installer.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/chenasraf/sofmani/appconfig"
|
||||
"github.com/chenasraf/sofmani/logger"
|
||||
"github.com/chenasraf/sofmani/platform"
|
||||
"github.com/chenasraf/sofmani/utils"
|
||||
)
|
||||
|
||||
type GitHubReleaseInstaller struct {
|
||||
InstallerBase
|
||||
Config *appconfig.AppConfig
|
||||
Data *appconfig.InstallerData
|
||||
}
|
||||
|
||||
type GitHubReleaseOpts struct {
|
||||
Repository *string
|
||||
Destination *string
|
||||
DownloadFilename *platform.PlatformMap[string]
|
||||
Strategy *GitHubReleaseInstallStrategy
|
||||
}
|
||||
|
||||
type GitHubReleaseInstallStrategy string
|
||||
|
||||
const (
|
||||
GitHubReleaseInstallStrategyNone GitHubReleaseInstallStrategy = "none"
|
||||
GitHubReleaseInstallStrategyTar GitHubReleaseInstallStrategy = "tar"
|
||||
GitHubReleaseInstallStrategyZip GitHubReleaseInstallStrategy = "zip"
|
||||
)
|
||||
|
||||
// Install implements IInstaller.
|
||||
func (i *GitHubReleaseInstaller) Install() error {
|
||||
opts := i.GetOpts()
|
||||
data := i.GetData()
|
||||
name := *data.Name
|
||||
tmpDir, err := os.MkdirTemp("", "sofmani")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpOut, err := os.Create(fmt.Sprintf("%s/%s.download", tmpDir, name))
|
||||
defer tmpOut.Close()
|
||||
|
||||
err = os.MkdirAll(*opts.Destination, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// defer os.RemoveAll(tmpDir)
|
||||
|
||||
tag, err := i.GetLatestTag()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
version := tag
|
||||
if strings.HasPrefix(tag, "v") {
|
||||
version = strings.TrimPrefix(tag, "v")
|
||||
}
|
||||
filename := i.GetFilename()
|
||||
filename = strings.ReplaceAll(filename, "{tag}", tag)
|
||||
filename = strings.ReplaceAll(filename, "{version}", version)
|
||||
if filename == "" {
|
||||
return fmt.Errorf("No download filename provided")
|
||||
}
|
||||
downloadUrl := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", *opts.Repository, tag, filename)
|
||||
logger.Debug("Downloading from %s", downloadUrl)
|
||||
resp, err := http.Get(downloadUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
n, err := io.Copy(tmpOut, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("No data was written to the file")
|
||||
}
|
||||
|
||||
strategy := GitHubReleaseInstallStrategyNone
|
||||
|
||||
if opts.Strategy != nil {
|
||||
strategy = *opts.Strategy
|
||||
}
|
||||
|
||||
logger.Debug("Strategy %s", strategy)
|
||||
|
||||
success := false
|
||||
|
||||
logger.Debug("Creating file %s", fmt.Sprintf("%s/%s", *opts.Destination, i.GetBinName()))
|
||||
out, err := os.Create(fmt.Sprintf("%s/%s", *opts.Destination, i.GetBinName()))
|
||||
defer out.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch strategy {
|
||||
case GitHubReleaseInstallStrategyTar:
|
||||
logger.Debug("Extracting tar file %s", tmpOut.Name())
|
||||
success, err = i.RunCmdGetSuccess("tar", "-xvf", tmpOut.Name(), "-C", tmpDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
success, err = i.CopyExtractedFile(out, tmpDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case GitHubReleaseInstallStrategyZip:
|
||||
logger.Debug("Extracting zip file %s", tmpOut.Name())
|
||||
success, err = i.RunCmdGetSuccess("unzip", tmpOut.Name(), "-d", tmpDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
success, err = i.CopyExtractedFile(out, tmpDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
io.Copy(out, tmpOut)
|
||||
success = true
|
||||
err = nil
|
||||
}
|
||||
|
||||
if !success || err != nil {
|
||||
return errors.Join(fmt.Errorf("Failed to extract the downloaded file"), err)
|
||||
}
|
||||
|
||||
err = i.UpdateCache(tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update implements IInstaller.
|
||||
func (i *GitHubReleaseInstaller) Update() error {
|
||||
return i.Install()
|
||||
}
|
||||
|
||||
// CheckIsInstalled implements IInstaller.
|
||||
func (i *GitHubReleaseInstaller) CheckIsInstalled() (bool, error) {
|
||||
if i.HasCustomInstallCheck() {
|
||||
return i.RunCustomInstallCheck()
|
||||
}
|
||||
logger.Debug("Checking if %s is installed on %s", *i.Data.Name, filepath.Join(i.GetInstallDir(), *i.Data.Name))
|
||||
return utils.PathExists(filepath.Join(i.GetInstallDir(), *i.Data.Name))
|
||||
}
|
||||
|
||||
// CheckNeedsUpdate implements IInstaller.
|
||||
func (i *GitHubReleaseInstaller) CheckNeedsUpdate() (bool, error) {
|
||||
if i.HasCustomUpdateCheck() {
|
||||
return i.RunCustomUpdateCheck()
|
||||
}
|
||||
cachedTag, err := i.GetCachedTag()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cachedTag == "" {
|
||||
return true, nil
|
||||
}
|
||||
latest, err := i.GetLatestTag()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if latest != strings.TrimSpace(latest) {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (i *GitHubReleaseInstaller) GetBinName() string {
|
||||
if i.Data.BinName != nil {
|
||||
return *i.Data.BinName
|
||||
}
|
||||
return filepath.Base(*i.Data.Name)
|
||||
}
|
||||
|
||||
func (i *GitHubReleaseInstaller) CopyExtractedFile(out *os.File, tmpDir string) (bool, error) {
|
||||
binFile, err := os.Create(out.Name())
|
||||
defer binFile.Close()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
tmpBinFile, err := os.Open(filepath.Join(tmpDir, i.GetBinName()))
|
||||
logger.Debug("Copying file %s to %s", tmpBinFile.Name(), binFile.Name())
|
||||
|
||||
n, err := io.Copy(binFile, tmpBinFile)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if n == 0 {
|
||||
return false, fmt.Errorf("No data was written to the file")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (i *GitHubReleaseInstaller) GetCachedTag() (string, error) {
|
||||
logger.Debug("Getting cached tag for %s", *i.Data.Name)
|
||||
cacheDir, err := utils.GetCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cacheFile := fmt.Sprintf("%s/%s", cacheDir, *i.Data.Name)
|
||||
exists, err := utils.PathExists(cacheFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return "", nil
|
||||
}
|
||||
reader, err := os.Open(cacheFile)
|
||||
contents, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
logger.Debug("Got cached tag %s for %s", strings.TrimSpace(string(contents)), *i.Data.Name)
|
||||
return strings.TrimSpace(string(contents)), nil
|
||||
}
|
||||
|
||||
func (i *GitHubReleaseInstaller) UpdateCache(tag string) error {
|
||||
cacheDir, err := utils.GetCacheDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cacheFile := fmt.Sprintf("%s/%s", cacheDir, *i.Data.Name)
|
||||
logger.Debug("Updating cache file %s with %s", cacheFile, tag)
|
||||
err = os.WriteFile(cacheFile, []byte(tag), 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetData implements IInstaller.
|
||||
func (i *GitHubReleaseInstaller) GetData() *appconfig.InstallerData {
|
||||
return i.Data
|
||||
}
|
||||
|
||||
func (i *GitHubReleaseInstaller) GetOpts() *GitHubReleaseOpts {
|
||||
opts := &GitHubReleaseOpts{}
|
||||
info := i.Data
|
||||
if info.Opts != nil {
|
||||
if repository, ok := (*info.Opts)["repository"].(string); ok {
|
||||
repository = utils.GetRealPath(i.GetData().Environ(), repository)
|
||||
opts.Repository = &repository
|
||||
}
|
||||
if destination, ok := (*info.Opts)["destination"].(string); ok {
|
||||
destination = utils.GetRealPath(i.GetData().Environ(), destination)
|
||||
opts.Destination = &destination
|
||||
}
|
||||
if filename, ok := (*info.Opts)["download_filename"].(string); ok {
|
||||
opts.DownloadFilename = &platform.PlatformMap[string]{
|
||||
MacOS: &filename,
|
||||
Linux: &filename,
|
||||
Windows: &filename,
|
||||
}
|
||||
} else if filenameMap, ok := (*info.Opts)["download_filename"].(map[string]*string); ok {
|
||||
opts.DownloadFilename = &platform.PlatformMap[string]{
|
||||
MacOS: filenameMap["macos"],
|
||||
Linux: filenameMap["linux"],
|
||||
Windows: filenameMap["windows"],
|
||||
}
|
||||
}
|
||||
if strategy, ok := (*info.Opts)["strategy"].(string); ok {
|
||||
strat := GitHubReleaseInstallStrategy(strings.ToLower(strategy))
|
||||
opts.Strategy = &strat
|
||||
}
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func (i *GitHubReleaseInstaller) GetLatestTag() (string, error) {
|
||||
latestReleaseUrl := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", *i.GetOpts().Repository)
|
||||
logger.Debug("Getting latest release from %s", latestReleaseUrl)
|
||||
resp, err := http.Get(latestReleaseUrl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
contents, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
jsonMap := make(map[string]any)
|
||||
err = json.Unmarshal(contents, &jsonMap)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tag := jsonMap["tag_name"].(string)
|
||||
logger.Debug("Latest release is %s", tag)
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
func (i *GitHubReleaseInstaller) GetFilename() string {
|
||||
opts := i.GetOpts()
|
||||
if opts.DownloadFilename != nil {
|
||||
return *opts.DownloadFilename.Resolve()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (i *GitHubReleaseInstaller) GetDestination() string {
|
||||
if i.GetOpts().Destination != nil {
|
||||
return *i.GetOpts().Destination
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return wd
|
||||
}
|
||||
|
||||
func (i *GitHubReleaseInstaller) GetInstallDir() string {
|
||||
return i.GetDestination()
|
||||
}
|
||||
|
||||
func NewGitHubReleaseInstaller(cfg *appconfig.AppConfig, installer *appconfig.InstallerData) *GitHubReleaseInstaller {
|
||||
i := &GitHubReleaseInstaller{
|
||||
InstallerBase: InstallerBase{Data: installer},
|
||||
Config: cfg,
|
||||
Data: installer,
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/chenasraf/sofmani/appconfig"
|
||||
"github.com/chenasraf/sofmani/logger"
|
||||
"github.com/chenasraf/sofmani/platform"
|
||||
@@ -36,6 +38,8 @@ func GetInstaller(config *appconfig.AppConfig, data *appconfig.InstallerData) (I
|
||||
return NewAptInstaller(config, data), nil
|
||||
case appconfig.InstallerTypePipx:
|
||||
return NewPipxInstaller(config, data), nil
|
||||
case appconfig.InstallerTypeGitHubRelease:
|
||||
return NewGitHubReleaseInstaller(config, data), nil
|
||||
case appconfig.InstallerTypeGit:
|
||||
return NewGitInstaller(config, data), nil
|
||||
case appconfig.InstallerTypeManifest:
|
||||
@@ -105,8 +109,7 @@ func RunInstaller(config *appconfig.AppConfig, installer IInstaller) error {
|
||||
enabled, err := InstallerIsEnabled(installer)
|
||||
|
||||
if err != nil {
|
||||
logger.Error("Failed to check if %s is enabled: %s", name, err)
|
||||
return nil
|
||||
return fmt.Errorf("Failed to check if %s is enabled: %s", name, err)
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
|
||||
19
utils/cache.go
Normal file
19
utils/cache.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func GetCacheDir() (string, error) {
|
||||
confDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cacheDir := filepath.Join(confDir, ".cache")
|
||||
err = os.MkdirAll(cacheDir, 0755)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cacheDir, nil
|
||||
}
|
||||
Reference in New Issue
Block a user