Skip to content

Commit 900fa96

Browse files
committed
feat: add github-release custom strategy
1 parent ea79fa9 commit 900fa96

6 files changed

Lines changed: 278 additions & 9 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ For a full list with all the supported options, see [the docs](./docs/installer-
245245
repository path, e.g. `chenasraf/sofmani`, GitHub is assumed.
246246

247247
- **`github-release`**
248-
- Downloads a GitHub release asset. Optionally untar, unzip, or gunzip the downloaded file.
248+
- Downloads a GitHub release asset. Optionally untar, unzip, gunzip, or run a custom
249+
shell hook to extract the downloaded file.
249250

250251
- **`manifest`**
251252
- Installs an entire manifest from a local or remote file.

docs/installer-configuration.md

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,11 @@ Available variables:
385385
| `{{ .DeviceIDAlias }}` | Friendly alias for the current machine, if defined in `machine_aliases` | `work-laptop` |
386386
| `{{ .Tag }}` | Full tag name (only available in `github-release` `download_filename`) | `v1.0.0` |
387387
| `{{ .Version }}` | Version without leading "v" (only available in `github-release` `download_filename`) | `1.0.0` |
388+
| `{{ .DownloadFile }}` | Absolute path to the downloaded asset (only in `github-release` `extract_command`) | `/tmp/sofmani.../app.download` |
389+
| `{{ .ExtractDir }}` | Temp directory to extract into (only in `github-release` `extract_command`) | `/tmp/sofmani...` |
390+
| `{{ .Destination }}` | Final destination directory (only in `github-release` `extract_command`) | `~/.local/bin` |
391+
| `{{ .BinName }}` | Expected output binary name (only in `github-release` `extract_command`) | `my-tool` |
392+
| `{{ .ArchiveBinName }}`| Filename sofmani copies from `ExtractDir` → `Destination` (only in `extract_command`)| `my-tool` |
388393

389394
In addition, `DEVICE_ID` and `DEVICE_ID_ALIAS` are injected as **environment variables** into all
390395
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.
454459
- `opts.repository`: The repository to download from. Should be in the format:
455460
`user/repository-name`
456461
- `opts.destination`: The target directory to extract the files to.
457-
- `opts.strategy`: The download strategy. Can be one of: `tar`, `zip`, `gzip`, `none` (default)
462+
- `opts.strategy`: The download strategy. Can be one of: `tar`, `zip`, `gzip`, `custom`, `none`
463+
(default)
458464
- `none` - the release file is not compressed, and should be copied directly
459465
- `tar` - the release file is a tar file, and should be extracted
460466
- `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.
472478
strategy: gzip
473479
download_filename: tree-sitter-{{ .OS }}-{{ .ArchAlias }}.gz
474480
```
481+
482+
- `custom` - run a user-provided shell hook (`opts.extract_command`) to extract the
483+
downloaded asset yourself. After the command finishes, sofmani copies
484+
`{{ .ExtractDir }}/{{ .ArchiveBinName }}` to `{{ .Destination }}/{{ .BinName }}` and
485+
sets the executable bit — exactly like the `tar` and `zip` strategies do. Use this
486+
for unusual archive formats (7-Zip, xz, self-extracting installers, ...).
487+
488+
- `opts.extract_command`: The shell command to run when `strategy: custom`. It goes through
489+
the same Go template substitution as other sofmani shell hooks, with extra variables
490+
specific to the extract context:
491+
492+
| Variable | Description |
493+
| ---------------------- | ----------------------------------------------------------------------- |
494+
| `{{ .DownloadFile }}` | Absolute path to the downloaded asset |
495+
| `{{ .ExtractDir }}` | Temp directory — your command should place extracted files here |
496+
| `{{ .Destination }}` | Final destination directory (from `opts.destination`) |
497+
| `{{ .BinName }}` | The expected output binary name (`bin_name` or installer name) |
498+
| `{{ .ArchiveBinName }}`| Filename sofmani will copy from `ExtractDir` → `Destination` afterwards |
499+
500+
All the usual template variables (`{{ .OS }}`, `{{ .Arch }}`, `{{ .Tag }}`, ...) are also
501+
available. `extract_command` is required when `strategy: custom`, and is not allowed
502+
with any other strategy.
503+
504+
Example — extracting a `.tar.xz` asset by shelling out to `tar`:
505+
506+
```yaml
507+
- name: my-tool
508+
type: github-release
509+
opts:
510+
repository: example/my-tool
511+
destination: ~/.local/bin
512+
strategy: custom
513+
download_filename: my-tool-{{ .Version }}-{{ .OS }}.tar.xz
514+
extract_command: tar -xJf {{ .DownloadFile }} -C {{ .ExtractDir }}
515+
```
516+
517+
Example — extracting a 7-Zip asset:
518+
519+
```yaml
520+
- name: weird-tool
521+
type: github-release
522+
opts:
523+
repository: example/weird-tool
524+
destination: ~/.local/bin
525+
strategy: custom
526+
download_filename: weird-tool-{{ .Version }}.7z
527+
extract_command: 7z x {{ .DownloadFile }} -o{{ .ExtractDir }}
528+
```
475529
- `opts.download_filename`: The filename of the release asset to download.
476530

477531
This should either be a string, or a map of platforms to filenames.

installer/github_release_installer.go

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ type GitHubReleaseOpts struct {
6060
// a symlink at Target pointing to Source; on Windows, the file is copied instead (since
6161
// symlinks require elevated privileges). Only meaningful with ExtractTo.
6262
BinLinks []GitHubReleaseBinLink
63+
// ExtractCommand is a user-provided shell command that performs the extraction when
64+
// Strategy is "custom". The command is run through Go template substitution with these
65+
// extra variables available (in addition to the usual .OS, .Arch, .Tag, ...):
66+
// {{ .DownloadFile }} - absolute path to the downloaded asset
67+
// {{ .ExtractDir }} - temp directory where the command should place extracted files
68+
// {{ .Destination }} - final destination directory
69+
// {{ .BinName }} - expected binary name (matches GetBinName())
70+
// {{ .ArchiveBinName }} - the filename sofmani will copy from ExtractDir to Destination
71+
// After the command finishes, sofmani copies ExtractDir/ArchiveBinName to
72+
// Destination/BinName, the same way the tar and zip strategies do.
73+
ExtractCommand *string
6374
}
6475

6576
// GitHubReleaseBinLink describes a single binary exposed from a tree-mode install.
@@ -76,10 +87,11 @@ type GitHubReleaseInstallStrategy string
7687

7788
// Constants for GitHub release installation strategies.
7889
const (
79-
GitHubReleaseInstallStrategyNone GitHubReleaseInstallStrategy = "none" // GitHubReleaseInstallStrategyNone means no special handling, just download the file.
80-
GitHubReleaseInstallStrategyTar GitHubReleaseInstallStrategy = "tar" // GitHubReleaseInstallStrategyTar means extract a tar archive.
81-
GitHubReleaseInstallStrategyZip GitHubReleaseInstallStrategy = "zip" // GitHubReleaseInstallStrategyZip means extract a zip archive.
82-
GitHubReleaseInstallStrategyGzip GitHubReleaseInstallStrategy = "gzip" // GitHubReleaseInstallStrategyGzip means decompress a single gzip-compressed file (not a tar archive).
90+
GitHubReleaseInstallStrategyNone GitHubReleaseInstallStrategy = "none" // GitHubReleaseInstallStrategyNone means no special handling, just download the file.
91+
GitHubReleaseInstallStrategyTar GitHubReleaseInstallStrategy = "tar" // GitHubReleaseInstallStrategyTar means extract a tar archive.
92+
GitHubReleaseInstallStrategyZip GitHubReleaseInstallStrategy = "zip" // GitHubReleaseInstallStrategyZip means extract a zip archive.
93+
GitHubReleaseInstallStrategyGzip GitHubReleaseInstallStrategy = "gzip" // GitHubReleaseInstallStrategyGzip means decompress a single gzip-compressed file (not a tar archive).
94+
GitHubReleaseInstallStrategyCustom GitHubReleaseInstallStrategy = "custom" // GitHubReleaseInstallStrategyCustom runs a user-provided shell command to extract the asset.
8395
)
8496

8597
// Validate validates the installer configuration.
@@ -107,12 +119,22 @@ func (i *GitHubReleaseInstaller) Validate() []ValidationError {
107119
case GitHubReleaseInstallStrategyNone,
108120
GitHubReleaseInstallStrategyTar,
109121
GitHubReleaseInstallStrategyZip,
110-
GitHubReleaseInstallStrategyGzip:
122+
GitHubReleaseInstallStrategyGzip,
123+
GitHubReleaseInstallStrategyCustom:
111124
// valid
112125
default:
113126
errors = append(errors, ValidationError{FieldName: "strategy", Message: validationInvalidFormat(), InstallerName: *info.Name})
114127
}
115128
}
129+
// extract_command only makes sense with strategy: custom, and strategy: custom requires it.
130+
strategyIsCustom := opts.Strategy != nil && *opts.Strategy == GitHubReleaseInstallStrategyCustom
131+
hasExtractCommand := opts.ExtractCommand != nil && *opts.ExtractCommand != ""
132+
if strategyIsCustom && !hasExtractCommand {
133+
errors = append(errors, ValidationError{FieldName: "extract_command", Message: validationIsRequired(), InstallerName: *info.Name})
134+
}
135+
if hasExtractCommand && !strategyIsCustom {
136+
errors = append(errors, ValidationError{FieldName: "extract_command", Message: "extract_command requires strategy: custom", InstallerName: *info.Name})
137+
}
116138
if opts.ExtractTo != nil {
117139
// Tree mode requires an archive strategy — a single downloaded file has no tree to
118140
// extract. We check explicitly rather than relying on the Install-time error so
@@ -305,6 +327,28 @@ func (i *GitHubReleaseInstaller) Install() error {
305327
}
306328
success = true
307329
err = nil
330+
case GitHubReleaseInstallStrategyCustom:
331+
logger.Debug("Strategy 'custom': running user extract_command against %s", tmpOut.Name())
332+
if opts.ExtractCommand == nil || *opts.ExtractCommand == "" {
333+
return fmt.Errorf("strategy 'custom' requires opts.extract_command")
334+
}
335+
extractVars := *templateVars
336+
extractVars.DownloadFile = tmpOut.Name()
337+
extractVars.ExtractDir = tmpDir
338+
extractVars.Destination = *opts.Destination
339+
extractVars.BinName = i.GetBinName()
340+
extractVars.ArchiveBinName = i.GetArchiveBinName()
341+
if err = i.runCustomExtract(*opts.ExtractCommand, &extractVars); err != nil {
342+
return fmt.Errorf("custom extract failed: %w", err)
343+
}
344+
logger.Debug("Strategy 'custom': copying binary '%s' to destination", i.GetArchiveBinName())
345+
success, err = i.CopyExtractedFile(out, tmpDir)
346+
if !success {
347+
return fmt.Errorf("failed to copy extracted file: %w", err)
348+
}
349+
if err != nil {
350+
return err
351+
}
308352
default:
309353
logger.Debug("Strategy 'none': copying downloaded file directly to destination")
310354
// Seek back to beginning of temp file before copying
@@ -414,6 +458,29 @@ func (i *GitHubReleaseInstaller) GetArchiveBinName() string {
414458
return i.GetBinName()
415459
}
416460

461+
// runCustomExtract runs a user-provided extract command through the platform's
462+
// default shell. The command is first rendered with ApplyTemplate so users can
463+
// reference {{ .DownloadFile }}, {{ .ExtractDir }}, {{ .Destination }},
464+
// {{ .BinName }}, {{ .ArchiveBinName }}, and all the usual template variables
465+
// (.OS, .Arch, .Tag, ...).
466+
func (i *GitHubReleaseInstaller) runCustomExtract(command string, vars *TemplateVars) error {
467+
rendered, err := ApplyTemplate(command, vars, *i.Info.Name)
468+
if err != nil {
469+
return fmt.Errorf("failed to render extract_command template: %w", err)
470+
}
471+
logger.Debug("Custom extract command: %s", rendered)
472+
shell := utils.GetOSShell(i.GetData().EnvShell)
473+
args := utils.GetOSShellArgs(rendered)
474+
success, err := i.RunCmdGetSuccessPassThrough(shell, args...)
475+
if err != nil {
476+
return err
477+
}
478+
if !success {
479+
return fmt.Errorf("extract_command exited non-zero")
480+
}
481+
return nil
482+
}
483+
417484
// decompressGzip reads a gzip-compressed stream from src and writes the
418485
// decompressed bytes to dst. It is used by the "gzip" github-release strategy
419486
// for single-file gzipped assets (i.e. not tarballs).
@@ -597,6 +664,9 @@ func (i *GitHubReleaseInstaller) GetOpts() *GitHubReleaseOpts {
597664
if archiveBinName, ok := (*info.Opts)["archive_bin_name"].(string); ok {
598665
opts.ArchiveBinName = &archiveBinName
599666
}
667+
if extractCommand, ok := (*info.Opts)["extract_command"].(string); ok {
668+
opts.ExtractCommand = &extractCommand
669+
}
600670
if extractTo, ok := (*info.Opts)["extract_to"].(string); ok {
601671
extractTo = utils.GetRealPath(i.GetData().Environ(), extractTo)
602672
opts.ExtractTo = &extractTo

installer/github_release_installer_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,47 @@ func TestGitHubReleaseValidation(t *testing.T) {
102102
},
103103
}
104104
assertNoValidationErrors(t, newTestGitHubReleaseInstaller(gzipStrategy).Validate())
105+
106+
// 🟢 Valid custom strategy with extract_command
107+
customStrategy := &appconfig.InstallerData{
108+
Name: lo.ToPtr("ghr-custom"),
109+
Type: appconfig.InstallerTypeGitHubRelease,
110+
Opts: &map[string]any{
111+
"repository": "owner/repo",
112+
"destination": "/some/path",
113+
"download_filename": "file.weird",
114+
"strategy": "custom",
115+
"extract_command": "7z x {{ .DownloadFile }} -o{{ .ExtractDir }}",
116+
},
117+
}
118+
assertNoValidationErrors(t, newTestGitHubReleaseInstaller(customStrategy).Validate())
119+
120+
// 🔴 custom strategy without extract_command
121+
customMissingCmd := &appconfig.InstallerData{
122+
Name: lo.ToPtr("ghr-custom-missing"),
123+
Type: appconfig.InstallerTypeGitHubRelease,
124+
Opts: &map[string]any{
125+
"repository": "owner/repo",
126+
"destination": "/some/path",
127+
"download_filename": "file.weird",
128+
"strategy": "custom",
129+
},
130+
}
131+
assertValidationError(t, newTestGitHubReleaseInstaller(customMissingCmd).Validate(), "extract_command")
132+
133+
// 🔴 extract_command without strategy: custom
134+
extractCmdWrongStrategy := &appconfig.InstallerData{
135+
Name: lo.ToPtr("ghr-custom-wrong-strategy"),
136+
Type: appconfig.InstallerTypeGitHubRelease,
137+
Opts: &map[string]any{
138+
"repository": "owner/repo",
139+
"destination": "/some/path",
140+
"download_filename": "file.tar.gz",
141+
"strategy": "tar",
142+
"extract_command": "echo nope",
143+
},
144+
}
145+
assertValidationError(t, newTestGitHubReleaseInstaller(extractCmdWrongStrategy).Validate(), "extract_command")
105146
}
106147

107148
func TestGitHubReleaseGetOpts(t *testing.T) {
@@ -195,6 +236,89 @@ func TestGitHubReleaseGetOpts(t *testing.T) {
195236

196237
assert.Equal(t, GitHubReleaseInstallStrategyGzip, *opts.Strategy)
197238
})
239+
240+
t.Run("handles custom strategy with extract_command", func(t *testing.T) {
241+
data := &appconfig.InstallerData{
242+
Name: lo.ToPtr("test-release"),
243+
Type: appconfig.InstallerTypeGitHubRelease,
244+
Opts: &map[string]any{
245+
"strategy": "custom",
246+
"extract_command": "cp {{ .DownloadFile }} {{ .ExtractDir }}/{{ .ArchiveBinName }}",
247+
},
248+
}
249+
installer := newTestGitHubReleaseInstaller(data)
250+
opts := installer.GetOpts()
251+
252+
assert.Equal(t, GitHubReleaseInstallStrategyCustom, *opts.Strategy)
253+
assert.NotNil(t, opts.ExtractCommand)
254+
assert.Contains(t, *opts.ExtractCommand, "{{ .DownloadFile }}")
255+
})
256+
}
257+
258+
func TestGitHubReleaseCustomExtract(t *testing.T) {
259+
logger.InitLogger(false)
260+
if runtime.GOOS == "windows" {
261+
t.Skip("custom extract test uses a POSIX shell command")
262+
}
263+
264+
// Prepare a fake "downloaded" asset on disk and an extract dir.
265+
tmpDir := t.TempDir()
266+
downloadFile := filepath.Join(tmpDir, "asset.weird")
267+
payload := []byte("binary payload from sofmani custom extract test")
268+
assert.NoError(t, os.WriteFile(downloadFile, payload, 0644))
269+
270+
extractDir := filepath.Join(tmpDir, "extract")
271+
assert.NoError(t, os.Mkdir(extractDir, 0755))
272+
273+
data := &appconfig.InstallerData{
274+
Name: lo.ToPtr("custom-tool"),
275+
Type: appconfig.InstallerTypeGitHubRelease,
276+
Opts: &map[string]any{
277+
// The user's command references template variables directly instead of env vars.
278+
// Here we pretend the "weird" asset really just needs to be copied.
279+
"extract_command": "cp {{ .DownloadFile }} {{ .ExtractDir }}/{{ .ArchiveBinName }}",
280+
},
281+
}
282+
installer := newTestGitHubReleaseInstaller(data)
283+
284+
vars := NewTemplateVars("v1.2.3", nil)
285+
vars.DownloadFile = downloadFile
286+
vars.ExtractDir = extractDir
287+
vars.Destination = filepath.Join(tmpDir, "dest")
288+
vars.BinName = "custom-tool"
289+
vars.ArchiveBinName = "custom-tool"
290+
291+
err := installer.runCustomExtract("cp {{ .DownloadFile }} {{ .ExtractDir }}/{{ .ArchiveBinName }}", vars)
292+
assert.NoError(t, err)
293+
294+
// The command should have produced extractDir/custom-tool with the original payload.
295+
got, err := os.ReadFile(filepath.Join(extractDir, "custom-tool"))
296+
assert.NoError(t, err)
297+
assert.Equal(t, payload, got)
298+
}
299+
300+
func TestGitHubReleaseCustomExtractFailures(t *testing.T) {
301+
logger.InitLogger(false)
302+
if runtime.GOOS == "windows" {
303+
t.Skip("uses a POSIX shell command")
304+
}
305+
306+
data := &appconfig.InstallerData{
307+
Name: lo.ToPtr("custom-tool"),
308+
Type: appconfig.InstallerTypeGitHubRelease,
309+
}
310+
installer := newTestGitHubReleaseInstaller(data)
311+
vars := NewTemplateVars("v0.0.0", nil)
312+
313+
t.Run("non-zero exit is surfaced", func(t *testing.T) {
314+
err := installer.runCustomExtract("exit 42", vars)
315+
assert.Error(t, err)
316+
})
317+
318+
t.Run("invalid template is surfaced", func(t *testing.T) {
319+
err := installer.runCustomExtract("echo {{ .NopeField", vars)
320+
assert.Error(t, err)
321+
})
198322
}
199323

200324
func TestDecompressGzip(t *testing.T) {

installer/template.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ type TemplateVars struct {
2828
DeviceID string
2929
// DeviceIDAlias is the friendly alias for the current machine, if one is defined in machine_aliases.
3030
DeviceIDAlias string
31+
// DownloadFile is the absolute path to the downloaded asset. Only populated for
32+
// github-release custom extract commands.
33+
DownloadFile string
34+
// ExtractDir is the temp directory where a custom extract command should place
35+
// extracted files. Only populated for github-release custom extract commands.
36+
ExtractDir string
37+
// Destination is the final destination directory. Only populated for github-release
38+
// custom extract commands.
39+
Destination string
40+
// BinName is the expected output binary name. Only populated for github-release
41+
// custom extract commands.
42+
BinName string
43+
// ArchiveBinName is the filename sofmani will copy from ExtractDir to Destination
44+
// after the custom extract command finishes. Only populated for github-release
45+
// custom extract commands.
46+
ArchiveBinName string
3147
}
3248

3349
// legacyTokens maps old-style tokens to their TemplateVars field names.

schema/sofmani.schema.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,9 +304,13 @@
304304
"destination": { "type": "string" },
305305
"strategy": {
306306
"type": "string",
307-
"enum": ["tar", "zip", "gzip", "gz", "none"],
307+
"enum": ["tar", "zip", "gzip", "gz", "none", "custom"],
308308
"default": "none",
309-
"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)."
309+
"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."
310+
},
311+
"extract_command": {
312+
"type": "string",
313+
"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 }}."
310314
},
311315
"download_filename": {
312316
"description": "Asset filename, or a per-platform map. Supports Go template variables.",

0 commit comments

Comments
 (0)