feat: github release installer

This commit is contained in:
2025-01-21 23:58:34 +02:00
parent a999708dde
commit 00934e98f9
7 changed files with 422 additions and 14 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
sofmani
sofmani.log
test.yml

View File

@@ -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.

View 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 {

View File

@@ -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

View 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
}

View File

@@ -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
View 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
}