mirror of
https://github.com/chenasraf/sofmani.git
synced 2026-05-17 17:28:04 +00:00
feat: add github-release custom strategy
This commit is contained in:
@@ -245,7 +245,8 @@ For a full list with all the supported options, see [the docs](./docs/installer-
|
||||
repository path, e.g. `chenasraf/sofmani`, GitHub is assumed.
|
||||
|
||||
- **`github-release`**
|
||||
- Downloads a GitHub release asset. Optionally untar, unzip, or gunzip the downloaded file.
|
||||
- Downloads a GitHub release asset. Optionally untar, unzip, gunzip, or run a custom
|
||||
shell hook to extract the downloaded file.
|
||||
|
||||
- **`manifest`**
|
||||
- Installs an entire manifest from a local or remote file.
|
||||
|
||||
@@ -385,6 +385,11 @@ Available variables:
|
||||
| `{{ .DeviceIDAlias }}` | Friendly alias for the current machine, if defined in `machine_aliases` | `work-laptop` |
|
||||
| `{{ .Tag }}` | Full tag name (only available in `github-release` `download_filename`) | `v1.0.0` |
|
||||
| `{{ .Version }}` | Version without leading "v" (only available in `github-release` `download_filename`) | `1.0.0` |
|
||||
| `{{ .DownloadFile }}` | Absolute path to the downloaded asset (only in `github-release` `extract_command`) | `/tmp/sofmani.../app.download` |
|
||||
| `{{ .ExtractDir }}` | Temp directory to extract into (only in `github-release` `extract_command`) | `/tmp/sofmani...` |
|
||||
| `{{ .Destination }}` | Final destination directory (only in `github-release` `extract_command`) | `~/.local/bin` |
|
||||
| `{{ .BinName }}` | Expected output binary name (only in `github-release` `extract_command`) | `my-tool` |
|
||||
| `{{ .ArchiveBinName }}`| Filename sofmani copies from `ExtractDir` → `Destination` (only in `extract_command`)| `my-tool` |
|
||||
|
||||
In addition, `DEVICE_ID` and `DEVICE_ID_ALIAS` are injected as **environment variables** into all
|
||||
command executions, so they can also be referenced as `$DEVICE_ID` and `$DEVICE_ID_ALIAS` in shell
|
||||
@@ -454,7 +459,8 @@ Downloads a GitHub release asset. Optionally untar/unzip the downloaded file.
|
||||
- `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`, `gzip`, `none` (default)
|
||||
- `opts.strategy`: The download strategy. Can be one of: `tar`, `zip`, `gzip`, `custom`, `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
|
||||
@@ -472,6 +478,54 @@ Downloads a GitHub release asset. Optionally untar/unzip the downloaded file.
|
||||
strategy: gzip
|
||||
download_filename: tree-sitter-{{ .OS }}-{{ .ArchAlias }}.gz
|
||||
```
|
||||
|
||||
- `custom` - run a user-provided shell hook (`opts.extract_command`) to extract the
|
||||
downloaded asset yourself. After the command finishes, sofmani copies
|
||||
`{{ .ExtractDir }}/{{ .ArchiveBinName }}` to `{{ .Destination }}/{{ .BinName }}` and
|
||||
sets the executable bit — exactly like the `tar` and `zip` strategies do. Use this
|
||||
for unusual archive formats (7-Zip, xz, self-extracting installers, ...).
|
||||
|
||||
- `opts.extract_command`: The shell command to run when `strategy: custom`. It goes through
|
||||
the same Go template substitution as other sofmani shell hooks, with extra variables
|
||||
specific to the extract context:
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | ----------------------------------------------------------------------- |
|
||||
| `{{ .DownloadFile }}` | Absolute path to the downloaded asset |
|
||||
| `{{ .ExtractDir }}` | Temp directory — your command should place extracted files here |
|
||||
| `{{ .Destination }}` | Final destination directory (from `opts.destination`) |
|
||||
| `{{ .BinName }}` | The expected output binary name (`bin_name` or installer name) |
|
||||
| `{{ .ArchiveBinName }}`| Filename sofmani will copy from `ExtractDir` → `Destination` afterwards |
|
||||
|
||||
All the usual template variables (`{{ .OS }}`, `{{ .Arch }}`, `{{ .Tag }}`, ...) are also
|
||||
available. `extract_command` is required when `strategy: custom`, and is not allowed
|
||||
with any other strategy.
|
||||
|
||||
Example — extracting a `.tar.xz` asset by shelling out to `tar`:
|
||||
|
||||
```yaml
|
||||
- name: my-tool
|
||||
type: github-release
|
||||
opts:
|
||||
repository: example/my-tool
|
||||
destination: ~/.local/bin
|
||||
strategy: custom
|
||||
download_filename: my-tool-{{ .Version }}-{{ .OS }}.tar.xz
|
||||
extract_command: tar -xJf {{ .DownloadFile }} -C {{ .ExtractDir }}
|
||||
```
|
||||
|
||||
Example — extracting a 7-Zip asset:
|
||||
|
||||
```yaml
|
||||
- name: weird-tool
|
||||
type: github-release
|
||||
opts:
|
||||
repository: example/weird-tool
|
||||
destination: ~/.local/bin
|
||||
strategy: custom
|
||||
download_filename: weird-tool-{{ .Version }}.7z
|
||||
extract_command: 7z x {{ .DownloadFile }} -o{{ .ExtractDir }}
|
||||
```
|
||||
- `opts.download_filename`: The filename of the release asset to download.
|
||||
|
||||
This should either be a string, or a map of platforms to filenames.
|
||||
|
||||
@@ -60,6 +60,17 @@ type GitHubReleaseOpts struct {
|
||||
// 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
|
||||
// ExtractCommand is a user-provided shell command that performs the extraction when
|
||||
// Strategy is "custom". The command is run through Go template substitution with these
|
||||
// extra variables available (in addition to the usual .OS, .Arch, .Tag, ...):
|
||||
// {{ .DownloadFile }} - absolute path to the downloaded asset
|
||||
// {{ .ExtractDir }} - temp directory where the command should place extracted files
|
||||
// {{ .Destination }} - final destination directory
|
||||
// {{ .BinName }} - expected binary name (matches GetBinName())
|
||||
// {{ .ArchiveBinName }} - the filename sofmani will copy from ExtractDir to Destination
|
||||
// After the command finishes, sofmani copies ExtractDir/ArchiveBinName to
|
||||
// Destination/BinName, the same way the tar and zip strategies do.
|
||||
ExtractCommand *string
|
||||
}
|
||||
|
||||
// GitHubReleaseBinLink describes a single binary exposed from a tree-mode install.
|
||||
@@ -76,10 +87,11 @@ type GitHubReleaseInstallStrategy string
|
||||
|
||||
// Constants for GitHub release installation strategies.
|
||||
const (
|
||||
GitHubReleaseInstallStrategyNone GitHubReleaseInstallStrategy = "none" // GitHubReleaseInstallStrategyNone means no special handling, just download the file.
|
||||
GitHubReleaseInstallStrategyTar GitHubReleaseInstallStrategy = "tar" // GitHubReleaseInstallStrategyTar means extract a tar archive.
|
||||
GitHubReleaseInstallStrategyZip GitHubReleaseInstallStrategy = "zip" // GitHubReleaseInstallStrategyZip means extract a zip archive.
|
||||
GitHubReleaseInstallStrategyGzip GitHubReleaseInstallStrategy = "gzip" // GitHubReleaseInstallStrategyGzip means decompress a single gzip-compressed file (not a tar archive).
|
||||
GitHubReleaseInstallStrategyNone GitHubReleaseInstallStrategy = "none" // GitHubReleaseInstallStrategyNone means no special handling, just download the file.
|
||||
GitHubReleaseInstallStrategyTar GitHubReleaseInstallStrategy = "tar" // GitHubReleaseInstallStrategyTar means extract a tar archive.
|
||||
GitHubReleaseInstallStrategyZip GitHubReleaseInstallStrategy = "zip" // GitHubReleaseInstallStrategyZip means extract a zip archive.
|
||||
GitHubReleaseInstallStrategyGzip GitHubReleaseInstallStrategy = "gzip" // GitHubReleaseInstallStrategyGzip means decompress a single gzip-compressed file (not a tar archive).
|
||||
GitHubReleaseInstallStrategyCustom GitHubReleaseInstallStrategy = "custom" // GitHubReleaseInstallStrategyCustom runs a user-provided shell command to extract the asset.
|
||||
)
|
||||
|
||||
// Validate validates the installer configuration.
|
||||
@@ -107,12 +119,22 @@ func (i *GitHubReleaseInstaller) Validate() []ValidationError {
|
||||
case GitHubReleaseInstallStrategyNone,
|
||||
GitHubReleaseInstallStrategyTar,
|
||||
GitHubReleaseInstallStrategyZip,
|
||||
GitHubReleaseInstallStrategyGzip:
|
||||
GitHubReleaseInstallStrategyGzip,
|
||||
GitHubReleaseInstallStrategyCustom:
|
||||
// valid
|
||||
default:
|
||||
errors = append(errors, ValidationError{FieldName: "strategy", Message: validationInvalidFormat(), InstallerName: *info.Name})
|
||||
}
|
||||
}
|
||||
// extract_command only makes sense with strategy: custom, and strategy: custom requires it.
|
||||
strategyIsCustom := opts.Strategy != nil && *opts.Strategy == GitHubReleaseInstallStrategyCustom
|
||||
hasExtractCommand := opts.ExtractCommand != nil && *opts.ExtractCommand != ""
|
||||
if strategyIsCustom && !hasExtractCommand {
|
||||
errors = append(errors, ValidationError{FieldName: "extract_command", Message: validationIsRequired(), InstallerName: *info.Name})
|
||||
}
|
||||
if hasExtractCommand && !strategyIsCustom {
|
||||
errors = append(errors, ValidationError{FieldName: "extract_command", Message: "extract_command requires strategy: custom", 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
|
||||
@@ -305,6 +327,28 @@ func (i *GitHubReleaseInstaller) Install() error {
|
||||
}
|
||||
success = true
|
||||
err = nil
|
||||
case GitHubReleaseInstallStrategyCustom:
|
||||
logger.Debug("Strategy 'custom': running user extract_command against %s", tmpOut.Name())
|
||||
if opts.ExtractCommand == nil || *opts.ExtractCommand == "" {
|
||||
return fmt.Errorf("strategy 'custom' requires opts.extract_command")
|
||||
}
|
||||
extractVars := *templateVars
|
||||
extractVars.DownloadFile = tmpOut.Name()
|
||||
extractVars.ExtractDir = tmpDir
|
||||
extractVars.Destination = *opts.Destination
|
||||
extractVars.BinName = i.GetBinName()
|
||||
extractVars.ArchiveBinName = i.GetArchiveBinName()
|
||||
if err = i.runCustomExtract(*opts.ExtractCommand, &extractVars); err != nil {
|
||||
return fmt.Errorf("custom extract failed: %w", err)
|
||||
}
|
||||
logger.Debug("Strategy 'custom': copying binary '%s' to destination", i.GetArchiveBinName())
|
||||
success, err = i.CopyExtractedFile(out, tmpDir)
|
||||
if !success {
|
||||
return fmt.Errorf("failed to copy extracted file: %w", err)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
logger.Debug("Strategy 'none': copying downloaded file directly to destination")
|
||||
// Seek back to beginning of temp file before copying
|
||||
@@ -414,6 +458,29 @@ func (i *GitHubReleaseInstaller) GetArchiveBinName() string {
|
||||
return i.GetBinName()
|
||||
}
|
||||
|
||||
// runCustomExtract runs a user-provided extract command through the platform's
|
||||
// default shell. The command is first rendered with ApplyTemplate so users can
|
||||
// reference {{ .DownloadFile }}, {{ .ExtractDir }}, {{ .Destination }},
|
||||
// {{ .BinName }}, {{ .ArchiveBinName }}, and all the usual template variables
|
||||
// (.OS, .Arch, .Tag, ...).
|
||||
func (i *GitHubReleaseInstaller) runCustomExtract(command string, vars *TemplateVars) error {
|
||||
rendered, err := ApplyTemplate(command, vars, *i.Info.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render extract_command template: %w", err)
|
||||
}
|
||||
logger.Debug("Custom extract command: %s", rendered)
|
||||
shell := utils.GetOSShell(i.GetData().EnvShell)
|
||||
args := utils.GetOSShellArgs(rendered)
|
||||
success, err := i.RunCmdGetSuccessPassThrough(shell, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !success {
|
||||
return fmt.Errorf("extract_command exited non-zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decompressGzip reads a gzip-compressed stream from src and writes the
|
||||
// decompressed bytes to dst. It is used by the "gzip" github-release strategy
|
||||
// for single-file gzipped assets (i.e. not tarballs).
|
||||
@@ -597,6 +664,9 @@ func (i *GitHubReleaseInstaller) GetOpts() *GitHubReleaseOpts {
|
||||
if archiveBinName, ok := (*info.Opts)["archive_bin_name"].(string); ok {
|
||||
opts.ArchiveBinName = &archiveBinName
|
||||
}
|
||||
if extractCommand, ok := (*info.Opts)["extract_command"].(string); ok {
|
||||
opts.ExtractCommand = &extractCommand
|
||||
}
|
||||
if extractTo, ok := (*info.Opts)["extract_to"].(string); ok {
|
||||
extractTo = utils.GetRealPath(i.GetData().Environ(), extractTo)
|
||||
opts.ExtractTo = &extractTo
|
||||
|
||||
@@ -102,6 +102,47 @@ func TestGitHubReleaseValidation(t *testing.T) {
|
||||
},
|
||||
}
|
||||
assertNoValidationErrors(t, newTestGitHubReleaseInstaller(gzipStrategy).Validate())
|
||||
|
||||
// 🟢 Valid custom strategy with extract_command
|
||||
customStrategy := &appconfig.InstallerData{
|
||||
Name: lo.ToPtr("ghr-custom"),
|
||||
Type: appconfig.InstallerTypeGitHubRelease,
|
||||
Opts: &map[string]any{
|
||||
"repository": "owner/repo",
|
||||
"destination": "/some/path",
|
||||
"download_filename": "file.weird",
|
||||
"strategy": "custom",
|
||||
"extract_command": "7z x {{ .DownloadFile }} -o{{ .ExtractDir }}",
|
||||
},
|
||||
}
|
||||
assertNoValidationErrors(t, newTestGitHubReleaseInstaller(customStrategy).Validate())
|
||||
|
||||
// 🔴 custom strategy without extract_command
|
||||
customMissingCmd := &appconfig.InstallerData{
|
||||
Name: lo.ToPtr("ghr-custom-missing"),
|
||||
Type: appconfig.InstallerTypeGitHubRelease,
|
||||
Opts: &map[string]any{
|
||||
"repository": "owner/repo",
|
||||
"destination": "/some/path",
|
||||
"download_filename": "file.weird",
|
||||
"strategy": "custom",
|
||||
},
|
||||
}
|
||||
assertValidationError(t, newTestGitHubReleaseInstaller(customMissingCmd).Validate(), "extract_command")
|
||||
|
||||
// 🔴 extract_command without strategy: custom
|
||||
extractCmdWrongStrategy := &appconfig.InstallerData{
|
||||
Name: lo.ToPtr("ghr-custom-wrong-strategy"),
|
||||
Type: appconfig.InstallerTypeGitHubRelease,
|
||||
Opts: &map[string]any{
|
||||
"repository": "owner/repo",
|
||||
"destination": "/some/path",
|
||||
"download_filename": "file.tar.gz",
|
||||
"strategy": "tar",
|
||||
"extract_command": "echo nope",
|
||||
},
|
||||
}
|
||||
assertValidationError(t, newTestGitHubReleaseInstaller(extractCmdWrongStrategy).Validate(), "extract_command")
|
||||
}
|
||||
|
||||
func TestGitHubReleaseGetOpts(t *testing.T) {
|
||||
@@ -195,6 +236,89 @@ func TestGitHubReleaseGetOpts(t *testing.T) {
|
||||
|
||||
assert.Equal(t, GitHubReleaseInstallStrategyGzip, *opts.Strategy)
|
||||
})
|
||||
|
||||
t.Run("handles custom strategy with extract_command", func(t *testing.T) {
|
||||
data := &appconfig.InstallerData{
|
||||
Name: lo.ToPtr("test-release"),
|
||||
Type: appconfig.InstallerTypeGitHubRelease,
|
||||
Opts: &map[string]any{
|
||||
"strategy": "custom",
|
||||
"extract_command": "cp {{ .DownloadFile }} {{ .ExtractDir }}/{{ .ArchiveBinName }}",
|
||||
},
|
||||
}
|
||||
installer := newTestGitHubReleaseInstaller(data)
|
||||
opts := installer.GetOpts()
|
||||
|
||||
assert.Equal(t, GitHubReleaseInstallStrategyCustom, *opts.Strategy)
|
||||
assert.NotNil(t, opts.ExtractCommand)
|
||||
assert.Contains(t, *opts.ExtractCommand, "{{ .DownloadFile }}")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitHubReleaseCustomExtract(t *testing.T) {
|
||||
logger.InitLogger(false)
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("custom extract test uses a POSIX shell command")
|
||||
}
|
||||
|
||||
// Prepare a fake "downloaded" asset on disk and an extract dir.
|
||||
tmpDir := t.TempDir()
|
||||
downloadFile := filepath.Join(tmpDir, "asset.weird")
|
||||
payload := []byte("binary payload from sofmani custom extract test")
|
||||
assert.NoError(t, os.WriteFile(downloadFile, payload, 0644))
|
||||
|
||||
extractDir := filepath.Join(tmpDir, "extract")
|
||||
assert.NoError(t, os.Mkdir(extractDir, 0755))
|
||||
|
||||
data := &appconfig.InstallerData{
|
||||
Name: lo.ToPtr("custom-tool"),
|
||||
Type: appconfig.InstallerTypeGitHubRelease,
|
||||
Opts: &map[string]any{
|
||||
// The user's command references template variables directly instead of env vars.
|
||||
// Here we pretend the "weird" asset really just needs to be copied.
|
||||
"extract_command": "cp {{ .DownloadFile }} {{ .ExtractDir }}/{{ .ArchiveBinName }}",
|
||||
},
|
||||
}
|
||||
installer := newTestGitHubReleaseInstaller(data)
|
||||
|
||||
vars := NewTemplateVars("v1.2.3", nil)
|
||||
vars.DownloadFile = downloadFile
|
||||
vars.ExtractDir = extractDir
|
||||
vars.Destination = filepath.Join(tmpDir, "dest")
|
||||
vars.BinName = "custom-tool"
|
||||
vars.ArchiveBinName = "custom-tool"
|
||||
|
||||
err := installer.runCustomExtract("cp {{ .DownloadFile }} {{ .ExtractDir }}/{{ .ArchiveBinName }}", vars)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// The command should have produced extractDir/custom-tool with the original payload.
|
||||
got, err := os.ReadFile(filepath.Join(extractDir, "custom-tool"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, payload, got)
|
||||
}
|
||||
|
||||
func TestGitHubReleaseCustomExtractFailures(t *testing.T) {
|
||||
logger.InitLogger(false)
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell command")
|
||||
}
|
||||
|
||||
data := &appconfig.InstallerData{
|
||||
Name: lo.ToPtr("custom-tool"),
|
||||
Type: appconfig.InstallerTypeGitHubRelease,
|
||||
}
|
||||
installer := newTestGitHubReleaseInstaller(data)
|
||||
vars := NewTemplateVars("v0.0.0", nil)
|
||||
|
||||
t.Run("non-zero exit is surfaced", func(t *testing.T) {
|
||||
err := installer.runCustomExtract("exit 42", vars)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid template is surfaced", func(t *testing.T) {
|
||||
err := installer.runCustomExtract("echo {{ .NopeField", vars)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecompressGzip(t *testing.T) {
|
||||
|
||||
@@ -28,6 +28,22 @@ type TemplateVars struct {
|
||||
DeviceID string
|
||||
// DeviceIDAlias is the friendly alias for the current machine, if one is defined in machine_aliases.
|
||||
DeviceIDAlias string
|
||||
// DownloadFile is the absolute path to the downloaded asset. Only populated for
|
||||
// github-release custom extract commands.
|
||||
DownloadFile string
|
||||
// ExtractDir is the temp directory where a custom extract command should place
|
||||
// extracted files. Only populated for github-release custom extract commands.
|
||||
ExtractDir string
|
||||
// Destination is the final destination directory. Only populated for github-release
|
||||
// custom extract commands.
|
||||
Destination string
|
||||
// BinName is the expected output binary name. Only populated for github-release
|
||||
// custom extract commands.
|
||||
BinName string
|
||||
// ArchiveBinName is the filename sofmani will copy from ExtractDir to Destination
|
||||
// after the custom extract command finishes. Only populated for github-release
|
||||
// custom extract commands.
|
||||
ArchiveBinName string
|
||||
}
|
||||
|
||||
// legacyTokens maps old-style tokens to their TemplateVars field names.
|
||||
|
||||
@@ -304,9 +304,13 @@
|
||||
"destination": { "type": "string" },
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": ["tar", "zip", "gzip", "gz", "none"],
|
||||
"enum": ["tar", "zip", "gzip", "gz", "none", "custom"],
|
||||
"default": "none",
|
||||
"description": "How to handle the downloaded asset. 'none' copies it directly; 'tar' and 'zip' extract an archive; 'gzip' (alias 'gz') decompresses a single gzip-compressed file (not a tarball)."
|
||||
"description": "How to handle the downloaded asset. 'none' copies it directly; 'tar' and 'zip' extract an archive; 'gzip' (alias 'gz') decompresses a single gzip-compressed file (not a tarball); 'custom' runs opts.extract_command as a user-supplied hook."
|
||||
},
|
||||
"extract_command": {
|
||||
"type": "string",
|
||||
"description": "Shell command to run when strategy is 'custom'. Supports Go template variables — in addition to the usual {{ .OS }}, {{ .Arch }}, {{ .Tag }}, etc., the following extract-specific variables are available: {{ .DownloadFile }}, {{ .ExtractDir }}, {{ .Destination }}, {{ .BinName }}, {{ .ArchiveBinName }}. After the command finishes, sofmani copies {{ .ExtractDir }}/{{ .ArchiveBinName }} to {{ .Destination }}/{{ .BinName }}."
|
||||
},
|
||||
"download_filename": {
|
||||
"description": "Asset filename, or a per-platform map. Supports Go template variables.",
|
||||
|
||||
Reference in New Issue
Block a user