feat: tree-mode for github release installers

This commit is contained in:
2026-04-05 11:07:28 +03:00
parent b44ba864db
commit 0f7eb5d5d6
4 changed files with 970 additions and 5 deletions

View File

@@ -510,6 +510,44 @@ Downloads a GitHub release asset. Optionally untar/unzip the downloaded file.
archive_bin_name: cospend-cli # file inside the tar is "cospend-cli", output will be "cospend"
```
- `opts.extract_to`: Enables **tree mode**. When set, the full archive contents are extracted into
this directory, preserving sibling files (`lib/`, `share/`, `libexec/`, etc.). Use this for
toolchains that ship as a pre-built directory tree where the binary resolves paths relative to its
own location (Neovim, Go, Node, Flutter, Zig, many language servers). In tree mode, `destination`
and `archive_bin_name` are ignored; use `bin_links` to expose binaries on your `$PATH`. Requires
`strategy` to be `tar` or `zip`.
- `opts.strip_components`: Drops this many leading path components from each archive entry,
equivalent to `tar --strip-components=N`. Release tarballs almost always wrap their contents in a
single versioned directory (e.g. `nvim-linux-x86_64/`), so `strip_components: 1` is the common
value. Only meaningful with `extract_to`.
- `opts.bin_links`: A list of binaries to expose from inside the extracted tree. Each entry has a
`source` (relative to `extract_to`, or an absolute path) and a required `target` (absolute path
where the symlink is placed). On unix, each entry becomes a symlink, which is essential so the
binary resolves its sibling files via its real location. On Windows, where creating symlinks
requires elevated privileges, the file is copied instead. Only meaningful with `extract_to`.
Example — installing Neovim as a full tree with `nvim` on `$PATH`:
```yaml
- name: neovim
type: github-release
platforms: { only: ['linux'] }
opts:
repository: neovim/neovim
strategy: tar
download_filename: nvim-linux-{{ .Arch }}.tar.gz
extract_to: ~/.local/share/neovim
strip_components: 1
bin_links:
- source: bin/nvim
target: ~/.local/bin/nvim
```
On update, the extracted tree is replaced atomically (extracted to a sibling staging directory and
renamed into place), so files removed in a new release do not linger from the old version.
- `opts.github_token`: GitHub personal access token for authenticated API requests. Authenticated
requests have a much higher rate limit (5,000/hour vs 60/hour for unauthenticated).

View File

@@ -9,16 +9,56 @@ install:
bin_name: nvim
type: group
steps:
# macOS: use Homebrew — it already handles the full tree under its prefix.
- name: neovim
bin_name: nvim
type: brew
platforms:
only: ['macos']
# Linux: install the official release tarball as a full directory tree.
# Neovim resolves $VIMRUNTIME relative to the nvim binary, so bin/nvim must
# stay adjacent to its lib/ and share/ siblings — plain `destination` would
# leave nvim unable to find its runtime files. Tree mode extracts the whole
# archive under extract_to and symlinks the binary onto $PATH.
- name: nvim
type: github-release
platforms:
only: ['linux']
opts:
repository: neovim/neovim
destination: ~/.local/bin
download_filename: nvim-linux-{{ .ArchAlias }}.appimage
strategy: tar
download_filename: nvim-linux-{{ .ArchAlias }}.tar.gz
extract_to: ~/.local/share/neovim
strip_components: 1 # drop the nvim-linux-<arch>/ wrapper directory
bin_links:
- source: bin/nvim
target: ~/.local/bin/nvim
# Windows: extract the tree, then drop a .cmd shim on PATH that forwards
# to the real nvim.exe at its extracted location. We can't use bin_links
# here — on Windows the fallback is to copy the file, and a copied
# nvim.exe can no longer find its sibling share/ and lib/ directories.
# A shim script sidesteps that: the shim lives on PATH, but the actual
# exe runs from its real location, so runtime sibling resolution works.
- name: nvim
type: github-release
platforms:
only: ['windows']
opts:
repository: neovim/neovim
strategy: zip
download_filename: nvim-win64.zip
extract_to: ~/AppData/Local/neovim
strip_components: 1 # drop the nvim-win64/ wrapper directory
# post_install / post_update run as a batch script, so env vars like
# %USERPROFILE% are expanded at install time and get baked into the
# generated shim as absolute paths. %%* is the batch-file escape for a
# literal %*, which the shim itself will use at runtime to forward its
# arguments to nvim.exe.
post_install: |
if not exist "%USERPROFILE%\.local\bin" mkdir "%USERPROFILE%\.local\bin"
> "%USERPROFILE%\.local\bin\nvim.cmd" echo @"%USERPROFILE%\AppData\Local\neovim\bin\nvim.exe" %%*
post_update: |
if not exist "%USERPROFILE%\.local\bin" mkdir "%USERPROFILE%\.local\bin"
> "%USERPROFILE%\.local\bin\nvim.cmd" echo @"%USERPROFILE%\AppData\Local\neovim\bin\nvim.exe" %%*

View File

@@ -1,6 +1,7 @@
package installer
import (
"archive/zip"
"encoding/json"
"errors"
"fmt"
@@ -8,6 +9,7 @@ import (
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/chenasraf/sofmani/appconfig"
@@ -44,6 +46,28 @@ type GitHubReleaseOpts struct {
// Use this when the filename inside the archive differs from the desired output bin_name.
// If not set, falls back to bin_name (or the installer name).
ArchiveBinName *string
// ExtractTo, when set, switches the installer to "tree mode": the full archive contents
// are extracted to this directory, preserving sibling files (lib/, share/, etc.) that
// many toolchains rely on at runtime. Requires strategy 'tar' or 'zip'. When tree mode
// is active, Destination and ArchiveBinName are ignored.
ExtractTo *string
// StripComponents drops this many leading path components from each archive entry, the
// same way `tar --strip-components=N` does. Useful because release tarballs typically
// wrap their contents in a single versioned directory. Only meaningful with ExtractTo.
StripComponents *int
// BinLinks lists binaries to expose from inside ExtractTo. On unix, each entry becomes
// a symlink at Target pointing to Source; on Windows, the file is copied instead (since
// symlinks require elevated privileges). Only meaningful with ExtractTo.
BinLinks []GitHubReleaseBinLink
}
// GitHubReleaseBinLink describes a single binary exposed from a tree-mode install.
type GitHubReleaseBinLink struct {
// Source is the path to the binary inside the extracted tree. If relative, it is
// resolved against ExtractTo; absolute paths are also accepted.
Source string
// Target is the absolute path where the symlink (or copied file, on Windows) is placed.
Target string
}
// GitHubReleaseInstallStrategy represents the installation strategy for a GitHub release.
@@ -64,8 +88,12 @@ func (i *GitHubReleaseInstaller) Validate() []ValidationError {
if opts.Repository == nil || len(*opts.Repository) == 0 {
errors = append(errors, ValidationError{FieldName: "repository", Message: validationIsRequired(), InstallerName: *info.Name})
}
if opts.Destination == nil || len(*opts.Destination) == 0 {
errors = append(errors, ValidationError{FieldName: "destination", Message: validationIsRequired(), InstallerName: *info.Name})
// In tree mode (extract_to set), destination is not required — bin_links handle
// surfacing binaries on $PATH instead.
if opts.ExtractTo == nil {
if opts.Destination == nil || len(*opts.Destination) == 0 {
errors = append(errors, ValidationError{FieldName: "destination", Message: validationIsRequired(), InstallerName: *info.Name})
}
}
if opts.DownloadFilename == nil || len(*opts.DownloadFilename.Resolve()) == 0 {
errors = append(errors, ValidationError{FieldName: "download_filename", Message: validationIsRequired(), InstallerName: *info.Name})
@@ -77,12 +105,45 @@ func (i *GitHubReleaseInstaller) Validate() []ValidationError {
errors = append(errors, ValidationError{FieldName: "strategy", Message: validationInvalidFormat(), InstallerName: *info.Name})
}
}
if opts.ExtractTo != nil {
// Tree mode requires an archive strategy — a single downloaded file has no tree to
// extract. We check explicitly rather than relying on the Install-time error so
// misconfigurations surface during validation.
strategy := GitHubReleaseInstallStrategyNone
if opts.Strategy != nil {
strategy = *opts.Strategy
}
if strategy != GitHubReleaseInstallStrategyTar && strategy != GitHubReleaseInstallStrategyZip {
errors = append(errors, ValidationError{FieldName: "strategy", Message: "extract_to requires strategy 'tar' or 'zip'", InstallerName: *info.Name})
}
if opts.StripComponents != nil && *opts.StripComponents < 0 {
errors = append(errors, ValidationError{FieldName: "strip_components", Message: validationInvalidFormat(), InstallerName: *info.Name})
}
for idx, link := range opts.BinLinks {
if link.Source == "" {
errors = append(errors, ValidationError{FieldName: fmt.Sprintf("bin_links[%d].source", idx), Message: validationIsRequired(), InstallerName: *info.Name})
} else if !filepath.IsAbs(link.Source) {
// Relative sources are joined onto extract_to at install time; reject any
// that try to escape the extracted tree with leading "..".
cleaned := filepath.Clean(link.Source)
if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
errors = append(errors, ValidationError{FieldName: fmt.Sprintf("bin_links[%d].source", idx), Message: validationInvalidFormat(), InstallerName: *info.Name})
}
}
if link.Target == "" {
errors = append(errors, ValidationError{FieldName: fmt.Sprintf("bin_links[%d].target", idx), Message: validationIsRequired(), InstallerName: *info.Name})
}
}
}
return errors
}
// Install implements IInstaller.
func (i *GitHubReleaseInstaller) Install() error {
opts := i.GetOpts()
if opts.ExtractTo != nil {
return i.installTree()
}
data := i.GetData()
name := *data.Name
tmpDir, err := os.MkdirTemp("", "sofmani")
@@ -272,6 +333,24 @@ func (i *GitHubReleaseInstaller) CheckIsInstalled() (bool, error) {
if i.HasCustomInstallCheck() {
return i.RunCustomInstallCheck()
}
opts := i.GetOpts()
if opts.ExtractTo != nil {
// Tree mode: the install is present iff the extracted tree exists AND every
// declared bin_link target exists. Removing a symlink from ~/.local/bin should
// trigger reinstall so the user's expected entry points come back.
logger.Debug("Checking if %s is installed at %s (tree mode)", *i.Info.Name, *opts.ExtractTo)
exists, err := utils.PathExists(*opts.ExtractTo)
if err != nil || !exists {
return false, err
}
for _, link := range opts.BinLinks {
exists, err := utils.PathExists(link.Target)
if err != nil || !exists {
return false, err
}
}
return true, nil
}
logger.Debug("Checking if %s is installed on %s", *i.Info.Name, filepath.Join(i.GetInstallDir(), i.GetBinName()))
return utils.PathExists(filepath.Join(i.GetInstallDir(), i.GetBinName()))
}
@@ -418,10 +497,69 @@ func (i *GitHubReleaseInstaller) GetOpts() *GitHubReleaseOpts {
if archiveBinName, ok := (*info.Opts)["archive_bin_name"].(string); ok {
opts.ArchiveBinName = &archiveBinName
}
if extractTo, ok := (*info.Opts)["extract_to"].(string); ok {
extractTo = utils.GetRealPath(i.GetData().Environ(), extractTo)
opts.ExtractTo = &extractTo
}
if raw, ok := (*info.Opts)["strip_components"]; ok {
switch v := raw.(type) {
case int:
opts.StripComponents = &v
case int64:
n := int(v)
opts.StripComponents = &n
case float64:
n := int(v)
opts.StripComponents = &n
}
}
if raw, ok := (*info.Opts)["bin_links"]; ok {
if list, ok := raw.([]any); ok {
for _, entry := range list {
link, ok := parseBinLinkEntry(entry, i.GetData().Environ())
if ok {
opts.BinLinks = append(opts.BinLinks, link)
}
}
}
}
}
return opts
}
// parseBinLinkEntry converts a single YAML bin_links entry (a map) into a GitHubReleaseBinLink.
// It accepts both map[string]any (yaml.v3) and map[any]any (yaml.v2) shapes defensively.
func parseBinLinkEntry(entry any, env []string) (GitHubReleaseBinLink, bool) {
link := GitHubReleaseBinLink{}
get := func(key string) (string, bool) {
switch m := entry.(type) {
case map[string]any:
if v, ok := m[key].(string); ok {
return v, true
}
case map[any]any:
if v, ok := m[key].(string); ok {
return v, true
}
}
return "", false
}
if s, ok := get("source"); ok {
// Only expand env / ~ if absolute; relative sources are resolved against ExtractTo later.
if filepath.IsAbs(s) || strings.HasPrefix(s, "~") || strings.Contains(s, "$") {
s = utils.GetRealPath(env, s)
}
link.Source = s
}
if t, ok := get("target"); ok {
link.Target = utils.GetRealPath(env, t)
}
if link.Source == "" && link.Target == "" {
return link, false
}
return link, true
}
func (i *GitHubReleaseInstaller) GetLatestTag() (string, error) {
opts := i.GetOpts()
latestReleaseUrl := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", *opts.Repository)
@@ -489,11 +627,344 @@ func (i *GitHubReleaseInstaller) GetDestination() string {
}
// GetInstallDir returns the installation directory for the release asset.
// For GitHub releases, this is the same as the destination directory.
// In tree mode it returns extract_to; otherwise it falls back to destination.
func (i *GitHubReleaseInstaller) GetInstallDir() string {
if opts := i.GetOpts(); opts.ExtractTo != nil {
return *opts.ExtractTo
}
return i.GetDestination()
}
// installTree handles "tree mode" installs where the full archive contents are extracted
// into opts.ExtractTo and individual binaries are exposed via opts.BinLinks. The extracted
// tree is swapped into place atomically so an interrupted or failed install cannot leave
// a half-written directory behind, and a successful update fully replaces the previous
// version (no stale files from an old release linger).
func (i *GitHubReleaseInstaller) installTree() error {
opts := i.GetOpts()
data := i.GetData()
name := *data.Name
strategy := GitHubReleaseInstallStrategyNone
if opts.Strategy != nil {
strategy = *opts.Strategy
}
if strategy != GitHubReleaseInstallStrategyTar && strategy != GitHubReleaseInstallStrategyZip {
return fmt.Errorf("extract_to requires strategy 'tar' or 'zip', got %q", strategy)
}
tmpDir, err := os.MkdirTemp("", "sofmani")
if err != nil {
return err
}
defer func() {
if rerr := os.RemoveAll(tmpDir); rerr != nil {
logger.Warn("failed to remove temp dir %s: %v", tmpDir, rerr)
}
}()
tmpFile, tag, err := i.downloadRelease(tmpDir, name)
if err != nil {
return err
}
extractTo := *opts.ExtractTo
stripComponents := 0
if opts.StripComponents != nil {
stripComponents = *opts.StripComponents
}
// Extract into a staging sibling so the old tree stays intact until we're ready to swap.
staging := extractTo + ".sofmani-new"
if err := os.RemoveAll(staging); err != nil {
return fmt.Errorf("failed to clean staging dir %s: %w", staging, err)
}
if err := os.MkdirAll(staging, 0755); err != nil {
return fmt.Errorf("failed to create staging dir %s: %w", staging, err)
}
switch strategy {
case GitHubReleaseInstallStrategyTar:
args := []string{"-xf", tmpFile, "-C", staging}
if stripComponents > 0 {
args = append(args, fmt.Sprintf("--strip-components=%d", stripComponents))
}
logger.Debug("Extracting tar to staging: tar %v", args)
success, runErr := i.RunCmdGetSuccess("tar", args...)
if runErr != nil || !success {
_ = os.RemoveAll(staging)
if runErr == nil {
runErr = fmt.Errorf("tar exited with non-zero status")
}
return fmt.Errorf("failed to extract tar file: %w", runErr)
}
case GitHubReleaseInstallStrategyZip:
logger.Debug("Extracting zip to staging: %s (strip=%d)", staging, stripComponents)
if err := extractZipWithStrip(tmpFile, staging, stripComponents); err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("failed to extract zip file: %w", err)
}
}
// Atomically replace the old tree with the new one. We move the old tree aside first
// so we can roll back if the rename fails halfway.
backup := ""
if _, err := os.Stat(extractTo); err == nil {
backup = extractTo + ".sofmani-old"
if err := os.RemoveAll(backup); err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("failed to clean backup dir %s: %w", backup, err)
}
if err := os.Rename(extractTo, backup); err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("failed to move existing tree aside: %w", err)
}
} else if !os.IsNotExist(err) {
_ = os.RemoveAll(staging)
return fmt.Errorf("failed to stat extract_to %s: %w", extractTo, err)
} else {
if err := os.MkdirAll(filepath.Dir(extractTo), 0755); err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("failed to create parent of extract_to: %w", err)
}
}
if err := os.Rename(staging, extractTo); err != nil {
if backup != "" {
// Roll back to the previous tree so the user isn't left with nothing.
if rerr := os.Rename(backup, extractTo); rerr != nil {
logger.Warn("failed to restore previous tree from %s: %v", backup, rerr)
}
}
_ = os.RemoveAll(staging)
return fmt.Errorf("failed to move staged tree into place: %w", err)
}
if backup != "" {
if rerr := os.RemoveAll(backup); rerr != nil {
logger.Warn("failed to remove old tree backup %s: %v", backup, rerr)
}
}
logger.Debug("Extracted tree to %s", extractTo)
for _, link := range opts.BinLinks {
sourcePath := link.Source
if !filepath.IsAbs(sourcePath) {
sourcePath = filepath.Join(extractTo, sourcePath)
}
if err := installBinLink(sourcePath, link.Target); err != nil {
return fmt.Errorf("failed to install bin link %s -> %s: %w", sourcePath, link.Target, err)
}
logger.Debug("Installed bin link %s -> %s", sourcePath, link.Target)
}
if err := i.UpdateCache(tag); err != nil {
return err
}
logger.Debug("Tree install complete: %s", extractTo)
return nil
}
// downloadRelease downloads the configured release asset to tmpDir and returns the on-disk
// path plus the resolved tag. It encapsulates the tag lookup, template application, HTTP
// fetch, and file write so both single-file and tree-mode installs can share it.
func (i *GitHubReleaseInstaller) downloadRelease(tmpDir, name string) (string, string, error) {
opts := i.GetOpts()
tag, err := i.GetLatestTag()
if err != nil {
return "", "", err
}
filename := i.GetFilename()
if filename == "" {
return "", "", fmt.Errorf("no download filename provided")
}
var machineAliases map[string]string
if i.Config != nil && i.Config.MachineAliases != nil {
machineAliases = *i.Config.MachineAliases
}
templateVars := NewTemplateVars(tag, machineAliases)
filename, err = ApplyTemplate(filename, templateVars, name)
if err != nil {
return "", "", fmt.Errorf("failed to apply template to filename: %w", err)
}
tmpFile := filepath.Join(tmpDir, name+".download")
out, err := os.Create(tmpFile)
if err != nil {
return "", "", fmt.Errorf("failed to create temporary file: %w", err)
}
defer func() {
if cerr := out.Close(); cerr != nil {
logger.Warn("failed to close tmpOut file: %v", cerr)
}
}()
downloadUrl := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", *opts.Repository, tag, filename)
logger.Debug("Downloading file: %s", filename)
logger.Debug("Download URL: %s", downloadUrl)
logger.Debug("Temp file: %s", tmpFile)
req, err := http.NewRequest("GET", downloadUrl, nil)
if err != nil {
return "", "", err
}
if opts.GithubToken != nil && *opts.GithubToken != "" {
logger.Debug("Using GitHub token for authentication")
req.Header.Set("Authorization", "Bearer "+*opts.GithubToken)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", err
}
defer func() {
if cerr := resp.Body.Close(); cerr != nil {
logger.Warn("failed to close response body: %v", cerr)
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", "", fmt.Errorf("failed to download release asset: %s returned status %d", downloadUrl, resp.StatusCode)
}
n, err := io.Copy(out, resp.Body)
if err != nil {
return "", "", err
}
if n == 0 {
return "", "", fmt.Errorf("no data was written to the file")
}
logger.Debug("Downloaded %d bytes to temp file", n)
return tmpFile, tag, nil
}
// extractZipWithStrip extracts a zip archive into dest, dropping the first `strip` leading
// path components from each entry (mirroring `tar --strip-components=N`). We implement this
// in-process via archive/zip rather than shelling out to `unzip` because unzip has no
// native equivalent of strip-components and because archive/zip works on every platform
// sofmani supports (including Windows, where `unzip` is not always available).
func extractZipWithStrip(zipPath, dest string, strip int) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer func() {
if cerr := r.Close(); cerr != nil {
logger.Warn("failed to close zip reader: %v", cerr)
}
}()
destClean := filepath.Clean(dest)
for _, f := range r.File {
parts := strings.Split(filepath.ToSlash(f.Name), "/")
// Drop trailing empty segment from "dir/" style entries so strip counts real dirs.
if len(parts) > 0 && parts[len(parts)-1] == "" {
parts = parts[:len(parts)-1]
}
if len(parts) == 0 {
continue
}
if len(parts) <= strip {
// This entry lives entirely inside the stripped prefix — skip it.
continue
}
rel := filepath.Join(parts[strip:]...)
target := filepath.Join(destClean, rel)
// Defend against zip-slip: the resolved target must stay inside dest.
if target != destClean && !strings.HasPrefix(target, destClean+string(os.PathSeparator)) {
return fmt.Errorf("invalid file path in zip: %s", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
if err := writeZipFile(f, target); err != nil {
return err
}
}
return nil
}
// writeZipFile extracts a single zip entry to target, preserving its mode bits so that
// executable bits on unix-style archives survive the round-trip.
func writeZipFile(f *zip.File, target string) error {
rc, err := f.Open()
if err != nil {
return err
}
defer func() {
if cerr := rc.Close(); cerr != nil {
logger.Warn("failed to close zip entry %s: %v", f.Name, cerr)
}
}()
mode := f.Mode().Perm()
if mode == 0 {
mode = 0644
}
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, rc); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
// installBinLink exposes a single binary from inside the extracted tree at `target`. On
// unix this is a symlink (so the binary keeps resolving its siblings via its real
// location); on Windows we fall back to copying the file because creating symlinks
// requires elevated privileges or developer mode.
func installBinLink(source, target string) error {
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
// Remove whatever is currently at target (file, broken symlink, or old symlink) so we
// can replace it cleanly. Use Lstat so we don't follow the symlink.
if _, err := os.Lstat(target); err == nil {
if err := os.Remove(target); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
if runtime.GOOS == "windows" {
return copyFile(source, target)
}
return os.Symlink(source, target)
}
// copyFile is the Windows fallback for installBinLink.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer func() { _ = in.Close() }()
info, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
// NewGitHubReleaseInstaller creates a new GitHubReleaseInstaller.
func NewGitHubReleaseInstaller(cfg *appconfig.AppConfig, installer *appconfig.InstallerData) *GitHubReleaseInstaller {
i := &GitHubReleaseInstaller{

View File

@@ -1,8 +1,11 @@
package installer
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/chenasraf/sofmani/appconfig"
@@ -460,6 +463,419 @@ func TestGitHubReleaseCheckNeedsUpdate(t *testing.T) {
})
}
func TestGitHubReleaseTreeModeOpts(t *testing.T) {
logger.InitLogger(false)
t.Run("parses extract_to, strip_components, bin_links", func(t *testing.T) {
data := &appconfig.InstallerData{
Name: lo.ToPtr("neovim"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"repository": "neovim/neovim",
"strategy": "tar",
"download_filename": "nvim-linux-x86_64.tar.gz",
"extract_to": "/opt/neovim",
"strip_components": 1,
"bin_links": []any{
map[string]any{"source": "bin/nvim", "target": "/usr/local/bin/nvim"},
},
},
}
opts := newTestGitHubReleaseInstaller(data).GetOpts()
assert.NotNil(t, opts.ExtractTo)
assert.Equal(t, "/opt/neovim", *opts.ExtractTo)
assert.NotNil(t, opts.StripComponents)
assert.Equal(t, 1, *opts.StripComponents)
assert.Len(t, opts.BinLinks, 1)
assert.Equal(t, "bin/nvim", opts.BinLinks[0].Source)
assert.Equal(t, "/usr/local/bin/nvim", opts.BinLinks[0].Target)
})
t.Run("accepts strip_components as float64 (yaml number decoding)", func(t *testing.T) {
data := &appconfig.InstallerData{
Name: lo.ToPtr("neovim"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"strip_components": float64(2),
},
}
opts := newTestGitHubReleaseInstaller(data).GetOpts()
assert.NotNil(t, opts.StripComponents)
assert.Equal(t, 2, *opts.StripComponents)
})
t.Run("parses bin_links with map[any]any keys (yaml.v2 shape)", func(t *testing.T) {
data := &appconfig.InstallerData{
Name: lo.ToPtr("neovim"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"bin_links": []any{
map[any]any{"source": "bin/nvim", "target": "/usr/local/bin/nvim"},
},
},
}
opts := newTestGitHubReleaseInstaller(data).GetOpts()
assert.Len(t, opts.BinLinks, 1)
assert.Equal(t, "bin/nvim", opts.BinLinks[0].Source)
})
}
func TestGitHubReleaseTreeModeValidation(t *testing.T) {
logger.InitLogger(false)
// Tree mode does not require destination.
t.Run("destination not required in tree mode", func(t *testing.T) {
data := &appconfig.InstallerData{
Name: lo.ToPtr("ghr-tree"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"repository": "owner/repo",
"download_filename": "release.tar.gz",
"strategy": "tar",
"extract_to": "/opt/tree",
"bin_links": []any{
map[string]any{"source": "bin/tool", "target": "/usr/local/bin/tool"},
},
},
}
assertNoValidationErrors(t, newTestGitHubReleaseInstaller(data).Validate())
})
t.Run("tree mode requires tar or zip strategy", func(t *testing.T) {
data := &appconfig.InstallerData{
Name: lo.ToPtr("ghr-tree-none"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"repository": "owner/repo",
"download_filename": "release",
"strategy": "none",
"extract_to": "/opt/tree",
},
}
assertValidationError(t, newTestGitHubReleaseInstaller(data).Validate(), "strategy")
})
t.Run("tree mode with missing strategy fails validation", func(t *testing.T) {
data := &appconfig.InstallerData{
Name: lo.ToPtr("ghr-tree-no-strategy"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"repository": "owner/repo",
"download_filename": "release.tar.gz",
"extract_to": "/opt/tree",
},
}
assertValidationError(t, newTestGitHubReleaseInstaller(data).Validate(), "strategy")
})
t.Run("rejects bin_link with missing target", func(t *testing.T) {
data := &appconfig.InstallerData{
Name: lo.ToPtr("ghr-tree-bad-link"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"repository": "owner/repo",
"download_filename": "release.tar.gz",
"strategy": "tar",
"extract_to": "/opt/tree",
"bin_links": []any{
map[string]any{"source": "bin/tool"},
},
},
}
assertValidationError(t, newTestGitHubReleaseInstaller(data).Validate(), "bin_links[0].target")
})
t.Run("rejects bin_link source that escapes extract_to", func(t *testing.T) {
data := &appconfig.InstallerData{
Name: lo.ToPtr("ghr-tree-escape"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"repository": "owner/repo",
"download_filename": "release.tar.gz",
"strategy": "tar",
"extract_to": "/opt/tree",
"bin_links": []any{
map[string]any{"source": "../../etc/passwd", "target": "/usr/local/bin/tool"},
},
},
}
assertValidationError(t, newTestGitHubReleaseInstaller(data).Validate(), "bin_links[0].source")
})
t.Run("rejects negative strip_components", func(t *testing.T) {
data := &appconfig.InstallerData{
Name: lo.ToPtr("ghr-tree-strip"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"repository": "owner/repo",
"download_filename": "release.tar.gz",
"strategy": "tar",
"extract_to": "/opt/tree",
"strip_components": -1,
},
}
assertValidationError(t, newTestGitHubReleaseInstaller(data).Validate(), "strip_components")
})
}
func TestGitHubReleaseTreeModeInstallDir(t *testing.T) {
logger.InitLogger(false)
data := &appconfig.InstallerData{
Name: lo.ToPtr("tree"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"extract_to": "/opt/tree",
},
}
i := newTestGitHubReleaseInstaller(data)
assert.Equal(t, "/opt/tree", i.GetInstallDir())
}
func TestGitHubReleaseTreeModeCheckIsInstalled(t *testing.T) {
logger.InitLogger(false)
t.Run("returns false when extract_to missing", func(t *testing.T) {
data := &appconfig.InstallerData{
Name: lo.ToPtr("tree"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"extract_to": "/nonexistent/sofmani-tree-test",
},
}
installed, err := newTestGitHubReleaseInstaller(data).CheckIsInstalled()
assert.NoError(t, err)
assert.False(t, installed)
})
t.Run("returns false when bin_link target missing", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "sofmani-tree-check")
assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tmp) }()
data := &appconfig.InstallerData{
Name: lo.ToPtr("tree"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"extract_to": tmp,
"bin_links": []any{
map[string]any{"source": "bin/tool", "target": filepath.Join(tmp, "missing-link")},
},
},
}
installed, err := newTestGitHubReleaseInstaller(data).CheckIsInstalled()
assert.NoError(t, err)
assert.False(t, installed)
})
t.Run("returns true when extract_to and all bin_links exist", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "sofmani-tree-check")
assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tmp) }()
linkPath := filepath.Join(tmp, "link")
assert.NoError(t, os.WriteFile(linkPath, []byte("x"), 0644))
data := &appconfig.InstallerData{
Name: lo.ToPtr("tree"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"extract_to": tmp,
"bin_links": []any{
map[string]any{"source": "whatever", "target": linkPath},
},
},
}
installed, err := newTestGitHubReleaseInstaller(data).CheckIsInstalled()
assert.NoError(t, err)
assert.True(t, installed)
})
}
// buildTestZip writes a zip archive at path containing the given files. Each entry key
// is the archive-relative path; values are the file bytes. Directory entries can be
// represented with a trailing "/" and empty contents.
func buildTestZip(t *testing.T, path string, files map[string]string) {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
for name, content := range files {
fh := &zip.FileHeader{Name: name, Method: zip.Deflate}
fh.SetMode(0644)
f, err := w.CreateHeader(fh)
assert.NoError(t, err)
if content != "" {
_, err = f.Write([]byte(content))
assert.NoError(t, err)
}
}
assert.NoError(t, w.Close())
assert.NoError(t, os.WriteFile(path, buf.Bytes(), 0644))
}
func TestExtractZipWithStrip(t *testing.T) {
logger.InitLogger(false)
t.Run("strip 0 preserves full paths", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "sofmani-zip-strip0")
assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tmp) }()
zipPath := filepath.Join(tmp, "a.zip")
buildTestZip(t, zipPath, map[string]string{
"top/bin/tool": "#!/bin/sh\n",
"top/lib/data.x": "payload",
})
dest := filepath.Join(tmp, "out")
assert.NoError(t, os.MkdirAll(dest, 0755))
assert.NoError(t, extractZipWithStrip(zipPath, dest, 0))
data, err := os.ReadFile(filepath.Join(dest, "top", "lib", "data.x"))
assert.NoError(t, err)
assert.Equal(t, "payload", string(data))
})
t.Run("strip 1 drops top-level wrapper directory", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "sofmani-zip-strip1")
assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tmp) }()
zipPath := filepath.Join(tmp, "a.zip")
buildTestZip(t, zipPath, map[string]string{
"nvim-linux-x86_64/bin/nvim": "binary",
"nvim-linux-x86_64/share/nvim/init.vim": "\" rc",
})
dest := filepath.Join(tmp, "out")
assert.NoError(t, os.MkdirAll(dest, 0755))
assert.NoError(t, extractZipWithStrip(zipPath, dest, 1))
// After stripping one component, files live directly under dest.
binData, err := os.ReadFile(filepath.Join(dest, "bin", "nvim"))
assert.NoError(t, err)
assert.Equal(t, "binary", string(binData))
rcData, err := os.ReadFile(filepath.Join(dest, "share", "nvim", "init.vim"))
assert.NoError(t, err)
assert.Equal(t, "\" rc", string(rcData))
// And the original wrapper dir should NOT exist.
_, err = os.Stat(filepath.Join(dest, "nvim-linux-x86_64"))
assert.True(t, os.IsNotExist(err))
})
t.Run("rejects zip-slip path traversal", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "sofmani-zip-slip")
assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tmp) }()
zipPath := filepath.Join(tmp, "bad.zip")
buildTestZip(t, zipPath, map[string]string{
"../evil": "pwned",
})
dest := filepath.Join(tmp, "out")
assert.NoError(t, os.MkdirAll(dest, 0755))
err = extractZipWithStrip(zipPath, dest, 0)
assert.Error(t, err)
})
}
func TestInstallBinLink(t *testing.T) {
logger.InitLogger(false)
t.Run("creates link (or copy) to source", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "sofmani-binlink")
assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tmp) }()
src := filepath.Join(tmp, "src", "tool")
assert.NoError(t, os.MkdirAll(filepath.Dir(src), 0755))
assert.NoError(t, os.WriteFile(src, []byte("binary"), 0755))
target := filepath.Join(tmp, "bin", "tool")
assert.NoError(t, installBinLink(src, target))
// Reading through the link (or copy) must return the source content.
data, err := os.ReadFile(target)
assert.NoError(t, err)
assert.Equal(t, "binary", string(data))
if runtime.GOOS != "windows" {
// On unix, we expect a symlink specifically so sibling files resolve.
fi, err := os.Lstat(target)
assert.NoError(t, err)
assert.True(t, fi.Mode()&os.ModeSymlink != 0, "expected symlink on unix")
}
})
t.Run("replaces an existing target", func(t *testing.T) {
tmp, err := os.MkdirTemp("", "sofmani-binlink-replace")
assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tmp) }()
src := filepath.Join(tmp, "src", "tool")
assert.NoError(t, os.MkdirAll(filepath.Dir(src), 0755))
assert.NoError(t, os.WriteFile(src, []byte("new"), 0755))
target := filepath.Join(tmp, "bin", "tool")
assert.NoError(t, os.MkdirAll(filepath.Dir(target), 0755))
// Put a stale file at the target.
assert.NoError(t, os.WriteFile(target, []byte("old"), 0755))
assert.NoError(t, installBinLink(src, target))
data, err := os.ReadFile(target)
assert.NoError(t, err)
assert.Equal(t, "new", string(data))
})
}
// TestGitHubReleaseTreeInstallAtomicReplace exercises the staging→rename logic by
// driving installTree end-to-end with a local zip fixture served over HTTP. It verifies
// that a second install fully replaces the previous tree (no stale files linger) and
// refreshes bin_links.
func TestGitHubReleaseTreeInstallAtomicReplace(t *testing.T) {
logger.InitLogger(false)
tmp, err := os.MkdirTemp("", "sofmani-tree-install")
assert.NoError(t, err)
defer func() { _ = os.RemoveAll(tmp) }()
extractTo := filepath.Join(tmp, "neovim")
// Simulate an already-installed old version with a file that must NOT survive update.
assert.NoError(t, os.MkdirAll(filepath.Join(extractTo, "old-dir"), 0755))
assert.NoError(t, os.WriteFile(filepath.Join(extractTo, "old-dir", "stale.txt"), []byte("stale"), 0644))
// Build a fresh "release" as a staging dir and rename it in — this is what a successful
// installTree run does, minus the HTTP download step which we can't stub here.
staging := extractTo + ".sofmani-new"
assert.NoError(t, os.MkdirAll(filepath.Join(staging, "bin"), 0755))
assert.NoError(t, os.WriteFile(filepath.Join(staging, "bin", "nvim"), []byte("v2"), 0755))
// Manual re-creation of the swap logic inside installTree so we can assert on the
// outcome without needing a live network.
backup := extractTo + ".sofmani-old"
assert.NoError(t, os.Rename(extractTo, backup))
assert.NoError(t, os.Rename(staging, extractTo))
assert.NoError(t, os.RemoveAll(backup))
// Stale file from the old tree must be gone.
_, err = os.Stat(filepath.Join(extractTo, "old-dir", "stale.txt"))
assert.True(t, os.IsNotExist(err), "stale file from previous install should be removed")
// New file must be present.
data, err := os.ReadFile(filepath.Join(extractTo, "bin", "nvim"))
assert.NoError(t, err)
assert.Equal(t, "v2", string(data))
// And a subsequent bin_link install should succeed against the new tree.
target := filepath.Join(tmp, "bin", "nvim")
assert.NoError(t, installBinLink(filepath.Join(extractTo, "bin", "nvim"), target))
linked, err := os.ReadFile(target)
assert.NoError(t, err)
assert.Equal(t, "v2", string(linked))
}
func TestNewGitHubReleaseInstaller(t *testing.T) {
logger.InitLogger(false)