mirror of
https://github.com/chenasraf/sofmani.git
synced 2026-05-17 17:28:04 +00:00
feat: tree-mode for github release installers
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -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" %%*
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user