From 900fa9681c9db32089258d2a43a714cf834bcab1 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Sun, 5 Apr 2026 14:58:49 +0300 Subject: [PATCH] feat: add github-release custom strategy --- README.md | 3 +- docs/installer-configuration.md | 56 +++++++++- installer/github_release_installer.go | 80 ++++++++++++- installer/github_release_installer_test.go | 124 +++++++++++++++++++++ installer/template.go | 16 +++ schema/sofmani.schema.json | 8 +- 6 files changed, 278 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f71a0d9..ebce3ee 100755 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/installer-configuration.md b/docs/installer-configuration.md index 4e0ec29..9b1254f 100755 --- a/docs/installer-configuration.md +++ b/docs/installer-configuration.md @@ -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. diff --git a/installer/github_release_installer.go b/installer/github_release_installer.go index 8317677..e1968f7 100755 --- a/installer/github_release_installer.go +++ b/installer/github_release_installer.go @@ -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 diff --git a/installer/github_release_installer_test.go b/installer/github_release_installer_test.go index 8cf90e3..05ac138 100755 --- a/installer/github_release_installer_test.go +++ b/installer/github_release_installer_test.go @@ -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) { diff --git a/installer/template.go b/installer/template.go index 1d29636..de7b9e0 100755 --- a/installer/template.go +++ b/installer/template.go @@ -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. diff --git a/schema/sofmani.schema.json b/schema/sofmani.schema.json index 31c2464..e21e47d 100644 --- a/schema/sofmani.schema.json +++ b/schema/sofmani.schema.json @@ -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.",