├── .golangci.yaml ├── assets └── demo.gif ├── main.go ├── internal ├── cmdutil │ └── factory.go ├── core │ ├── switcher │ │ └── channel.go │ ├── verify │ │ ├── verify.go │ │ └── verify_test.go │ ├── catalog │ │ ├── search.go │ │ ├── list.go │ │ ├── info.go │ │ ├── info_test.go │ │ └── list_test.go │ ├── uninstaller │ │ ├── uninstaller.go │ │ └── uninstaller_integration_test.go │ ├── updater │ │ ├── updater.go │ │ └── updater_integration_test.go │ └── installer │ │ ├── installer.go │ │ ├── release.go │ │ └── release_test.go ├── config │ ├── loader.go │ ├── config.go │ ├── config_test.go │ └── loader_test.go ├── gh │ ├── client.go │ ├── client_test.go │ ├── requests.go │ └── requests_test.go ├── parmutil │ ├── package.go │ └── package_test.go └── manifest │ ├── manifest.go │ └── manifest_test.go ├── docs ├── docs.md ├── roadmap.md └── usage.md ├── pkg ├── cmdx │ ├── validation.go │ └── validation_test.go ├── sysutil │ ├── file_test.go │ ├── process.go │ ├── process_test.go │ └── file.go ├── progress │ └── progress.go ├── cmdparser │ ├── cmdparser.go │ └── cmdparser_test.go ├── archive │ ├── extract.go │ └── extract_test.go └── deps │ ├── deps_test.go │ └── deps.go ├── cmd ├── sw │ ├── switch.go │ └── channel.go ├── list │ └── list.go ├── configure │ ├── configure.go │ ├── set.go │ └── reset.go ├── search │ └── search.go ├── remove │ └── remove.go ├── info │ └── info.go ├── root.go ├── update │ └── update.go └── install │ └── install.go ├── .gitignore ├── scripts ├── demo.tape └── install.sh ├── .github ├── workflows │ ├── ci.yml │ └── release.yml └── CODE_OF_CONDUCT.md ├── parmver └── version.go ├── go.mod ├── Makefile └── go.sum /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | disable: 4 | - errcheck 5 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yhoundz/parm/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 Alexander Wang 3 | */ 4 | 5 | package main 6 | 7 | import ( 8 | "parm/cmd" 9 | ) 10 | 11 | func main() { 12 | cmd.Execute() 13 | } 14 | -------------------------------------------------------------------------------- /internal/cmdutil/factory.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "context" 5 | "parm/internal/gh" 6 | ) 7 | 8 | type ProviderFactory func(ctx context.Context, token string, opts ...gh.Option) gh.Provider 9 | 10 | type Factory struct { 11 | Provider ProviderFactory 12 | } 13 | -------------------------------------------------------------------------------- /docs/docs.md: -------------------------------------------------------------------------------- 1 | # Parm Documentation 2 | 3 | > [!WARNING] 4 | > Since Parm is in such an early state, the documentation is a Work In Progress. See [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 5 | 6 | ## For Users 7 | - [Usage](usage.md) 8 | 9 | ## For Developers 10 | - [Roadmap](roadmap.md) 11 | -------------------------------------------------------------------------------- /pkg/cmdx/validation.go: -------------------------------------------------------------------------------- 1 | package cmdx 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func MarkFlagsRequireFlag(cmd *cobra.Command, reqFlag string, depFlags ...string) error { 10 | reqFlagSet := cmd.Flags().Changed(reqFlag) 11 | if !reqFlagSet { 12 | for _, depFlag := range depFlags { 13 | if cmd.Flags().Changed(depFlag) { 14 | return fmt.Errorf("flag --%s is only valid when used with --%s", depFlag, reqFlag) 15 | } 16 | } 17 | } 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /internal/core/switcher/channel.go: -------------------------------------------------------------------------------- 1 | package switcher 2 | 3 | import ( 4 | "parm/internal/manifest" 5 | "parm/internal/parmutil" 6 | ) 7 | 8 | func SwitchChannel(owner, repo string, channel manifest.InstallType) error { 9 | installDir := parmutil.GetInstallDir(owner, repo) 10 | man, err := manifest.Read(installDir) 11 | if err != nil { 12 | return err 13 | } 14 | if man.InstallType == channel { 15 | return nil 16 | } 17 | 18 | man.InstallType = channel 19 | err = man.Write(installDir) 20 | if err != nil { 21 | return err 22 | } 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /cmd/sw/switch.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 NAME HERE 3 | */ 4 | package sw 5 | 6 | import ( 7 | "parm/internal/cmdutil" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | func NewSwitchCmd(f *cmdutil.Factory) *cobra.Command { 13 | // switchCmd represents the switch command 14 | var switchCmd = &cobra.Command{ 15 | Use: "switch", 16 | Short: "A brief description of your command", 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | return cmd.Help() 19 | }, 20 | } 21 | 22 | switchCmd.AddCommand( 23 | NewChannelCmd(f), 24 | ) 25 | 26 | return switchCmd 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 2 | # 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | /bin 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Code coverage profiles and other test artifacts 15 | *.out 16 | coverage.* 17 | *.coverprofile 18 | profile.cov 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | go.work.sum 26 | 27 | # env file 28 | .env 29 | *.env 30 | 31 | # Editor/IDE 32 | .idea/ 33 | .vscode/ 34 | -------------------------------------------------------------------------------- /internal/core/verify/verify.go: -------------------------------------------------------------------------------- 1 | package verify 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "io" 7 | "os" 8 | ) 9 | 10 | func VerifyLevel1(path, upstreamHash string) (bool, *string, error) { 11 | hash, err := GetSha256(path) 12 | if err != nil { 13 | return false, nil, err 14 | } 15 | 16 | hash = fmt.Sprintf("sha256:%s", hash) 17 | return hash == upstreamHash, &hash, nil 18 | } 19 | 20 | func GetSha256(path string) (string, error) { 21 | f, err := os.Open(path) 22 | if err != nil { 23 | return "", err 24 | } 25 | defer f.Close() 26 | 27 | h := sha256.New() 28 | if _, err := io.Copy(h, f); err != nil { 29 | return "", err 30 | } 31 | 32 | return fmt.Sprintf("%x", h.Sum(nil)), nil 33 | } 34 | -------------------------------------------------------------------------------- /cmd/list/list.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 Alexander Wang 3 | */ 4 | package list 5 | 6 | import ( 7 | "fmt" 8 | "parm/internal/cmdutil" 9 | "parm/internal/core/catalog" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func NewListCmd(f *cmdutil.Factory) *cobra.Command { 15 | var listCmd = &cobra.Command{ 16 | Use: "list", 17 | Short: "Lists out currently installed packages", 18 | Args: cobra.ExactArgs(0), 19 | RunE: func(cmd *cobra.Command, args []string) error { 20 | list, data, err := catalog.GetInstalledPkgInfo() 21 | if err != nil { 22 | return err 23 | } 24 | for _, pkg := range list { 25 | fmt.Println(pkg) 26 | } 27 | fmt.Printf("Total: %d packages installed.\n", data.NumPkgs) 28 | return nil 29 | }, 30 | } 31 | 32 | return listCmd 33 | } 34 | -------------------------------------------------------------------------------- /pkg/sysutil/file_test.go: -------------------------------------------------------------------------------- 1 | package sysutil 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSafeJoin_ValidPaths(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | root string 11 | path string 12 | wantErr bool 13 | }{ 14 | {"simple file", "/tmp/root", "file.txt", false}, 15 | {"nested file", "/tmp/root", "dir/file.txt", false}, 16 | {"dot path", "/tmp/root", "./file.txt", false}, 17 | {"traversal attempt", "/tmp/root", "../etc/passwd", true}, 18 | {"complex traversal", "/tmp/root", "dir/../../etc/passwd", true}, 19 | } 20 | 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(t *testing.T) { 23 | result, err := SafeJoin(tt.root, tt.path) 24 | 25 | if tt.wantErr { 26 | if err == nil { 27 | t.Errorf("SafeJoin() expected error, got nil") 28 | } 29 | } else { 30 | if err != nil { 31 | t.Errorf("SafeJoin() unexpected error: %v", err) 32 | } 33 | if result == "" { 34 | t.Error("SafeJoin() returned empty string") 35 | } 36 | } 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/sysutil/process.go: -------------------------------------------------------------------------------- 1 | package sysutil 2 | 3 | import ( 4 | "path/filepath" 5 | "runtime" 6 | "strings" 7 | 8 | "github.com/shirou/gopsutil/v4/process" 9 | ) 10 | 11 | func IsProcessRunning(execPath string) (bool, error) { 12 | absPath, err := filepath.Abs(execPath) 13 | if err != nil { 14 | return false, err 15 | } 16 | absPath = filepath.Clean(absPath) 17 | 18 | pses, err := process.Processes() 19 | if err != nil { 20 | return false, err 21 | } 22 | 23 | for _, p := range pses { 24 | procExe, err := p.Exe() 25 | if err != nil { 26 | continue 27 | } 28 | 29 | resolvedProcExe, err := filepath.EvalSymlinks(procExe) 30 | if err == nil { 31 | procExe = resolvedProcExe 32 | } 33 | 34 | procExe = filepath.Clean(procExe) 35 | 36 | isMatch := false 37 | 38 | if runtime.GOOS == "windows" { 39 | isMatch = strings.EqualFold(procExe, absPath) 40 | } else { 41 | isMatch = procExe == absPath 42 | } 43 | 44 | if isMatch { 45 | return true, nil 46 | } 47 | } 48 | 49 | return false, nil 50 | } 51 | -------------------------------------------------------------------------------- /cmd/configure/configure.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 Alexander Wang 3 | */ 4 | package configure 5 | 6 | import ( 7 | "fmt" 8 | "maps" 9 | "parm/internal/cmdutil" 10 | "parm/internal/config" 11 | "slices" 12 | 13 | "github.com/go-viper/mapstructure/v2" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | func NewConfigureCmd(f *cmdutil.Factory) *cobra.Command { 18 | var configCmd = &cobra.Command{ 19 | Use: "config", 20 | Aliases: []string{"configure, cfg"}, 21 | Short: "Configures parm.", 22 | Long: `Prints the current configuration settings to your console.`, 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | var settings map[string]any 25 | if err := mapstructure.Decode(config.Cfg, &settings); err != nil { 26 | return err 27 | } 28 | sorted := slices.Sorted(maps.Keys(settings)) 29 | for _, k := range sorted { 30 | fmt.Printf("%s: %s\n", k, settings[k]) 31 | } 32 | 33 | return nil 34 | }, 35 | } 36 | 37 | configCmd.AddCommand( 38 | NewSetCmd(f), 39 | NewResetCmd(f), 40 | ) 41 | 42 | return configCmd 43 | } 44 | -------------------------------------------------------------------------------- /internal/core/catalog/search.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/google/go-github/v74/github" 8 | ) 9 | 10 | // TODO: switch to functional/variadic options instead? 11 | type RepoSearchOptions struct { 12 | Key *string 13 | Query *string 14 | } 15 | 16 | func SearchRepo(ctx context.Context, search *github.SearchService, opts RepoSearchOptions) (*github.RepositoriesSearchResult, error) { 17 | ghOpts := github.SearchOptions{ 18 | Sort: "stars", 19 | Order: "desc", 20 | } 21 | var query string 22 | if opts.Key != nil { 23 | // TODO: change this query logic to something better 24 | query = fmt.Sprintf("q=%s", *opts.Key) 25 | } else if opts.Query != nil { 26 | query = *opts.Query 27 | } else { 28 | // both null, return err 29 | return nil, fmt.Errorf("error: query cannot be nil") 30 | } 31 | res, _, err := search.Repositories(ctx, query, &ghOpts) 32 | if err != nil { 33 | return nil, fmt.Errorf("error: could not search repositories:\n%q", err) 34 | } 35 | 36 | // TODO: filter for only repos that have releases 37 | // var repos [][2]string 38 | // 39 | // for _, repo := range res.Repositories { 40 | // } 41 | 42 | return res, nil 43 | } 44 | -------------------------------------------------------------------------------- /cmd/configure/set.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 Alexander Wang 3 | */ 4 | package configure 5 | 6 | import ( 7 | "fmt" 8 | "parm/internal/cmdutil" 9 | "parm/pkg/cmdparser" 10 | 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | func NewSetCmd(f *cmdutil.Factory) *cobra.Command { 16 | // setCmd represents the set command 17 | var setCmd = &cobra.Command{ 18 | Use: "set key=value", 19 | Short: "Sets a key/value pair in the config", 20 | Long: ``, 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | for _, val := range args { 23 | k, v, err := cmdparser.StringToString(val) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | old := viper.Get(k) 29 | viper.Set(k, v) 30 | 31 | fmt.Printf("Set %s from %s to %s\n", k, old, v) 32 | } 33 | 34 | if err := viper.WriteConfig(); err != nil { 35 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 36 | if err = viper.SafeWriteConfig(); err != nil { 37 | return fmt.Errorf("error: failed to create config file: \n%w", err) 38 | } 39 | } else { 40 | return fmt.Errorf("error: failed to write config file: \n%w", err) 41 | } 42 | } 43 | return nil 44 | }, 45 | } 46 | 47 | return setCmd 48 | } 49 | -------------------------------------------------------------------------------- /cmd/sw/channel.go: -------------------------------------------------------------------------------- 1 | package sw 2 | 3 | import ( 4 | "fmt" 5 | "parm/internal/cmdutil" 6 | "parm/internal/core/switcher" 7 | "parm/internal/manifest" 8 | "parm/pkg/cmdparser" 9 | "strings" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func NewChannelCmd(f *cmdutil.Factory) *cobra.Command { 15 | // switchCmd represents the switch command 16 | var channelCmd = &cobra.Command{ 17 | Use: "channel", 18 | Short: "A brief description of your command", 19 | Args: cobra.ExactArgs(2), 20 | PreRunE: func(cmd *cobra.Command, args []string) error { 21 | return nil 22 | }, 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | pkg := args[0] 25 | channel := strings.ToLower(args[1]) 26 | // should guarantee to work after PreRunE 27 | owner, repo, _ := cmdparser.ParseRepoRef(pkg) 28 | var instType manifest.InstallType 29 | switch channel { 30 | case "release": 31 | instType = manifest.Release 32 | case "pre-release": 33 | instType = manifest.PreRelease 34 | default: 35 | return fmt.Errorf("error: invalid release channel") 36 | } 37 | err := switcher.SwitchChannel(owner, repo, instType) 38 | if err != nil { 39 | return err 40 | } 41 | return nil 42 | }, 43 | } 44 | 45 | return channelCmd 46 | } 47 | -------------------------------------------------------------------------------- /scripts/demo.tape: -------------------------------------------------------------------------------- 1 | Set WindowBar Colorful 2 | Set Theme "catppuccin-mocha" 3 | Set LineHeight 1.6 4 | Require parm 5 | Output assets/demo.gif 6 | 7 | Set FontSize 24 8 | Set TypingSpeed 60ms 9 | Set Width 1440 10 | Set Height 960 11 | 12 | Hide 13 | Type "export CI=false TERM=xterm-256color && clear" 14 | Enter 15 | Show 16 | 17 | Type "parm install burntsushi/ripgrep" 18 | Sleep 0.1s 19 | Enter 20 | Wait />$/ 21 | Sleep 0.5s 22 | 23 | Type "parm install jesseduffield/lazygit" 24 | Sleep 0.1s 25 | Type "@v0.55.0" 26 | Enter 27 | Wait />$/ 28 | Sleep 0.5s 29 | 30 | Type "parm install sxyazi/yazi --pre-release --strict" 31 | Sleep 0.1s 32 | Enter 33 | Wait />$/ 34 | Sleep 0.5s 35 | 36 | Type "parm install charmbracelet/crush --asset crush_0.18.5_Linux_x86_64.tar.gz" 37 | Sleep 0.1s 38 | Enter 39 | Wait />$/ 40 | Sleep 2s 41 | 42 | Type "clear" 43 | Sleep 0.1s 44 | Enter 45 | Wait />$/ 46 | Sleep 1s 47 | 48 | Type "parm list" 49 | Sleep 0.1s 50 | Enter 51 | Wait />$/ 52 | Sleep 2s 53 | 54 | Type "parm info sxyazi/yazi" 55 | Sleep 0.1s 56 | Enter 57 | Wait />$/ 58 | Sleep 2s 59 | 60 | Type "parm remove sxyazi/yazi" 61 | Sleep 0.1s 62 | Enter 63 | Wait />$/ 64 | Sleep 2s 65 | 66 | Type "parm list" 67 | Sleep 0.1s 68 | Enter 69 | Wait />$/ 70 | Sleep 4s 71 | 72 | Hide 73 | Type "parm remove burntsushi/ripgrep charmbracelet/crush jesseduffield/lazygit" 74 | Enter 75 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | 8 | concurrency: 9 | group: ci-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - uses: actions/setup-go@v5 21 | with: 22 | go-version-file: go.mod 23 | 24 | - name: Cache Go modules 25 | uses: actions/cache@v4 26 | with: 27 | path: | 28 | ~/go/pkg/mod 29 | ~/.cache/go-build 30 | key: ${{ runner.os }}-gomod-${{ hashFiles('**/go.sum') }} 31 | restore-keys: ${{ runner.os }}-gomod- 32 | - name: Test 33 | run: make test 34 | 35 | lint: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | with: 40 | fetch-depth: 0 41 | 42 | - uses: actions/setup-go@v5 43 | with: 44 | go-version-file: go.mod 45 | - name: Verify formatting 46 | run: | 47 | out=$(gofmt -l .) 48 | if [ -n "$out" ]; then 49 | echo "$out"; echo "Run gofmt on the files above"; exit 1 50 | fi 51 | 52 | - name: Vet 53 | run: go vet ./... 54 | 55 | - name: Lint 56 | uses: golangci/golangci-lint-action@v9 57 | with: 58 | version: v2.6 59 | -------------------------------------------------------------------------------- /internal/config/loader.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | var Cfg Config 12 | 13 | func GetParmConfigDir() (string, error) { 14 | cfgDir, err := os.UserConfigDir() 15 | if err != nil { 16 | return "", fmt.Errorf("error: cannot find XDG_CONFIG_HOME or APPDATA: \n%w", err) 17 | } 18 | cfgPath := filepath.Join(cfgDir, "parm") 19 | return cfgPath, nil 20 | } 21 | 22 | func Init() error { 23 | cfgPath, err := GetParmConfigDir() 24 | if err != nil { 25 | return err 26 | } 27 | if err := os.MkdirAll(cfgPath, 0o700); err != nil { 28 | return fmt.Errorf("error: cannot create config dir: \n%w", err) 29 | } 30 | 31 | v := viper.GetViper() 32 | 33 | v.SetConfigName("config") 34 | v.SetConfigType("toml") 35 | v.AddConfigPath(cfgPath) 36 | 37 | err = setConfigDefaults(v) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | if err := v.ReadInConfig(); err != nil { 43 | if _, ok := err.(viper.ConfigFileNotFoundError); ok { 44 | if err := v.SafeWriteConfig(); err != nil { 45 | return fmt.Errorf("error: cannot create config file: \n%w", err) 46 | } 47 | } else { 48 | return fmt.Errorf("error: Cannot read config file \n%w", err) 49 | } 50 | } 51 | 52 | setEnvVars(v) 53 | 54 | if err := v.Unmarshal(&Cfg); err != nil { 55 | return fmt.Errorf("error: Cannot unmarshal config file \n%w", err) 56 | } 57 | 58 | // watch for live reload ?? 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /cmd/search/search.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 NAME HERE 3 | */ 4 | package search 5 | 6 | import ( 7 | "fmt" 8 | "parm/internal/cmdutil" 9 | "parm/internal/core/catalog" 10 | "parm/internal/gh" 11 | 12 | "github.com/spf13/cobra" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | func NewSearchCmd(f *cmdutil.Factory) *cobra.Command { 17 | var query string 18 | 19 | // searchCmd represents the search command 20 | var searchCmd = &cobra.Command{ 21 | Use: "search", 22 | Short: "Searches for repositories", 23 | Args: func(cmd *cobra.Command, args []string) error { 24 | if query != "" && len(args) > 0 { 25 | return fmt.Errorf("cannot have any args with the --query flag") 26 | } else { 27 | exp := cobra.ExactArgs(1) 28 | return exp(cmd, args) 29 | } 30 | }, 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | ctx := cmd.Context() 33 | token, err := gh.GetStoredApiKey(viper.GetViper()) 34 | if err != nil { 35 | return err 36 | } 37 | client := f.Provider(ctx, token) 38 | var opts = catalog.RepoSearchOptions{ 39 | Key: nil, 40 | Query: nil, 41 | } 42 | if query != "" { 43 | opts.Query = &query 44 | } else { 45 | opts.Key = &args[0] 46 | } 47 | // TODO: finish this up 48 | _, _ = catalog.SearchRepo(ctx, client.Search(), opts) 49 | return nil 50 | }, 51 | } 52 | 53 | searchCmd.Flags().StringVarP(&query, "query", "q", "", "Searches for the exact query string outlined by the GitHub REST API instead of a general search term.") 54 | 55 | return searchCmd 56 | } 57 | -------------------------------------------------------------------------------- /parmver/version.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 Alexander Wang 3 | */ 4 | 5 | package parmver 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/Masterminds/semver/v3" 11 | ) 12 | 13 | type Channel int 14 | 15 | const ( 16 | Unknown Channel = iota - 1 17 | Dev 18 | Stable 19 | ) 20 | 21 | type Version struct { 22 | major uint 23 | minor uint 24 | patch uint 25 | channel Channel 26 | } 27 | 28 | var StringVersion string 29 | var AppVersion Version 30 | 31 | func init() { 32 | if StringVersion == "" { 33 | return 34 | } 35 | 36 | ver, err := semver.NewVersion(StringVersion) 37 | if err != nil { 38 | return 39 | } 40 | 41 | chstr := ver.Prerelease() 42 | var channel Channel 43 | switch chstr { 44 | case "stable", "": 45 | channel = Stable 46 | case "dev": 47 | channel = Dev 48 | default: 49 | channel = Unknown 50 | } 51 | 52 | AppVersion = Version{ 53 | major: uint(ver.Major()), 54 | minor: uint(ver.Minor()), 55 | patch: uint(ver.Patch()), 56 | channel: channel, 57 | } 58 | } 59 | 60 | func (c Channel) String() string { 61 | switch c { 62 | case Stable: 63 | return "stable" 64 | case Dev: 65 | return "dev" 66 | default: 67 | return "unknown?" 68 | } 69 | } 70 | 71 | func (v Version) String() string { 72 | switch v.channel { 73 | case Dev: 74 | return fmt.Sprintf("v%d.%d.%d-%s", v.major, v.minor, v.patch, v.channel.String()) 75 | case Stable: 76 | return fmt.Sprintf("v%d.%d.%d", v.major, v.minor, v.patch) 77 | default: 78 | return fmt.Sprintf("v%d.%d.%d-%s", v.major, v.minor, v.patch, Unknown.String()) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /cmd/remove/remove.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 Alexander Wang 3 | */ 4 | package remove 5 | 6 | import ( 7 | "fmt" 8 | "parm/internal/cmdutil" 9 | "parm/internal/core/uninstaller" 10 | "parm/pkg/cmdparser" 11 | 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | func NewRemoveCmd(f *cmdutil.Factory) *cobra.Command { 16 | // uninstallCmd represents the uninstall command 17 | var RemoveCmd = &cobra.Command{ 18 | Use: "remove /...", 19 | Aliases: []string{"uninstall", "rm"}, 20 | Short: "Uninstalls a parm package", 21 | Long: `Uninstalls a parm package. Does not remove the configuration files`, 22 | Run: func(cmd *cobra.Command, args []string) { 23 | ctx := cmd.Context() 24 | removed := make(map[string]bool) 25 | 26 | for _, pkg := range args { 27 | if _, ok := removed[pkg]; ok { 28 | // already processed the package uninstallation 29 | continue 30 | } 31 | removed[pkg] = true 32 | owner, repo, err := cmdparser.ParseRepoRef(pkg) 33 | 34 | if err != nil { 35 | fmt.Printf("invalid package ref: %q: %s\n", pkg, err) 36 | continue 37 | } 38 | 39 | err = uninstaller.RemovePkgSymlinks(ctx, owner, repo) 40 | if err != nil { 41 | fmt.Printf("error: cannot remove symlink for %s/%s:\n%q", owner, repo, err) 42 | } 43 | 44 | err = uninstaller.Uninstall(ctx, owner, repo) 45 | if err != nil { 46 | fmt.Printf("error: cannot uninstall %s: %s\n", pkg, err) 47 | } 48 | fmt.Printf("* Successfully uninstalled %s/%s\n", owner, repo) 49 | } 50 | }, 51 | } 52 | 53 | return RemoveCmd 54 | } 55 | -------------------------------------------------------------------------------- /internal/core/catalog/list.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "parm/internal/manifest" 7 | "path/filepath" 8 | 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | type PkgListData struct { 13 | NumPkgs int 14 | } 15 | 16 | func GetInstalledPkgInfo() ([]string, PkgListData, error) { 17 | mans, err := GetAllPkgManifest() 18 | var data PkgListData 19 | if err != nil { 20 | return nil, data, err 21 | } 22 | var infos []string 23 | for _, man := range mans { 24 | str := fmt.Sprintf("%s/%s || ver. %s", man.Owner, man.Repo, man.Version) 25 | infos = append(infos, str) 26 | } 27 | 28 | data.NumPkgs = len(infos) 29 | return infos, data, nil 30 | } 31 | 32 | func GetAllPkgManifest() ([]*manifest.Manifest, error) { 33 | pkgDirPath := viper.GetViper().GetString("parm_pkg_path") 34 | if pkgDirPath == "" { 35 | return nil, fmt.Errorf("error: parm_pkg_path could not be found") 36 | } 37 | 38 | var mans []*manifest.Manifest 39 | entries, err := os.ReadDir(pkgDirPath) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | for _, file := range entries { 45 | if !file.IsDir() { 46 | continue 47 | } 48 | path := filepath.Join(pkgDirPath, file.Name()) 49 | pkgs, err := os.ReadDir(path) 50 | if err != nil { 51 | continue 52 | } 53 | for _, pkg := range pkgs { 54 | fullFilePath := filepath.Join(path, pkg.Name()) 55 | man, err := manifest.Read(fullFilePath) 56 | if err != nil { 57 | // cannot find manifest, assume it's not an installation folder and continue 58 | continue 59 | } 60 | mans = append(mans, man) 61 | } 62 | } 63 | 64 | return mans, nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/gh/client.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/google/go-github/v74/github" 9 | "github.com/spf13/viper" 10 | "golang.org/x/oauth2" 11 | ) 12 | 13 | type Provider interface { 14 | Repos() *github.RepositoriesService 15 | Search() *github.SearchService 16 | } 17 | 18 | type client struct { 19 | c *github.Client 20 | } 21 | 22 | func (cli *client) Repos() *github.RepositoriesService { return cli.c.Repositories } 23 | func (cli *client) Search() *github.SearchService { return cli.c.Search } 24 | 25 | type Option func(*clientOptions) 26 | 27 | type clientOptions struct { 28 | hc *http.Client 29 | } 30 | 31 | func WithHTTPClient(hc *http.Client) Option { 32 | return func(c *clientOptions) { 33 | c.hc = hc 34 | } 35 | } 36 | 37 | func New(ctx context.Context, token string, opts ...Option) Provider { 38 | var cliOpts clientOptions 39 | for _, opt := range opts { 40 | opt(&cliOpts) 41 | } 42 | 43 | hc := cliOpts.hc 44 | if hc == nil && token != "" { 45 | src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 46 | hc = oauth2.NewClient(ctx, src) 47 | } 48 | 49 | cli := github.NewClient(hc) 50 | return &client{ 51 | c: cli, 52 | } 53 | } 54 | 55 | // returns the current API key, or nil if there is none 56 | func GetStoredApiKey(v *viper.Viper) (string, error) { 57 | var tok string 58 | tok = v.GetString("github_api_token") 59 | if tok == "" { 60 | tok = v.GetString("github_api_token_fallback") 61 | if tok == "" { 62 | return "", fmt.Errorf("error: api key not found") 63 | } 64 | } 65 | 66 | return tok, nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/cmdx/validation_test.go: -------------------------------------------------------------------------------- 1 | package cmdx 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func TestMarkFlagsRequireFlag(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | args []string 13 | expectErr bool 14 | }{ 15 | { 16 | name: "dependent flag without required flag should error", 17 | args: []string{"--child"}, 18 | expectErr: true, 19 | }, 20 | { 21 | name: "dependent flag with required flag should not error", 22 | args: []string{"--parent", "--child"}, 23 | expectErr: false, 24 | }, 25 | { 26 | name: "no flags should not error", 27 | args: []string{}, 28 | expectErr: false, 29 | }, 30 | { 31 | name: "only required flag should not error", 32 | args: []string{"--parent"}, 33 | expectErr: false, 34 | }, 35 | } 36 | 37 | for _, tc := range testCases { 38 | t.Run(tc.name, func(t *testing.T) { 39 | var parentFlag bool 40 | var childFlag bool 41 | 42 | cmd := &cobra.Command{ 43 | Use: "test", 44 | RunE: func(cmd *cobra.Command, args []string) error { 45 | return MarkFlagsRequireFlag(cmd, "parent", "child") 46 | }, 47 | } 48 | 49 | cmd.Flags().BoolVar(&parentFlag, "parent", false, "parent flag") 50 | cmd.Flags().BoolVar(&childFlag, "child", false, "child flag") 51 | 52 | cmd.SetArgs(tc.args) 53 | err := cmd.Execute() 54 | 55 | if tc.expectErr && err == nil { 56 | t.Errorf("Expected an error, but got nil") 57 | } 58 | if !tc.expectErr && err != nil { 59 | t.Errorf("Expected no error, but got: %v", err) 60 | } 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/progress/progress.go: -------------------------------------------------------------------------------- 1 | package progress 2 | 3 | import "io" 4 | 5 | type Stage string 6 | 7 | const ( 8 | StageDownload Stage = "download" 9 | StageExtract Stage = "extract" 10 | StageSearchBin Stage = "search_bin" 11 | ) 12 | 13 | type Event struct { 14 | Stage Stage 15 | Current int64 16 | Total int64 17 | Done bool 18 | } 19 | 20 | type Hooks struct { 21 | Callback Callback 22 | Decorator Decorator 23 | } 24 | 25 | type Callback func(Event) 26 | type Decorator func(stage Stage, r io.Reader, total int64) io.Reader 27 | 28 | type Reader struct { 29 | reader io.Reader 30 | callback Callback 31 | stage Stage 32 | total int64 33 | curr int64 34 | } 35 | 36 | var Nop Callback = func(Event) {} 37 | 38 | func NewReader(r io.Reader, total int64, st Stage, cb Callback) *Reader { 39 | if cb == nil { 40 | cb = Nop 41 | } 42 | return &Reader{ 43 | reader: r, 44 | callback: cb, 45 | stage: st, 46 | total: total, 47 | curr: 0, 48 | } 49 | } 50 | 51 | func (pr *Reader) Read(p []byte) (int, error) { 52 | n, err := pr.reader.Read(p) 53 | if n > 0 { 54 | pr.curr += int64(n) 55 | pr.callback(Event{ 56 | Stage: pr.stage, 57 | Current: pr.curr, 58 | Total: pr.total, 59 | }) 60 | } 61 | return n, err 62 | } 63 | 64 | func GetAsyncCallback(cb Callback, buf int) (wrapped Callback, stop func()) { 65 | ch := make(chan Event, buf) 66 | 67 | go func() { 68 | for ev := range ch { 69 | cb(ev) 70 | } 71 | }() 72 | 73 | wrapped = func(ev Event) { 74 | select { 75 | case ch <- ev: 76 | default: 77 | } 78 | } 79 | 80 | stop = func() { close(ch) } 81 | return 82 | } 83 | -------------------------------------------------------------------------------- /cmd/info/info.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 Alexander Wang 3 | */ 4 | package info 5 | 6 | import ( 7 | "fmt" 8 | "parm/internal/cmdutil" 9 | "parm/internal/core/catalog" 10 | "parm/internal/gh" 11 | "parm/pkg/cmdparser" 12 | 13 | "github.com/spf13/cobra" 14 | "github.com/spf13/viper" 15 | ) 16 | 17 | // TODO: don't retrive package info from GitHub if it doesn't have any releases. 18 | // doing this would be very api-expensive though still. 19 | 20 | // TODO: don't error out if trying to retrieve package info locally if the package doesn't exist. 21 | 22 | func NewInfoCmd(f *cmdutil.Factory) *cobra.Command { 23 | var getUpstream bool 24 | var infoCmd = &cobra.Command{ 25 | Use: "info /", 26 | Short: "Prints out information about a package", 27 | Args: cobra.ExactArgs(1), 28 | RunE: func(cmd *cobra.Command, args []string) error { 29 | ctx := cmd.Context() 30 | pkg := args[0] 31 | token, err := gh.GetStoredApiKey(viper.GetViper()) 32 | if err != nil { 33 | fmt.Printf("%s\ncontinuing without api key", err) 34 | } 35 | client := f.Provider(ctx, token).Repos() 36 | var owner, repo string 37 | 38 | owner, repo, err = cmdparser.ParseRepoRef(pkg) 39 | if err != nil { 40 | owner, repo, err = cmdparser.ParseGithubUrlPattern(pkg) 41 | if err != nil { 42 | return err 43 | } 44 | } 45 | 46 | info, err := catalog.GetPackageInfo(ctx, client, owner, repo, getUpstream) 47 | if err != nil { 48 | return err 49 | } 50 | pr := info.String() 51 | fmt.Println(pr) 52 | return nil 53 | }, 54 | } 55 | infoCmd.Flags().BoolVarP(&getUpstream, "get-upstream", "u", false, "Retrieves the Repository info from the GitHub repository instead of the locally installed package") 56 | 57 | return infoCmd 58 | } 59 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 Alexander Wang 3 | */ 4 | package cmd 5 | 6 | import ( 7 | "os" 8 | "parm/cmd/configure" 9 | "parm/cmd/info" 10 | "parm/cmd/install" 11 | "parm/cmd/list" 12 | "parm/cmd/remove" 13 | "parm/cmd/update" 14 | "parm/internal/cmdutil" 15 | "parm/internal/config" 16 | "parm/internal/gh" 17 | "parm/parmver" 18 | 19 | "github.com/spf13/cobra" 20 | ) 21 | 22 | func NewRootCmd(f *cmdutil.Factory) *cobra.Command { 23 | // rootCmd represents the base command when called without any subcommands 24 | var rootCmd = &cobra.Command{ 25 | Use: "parm", 26 | Short: "A zero-root, GitHub-native CLI package manager for installing and managing any GitHub-hosted tool.", 27 | Long: `Parm is a thin CLI tool that downloads and installs prebuilt 28 | your programs. It has zero dependencies, zero root access, and is truly 29 | cross-platform on Windows, Linux, and MacOS.`, 30 | Version: parmver.AppVersion.String(), 31 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 32 | err := config.Init() 33 | if err != nil { 34 | return err 35 | } 36 | return nil 37 | }, 38 | } 39 | 40 | rootCmd.AddCommand( 41 | configure.NewConfigureCmd(f), 42 | install.NewInstallCmd(f), 43 | remove.NewRemoveCmd(f), 44 | update.NewUpdateCmd(f), 45 | list.NewListCmd(f), 46 | info.NewInfoCmd(f), 47 | // search.NewSearchCmd(f), 48 | ) 49 | 50 | return rootCmd 51 | } 52 | 53 | // Execute adds all child commands to the root command and sets flags appropriately. 54 | // This is called by main.main(). It only needs to happen once to the rootCmd. 55 | func Execute() { 56 | factory := &cmdutil.Factory{ 57 | Provider: gh.New, 58 | } 59 | err := NewRootCmd(factory).Execute() 60 | if err != nil { 61 | os.Exit(1) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cmd/configure/reset.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 Alexander Wang 3 | */ 4 | package configure 5 | 6 | import ( 7 | "fmt" 8 | "maps" 9 | "parm/internal/cmdutil" 10 | "parm/internal/config" 11 | "slices" 12 | 13 | "github.com/go-viper/mapstructure/v2" 14 | "github.com/spf13/cobra" 15 | "github.com/spf13/viper" 16 | ) 17 | 18 | func NewResetCmd(f *cmdutil.Factory) *cobra.Command { 19 | var resetAll bool 20 | 21 | var resetCmd = &cobra.Command{ 22 | Use: "reset ", 23 | Short: "Resets key/value pairs to their default value.", 24 | Long: ``, 25 | RunE: func(cmd *cobra.Command, args []string) error { 26 | var cfgMap map[string]any 27 | if err := mapstructure.Decode(config.DefaultCfg, &cfgMap); err != nil { 28 | return err 29 | } 30 | 31 | if resetAll { 32 | args = slices.Sorted(maps.Keys(cfgMap)) 33 | } else { 34 | slices.Sort(args) 35 | } 36 | 37 | for _, arg := range args { 38 | def, ok := cfgMap[arg] 39 | if !ok { 40 | return fmt.Errorf("error: %s is not a valid configuration key", arg) 41 | } 42 | 43 | viper.Set(arg, def) 44 | fmt.Printf("Reset %s to default value: %v\n", arg, def) 45 | if err := viper.WriteConfig(); err != nil { 46 | return fmt.Errorf("error: failed to write config file: \n%w", err) 47 | } 48 | } 49 | return nil 50 | }, 51 | Args: func(cmd *cobra.Command, args []string) error { 52 | resetAllFlag, err := cmd.Flags().GetBool("all") 53 | if err != nil { 54 | return err 55 | } 56 | 57 | if resetAllFlag && len(args) > 0 { 58 | return fmt.Errorf("no arguments accepted when using the --all flag") 59 | } 60 | return nil 61 | }, 62 | } 63 | 64 | resetCmd.Flags().BoolVarP(&resetAll, "all", "a", false, "Resets all config values to their defaults.") 65 | 66 | return resetCmd 67 | } 68 | -------------------------------------------------------------------------------- /pkg/sysutil/process_test.go: -------------------------------------------------------------------------------- 1 | package sysutil 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "testing" 8 | ) 9 | 10 | func TestIsProcessRunning_NonExistent(t *testing.T) { 11 | tmpDir := t.TempDir() 12 | 13 | // Path to non-existent binary 14 | nonExistentPath := filepath.Join(tmpDir, "nonexistent") 15 | 16 | running, err := IsProcessRunning(nonExistentPath) 17 | if err != nil { 18 | // Error is acceptable for non-existent file 19 | return 20 | } 21 | 22 | if running { 23 | t.Error("IsProcessRunning() returned true for non-existent binary") 24 | } 25 | } 26 | 27 | func TestIsProcessRunning_CurrentProcess(t *testing.T) { 28 | // Get current executable path 29 | exePath, err := os.Executable() 30 | if err != nil { 31 | t.Skipf("Cannot get executable path: %v", err) 32 | } 33 | 34 | // The test runner itself should be running 35 | running, err := IsProcessRunning(exePath) 36 | if err != nil { 37 | t.Fatalf("IsProcessRunning() error: %v", err) 38 | } 39 | 40 | // Note: This might be false if the process list doesn't include the test binary 41 | // This is OS-dependent behavior 42 | t.Logf("Current process running status: %v", running) 43 | } 44 | 45 | func TestIsProcessRunning_RelativePath(t *testing.T) { 46 | if runtime.GOOS == "windows" { 47 | t.Skip("Skipping relative path test on Windows") 48 | } 49 | 50 | tmpDir := t.TempDir() 51 | binPath := filepath.Join(tmpDir, "testbin") 52 | 53 | // Create a dummy file (not a real running process) 54 | os.WriteFile(binPath, []byte("dummy"), 0755) 55 | 56 | running, err := IsProcessRunning(binPath) 57 | if err != nil { 58 | t.Logf("Error checking process: %v", err) 59 | } 60 | 61 | // Should not be running since it's not actually executed 62 | if running { 63 | t.Error("IsProcessRunning() returned true for non-running binary") 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | 7 | jobs: 8 | build: 9 | permissions: 10 | contents: write 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | include: 15 | - name: linux 16 | make_target: release-linux 17 | - name: windows 18 | make_target: release-windows 19 | - name: darwin 20 | make_target: release-darwin 21 | 22 | name: build-${{ matrix.name }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | fetch-tags: true 28 | 29 | - uses: actions/setup-go@v5 30 | with: 31 | go-version-file: go.mod 32 | 33 | - name: Ensure packaging tools 34 | shell: bash 35 | run: | 36 | for tool in zip; do 37 | if ! command -v "$tool" >/dev/null 2>&1; then 38 | need_install=1 39 | fi 40 | done 41 | if [ "${need_install:-}" = "1" ]; then 42 | sudo apt-get update 43 | sudo apt-get install -y zip 44 | fi 45 | 46 | - name: Build ${{ matrix.name }} artifacts 47 | env: 48 | VERSION: ${{ github.ref_name }} 49 | run: make ${{ matrix.make_target }} 50 | 51 | release: 52 | runs-on: ubuntu-latest 53 | needs: build 54 | steps: 55 | - uses: actions/checkout@v4 56 | with: 57 | fetch-depth: 0 58 | fetch-tags: true 59 | 60 | - uses: actions/setup-go@v5 61 | with: 62 | go-version-file: go.mod 63 | 64 | - name: Upload release assets 65 | uses: softprops/action-gh-release@v2 66 | with: 67 | files: | 68 | bin/parm-linux-amd64.tar.gz 69 | bin/parm-linux-arm64.tar.gz 70 | bin/parm-darwin-amd64.tar.gz 71 | bin/parm-darwin-arm64.tar.gz 72 | bin/parm-windows-amd64.zip 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module parm 2 | 3 | go 1.24.5 4 | 5 | require ( 6 | github.com/Masterminds/semver/v3 v3.4.0 7 | github.com/go-viper/mapstructure/v2 v2.2.1 8 | github.com/google/go-github/v74 v74.0.0 9 | github.com/h2non/filetype v1.1.3 10 | github.com/migueleliasweb/go-github-mock v1.4.0 11 | github.com/shirou/gopsutil/v4 v4.25.7 12 | github.com/spf13/cobra v1.9.1 13 | github.com/spf13/viper v1.20.1 14 | github.com/vbauerster/mpb/v8 v8.11.2 15 | golang.org/x/oauth2 v0.30.0 16 | ) 17 | 18 | require ( 19 | github.com/VividCortex/ewma v1.2.0 // indirect 20 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect 21 | github.com/clipperhouse/stringish v0.1.1 // indirect 22 | github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 23 | github.com/ebitengine/purego v0.8.4 // indirect 24 | github.com/fsnotify/fsnotify v1.8.0 // indirect 25 | github.com/go-ole/go-ole v1.2.6 // indirect 26 | github.com/google/go-github/v73 v73.0.0 // indirect 27 | github.com/google/go-querystring v1.1.0 // indirect 28 | github.com/gorilla/mux v1.8.1 // indirect 29 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 30 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 31 | github.com/mattn/go-runewidth v0.0.19 // indirect 32 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 33 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 34 | github.com/sagikazarmark/locafero v0.7.0 // indirect 35 | github.com/sourcegraph/conc v0.3.0 // indirect 36 | github.com/spf13/afero v1.12.0 // indirect 37 | github.com/spf13/cast v1.7.1 // indirect 38 | github.com/spf13/pflag v1.0.6 // indirect 39 | github.com/subosito/gotenv v1.6.0 // indirect 40 | github.com/tklauser/go-sysconf v0.3.15 // indirect 41 | github.com/tklauser/numcpus v0.10.0 // indirect 42 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 43 | go.uber.org/atomic v1.9.0 // indirect 44 | go.uber.org/multierr v1.9.0 // indirect 45 | golang.org/x/sys v0.38.0 // indirect 46 | golang.org/x/text v0.26.0 // indirect 47 | golang.org/x/time v0.11.0 // indirect 48 | gopkg.in/yaml.v3 v3.0.1 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /internal/core/uninstaller/uninstaller.go: -------------------------------------------------------------------------------- 1 | package uninstaller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "parm/internal/config" 8 | "parm/internal/manifest" 9 | "parm/internal/parmutil" 10 | "parm/pkg/sysutil" 11 | "path/filepath" 12 | ) 13 | 14 | // TODO: when version management is added?, have an option to remove a specific version 15 | // remove concurrently? 16 | func Uninstall(ctx context.Context, owner, repo string) error { 17 | dir := parmutil.GetInstallDir(owner, repo) 18 | fi, err := os.Stat(dir) 19 | if err != nil { 20 | return fmt.Errorf("error: dir does not exist: \n%w", err) 21 | } 22 | if !fi.IsDir() { 23 | return fmt.Errorf("error: selected item is not a dir: \n%w", err) 24 | } 25 | 26 | manifest, err := manifest.Read(dir) 27 | if err != nil { 28 | return fmt.Errorf("error: could not read manifest: \n%w", err) 29 | } 30 | 31 | var execPaths []string 32 | for _, path := range manifest.Executables { 33 | fullPath := filepath.Join(dir, path) 34 | execPaths = append(execPaths, fullPath) 35 | } 36 | 37 | for _, path := range execPaths { 38 | isRunning, err := sysutil.IsProcessRunning(path) 39 | if err != nil { 40 | fmt.Fprintf(os.Stderr, "Warning: Could not check for process: %s: %v\n", path, err) 41 | continue 42 | } 43 | if isRunning { 44 | return fmt.Errorf("error: cannot uninstall process %s because it is currently running", filepath.Base(path)) 45 | } 46 | } 47 | 48 | if err = os.RemoveAll(dir); err != nil { 49 | return fmt.Errorf("error: Cannot remove dir: %s: \n%w", dir, err) 50 | } 51 | 52 | parentDir, err := sysutil.GetParentDir(dir) 53 | // NOTE: don't want to error out here if it fails 54 | if err != nil { 55 | return nil 56 | } 57 | 58 | entries, err := os.ReadDir(parentDir) 59 | if err == nil && len(entries) == 0 { 60 | _ = os.Remove(parentDir) 61 | return nil 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func RemovePkgSymlinks(ctx context.Context, owner, repo string) error { 68 | man, err := manifest.Read(parmutil.GetInstallDir(owner, repo)) 69 | if err != nil { 70 | return err 71 | } 72 | exDirs := man.GetFullExecPaths() 73 | 74 | for _, dir := range exDirs { 75 | binPath := filepath.Join(config.Cfg.ParmBinPath, filepath.Base(dir)) 76 | if _, err := os.Lstat(binPath); err != nil { 77 | return err 78 | } 79 | _ = os.Remove(binPath) // continue if there's an error 80 | } 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /internal/parmutil/package.go: -------------------------------------------------------------------------------- 1 | package parmutil 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "parm/internal/config" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | const STAGING_DIR_PREFIX string = ".staging-" 12 | 13 | func MakeInstallDir(owner, repo string, perm os.FileMode) (string, error) { 14 | path := GetInstallDir(owner, repo) 15 | err := os.MkdirAll(path, perm) 16 | if err != nil { 17 | return "", fmt.Errorf("error: cannot create install dir: \n%w", err) 18 | } 19 | return path, nil 20 | } 21 | 22 | // Generates install directory for a package. Does not guarantee that the directory actually exists. 23 | func GetInstallDir(owner, repo string) string { 24 | installPath := config.Cfg.ParmPkgPath 25 | dest := filepath.Join(installPath, owner, repo) 26 | return dest 27 | } 28 | 29 | func GetBinDir(repoName string) string { 30 | binPath := config.Cfg.ParmBinPath 31 | dest := filepath.Join(binPath, repoName) 32 | return dest 33 | } 34 | 35 | func MakeStagingDir(owner, repo string) (string, error) { 36 | parentDir := filepath.Join(config.Cfg.ParmPkgPath, owner) 37 | if err := os.MkdirAll(parentDir, 0o755); err != nil { 38 | return "", err 39 | } 40 | var tmpDir string 41 | var err error 42 | if tmpDir, err = os.MkdirTemp(parentDir, STAGING_DIR_PREFIX+repo+"-"); err != nil { 43 | return "", err 44 | } 45 | return tmpDir, nil 46 | } 47 | 48 | func PromoteStagingDir(final, staging string) (string, error) { 49 | if fi, err := os.Stat(final); err == nil && fi.IsDir() { 50 | if err := os.RemoveAll(final); err != nil { 51 | return "", fmt.Errorf("error: failed removing existing install: %w", err) 52 | } 53 | } 54 | if err := os.Rename(staging, final); err != nil { 55 | return "", fmt.Errorf("error: staging promotion failed: %w", err) 56 | } 57 | return final, nil 58 | } 59 | 60 | // dir should be the owner dir, at $PKG_ROOT/owner/ 61 | // TODO: fix this mess 62 | func Cleanup(dir string) error { 63 | if dir == "" { 64 | // fail silently 65 | return nil 66 | } 67 | 68 | var fi os.FileInfo 69 | var err error 70 | if fi, err = os.Stat(dir); err != nil { 71 | if os.IsNotExist(err) { 72 | return nil 73 | } 74 | return err 75 | } 76 | if !fi.IsDir() { 77 | return fmt.Errorf("error: %s is not a dir", dir) 78 | } 79 | dr, err := os.ReadDir(dir) 80 | if err != nil { 81 | return err 82 | } 83 | for _, item := range dr { 84 | if strings.HasPrefix(item.Name(), STAGING_DIR_PREFIX) { 85 | path := filepath.Join(dir, item.Name()) 86 | _ = os.RemoveAll(path) 87 | } 88 | } 89 | 90 | dr, _ = os.ReadDir(dir) 91 | if len(dr) == 0 { 92 | _ = os.Remove(dir) 93 | } 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /internal/gh/client_test.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | func TestNew_WithToken(t *testing.T) { 11 | ctx := context.Background() 12 | token := "test_token_123" 13 | 14 | provider := New(ctx, token) 15 | if provider == nil { 16 | t.Fatal("New() returned nil") 17 | } 18 | 19 | // Verify we can get services 20 | repos := provider.Repos() 21 | if repos == nil { 22 | t.Error("Repos() returned nil") 23 | } 24 | 25 | search := provider.Search() 26 | if search == nil { 27 | t.Error("Search() returned nil") 28 | } 29 | } 30 | 31 | func TestNew_WithoutToken(t *testing.T) { 32 | ctx := context.Background() 33 | token := "" 34 | 35 | provider := New(ctx, token) 36 | if provider == nil { 37 | t.Fatal("New() returned nil") 38 | } 39 | 40 | // Should still work without token (unauthenticated) 41 | repos := provider.Repos() 42 | if repos == nil { 43 | t.Error("Repos() returned nil for unauthenticated client") 44 | } 45 | } 46 | 47 | func TestGetStoredApiKey_FromFallback(t *testing.T) { 48 | v := viper.New() 49 | 50 | testToken := "fallback_token_123" 51 | v.Set("github_api_token_fallback", testToken) 52 | 53 | token, err := GetStoredApiKey(v) 54 | if err != nil { 55 | t.Fatalf("GetStoredApiKey() error: %v", err) 56 | } 57 | 58 | if token != testToken { 59 | t.Errorf("GetStoredApiKey() = %v, want %v", token, testToken) 60 | } 61 | } 62 | 63 | func TestGetStoredApiKey_FromMain(t *testing.T) { 64 | v := viper.New() 65 | 66 | mainToken := "main_token_123" 67 | v.Set("github_api_token", mainToken) 68 | 69 | fallbackToken := "fallback_token_456" 70 | v.Set("github_api_token_fallback", fallbackToken) 71 | 72 | token, err := GetStoredApiKey(v) 73 | if err != nil { 74 | t.Fatalf("GetStoredApiKey() error: %v", err) 75 | } 76 | 77 | // Should prefer main token over fallback 78 | if token != mainToken { 79 | t.Errorf("GetStoredApiKey() = %v, want %v (main token)", token, mainToken) 80 | } 81 | } 82 | 83 | func TestGetStoredApiKey_NoToken(t *testing.T) { 84 | v := viper.New() 85 | 86 | _, err := GetStoredApiKey(v) 87 | if err == nil { 88 | t.Error("GetStoredApiKey() should return error when no token is set") 89 | } 90 | } 91 | 92 | func TestGetStoredApiKey_EmptyTokens(t *testing.T) { 93 | v := viper.New() 94 | 95 | v.Set("github_api_token", "") 96 | v.Set("github_api_token_fallback", "") 97 | 98 | _, err := GetStoredApiKey(v) 99 | if err == nil { 100 | t.Error("GetStoredApiKey() should return error for empty tokens") 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /pkg/cmdparser/cmdparser.go: -------------------------------------------------------------------------------- 1 | package cmdparser 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | var ownerRepoStr = `([a-z\d](?:[a-z\d-]{0,38}[a-z\d])*)/([a-z\d_.-]+?)` 10 | var ownerRepoPattern = regexp.MustCompile(`(?i)^` + ownerRepoStr + `$`) 11 | var ownerRepoTagPattern = regexp.MustCompile(`(?i)^` + ownerRepoStr + `(?:@(.+))?$`) 12 | var githubUrlPattern = regexp.MustCompile(`(?i)^(?:https://github\.com/|git@github\.com:)` + ownerRepoStr + `(?:\.git)?$`) 13 | var githubUrlPatternWithRelease = regexp.MustCompile(`(?i)^(?:https://github\.com/|git@github\.com:)` + ownerRepoStr + `(?:\.git)?(?:@(.+))?$`) 14 | 15 | // general purpose 16 | func ParseRepoRef(ref string) (owner string, repo string, err error) { 17 | if matches := ownerRepoPattern.FindStringSubmatch(ref); matches != nil { 18 | return matches[1], matches[2], nil 19 | } 20 | return "", "", fmt.Errorf("cannot validate owner/repository link: %q", ref) 21 | } 22 | 23 | // specifically parsing tag args 24 | func ParseRepoReleaseRef(ref string) (owner string, repo string, release string, err error) { 25 | if matches := ownerRepoTagPattern.FindStringSubmatch(ref); matches != nil { 26 | return matches[1], matches[2], matches[3], nil 27 | } 28 | return "", "", "", fmt.Errorf("cannot validate owner/repository link: %q", ref) 29 | } 30 | 31 | // general purpose 32 | func ParseGithubUrlPattern(ref string) (owner string, repo string, err error) { 33 | if matches := githubUrlPattern.FindStringSubmatch(ref); matches != nil { 34 | owner, repo := matches[1], matches[2] 35 | if repo != ".git" { 36 | return owner, repo, nil 37 | } 38 | } 39 | return "", "", fmt.Errorf("cannot validate owner/repository link: %q", ref) 40 | } 41 | 42 | // specifically parsing tag args 43 | func ParseGithubUrlPatternWithRelease(ref string) (owner string, repo string, release string, err error) { 44 | if matches := githubUrlPatternWithRelease.FindStringSubmatch(ref); matches != nil { 45 | owner, repo, tag := matches[1], matches[2], matches[3] 46 | if repo != ".git" { 47 | return owner, repo, tag, nil 48 | } 49 | } 50 | return "", "", "", fmt.Errorf("cannot validate owner/repository link: %q", ref) 51 | } 52 | 53 | func BuildGitLink(owner string, repo string) (httpsLink string, sshLink string) { 54 | httpCloneLink := fmt.Sprintf("https://github.com/%s/%s.git", owner, repo) 55 | sshCloneLink := fmt.Sprintf("git@github.com:%s/%s.git", owner, repo) 56 | return httpCloneLink, sshCloneLink 57 | } 58 | 59 | func StringToString(in string) (string, string, error) { 60 | parts := strings.SplitN(in, "=", 2) 61 | if len(parts) != 2 { 62 | return "", "", fmt.Errorf("invalid argument format: %q. Expected key=value", in) 63 | } 64 | return parts[0], parts[1], nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | 9 | "github.com/go-viper/mapstructure/v2" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | type Config struct { 14 | GitHubApiTokenFallback string `mapstructure:"github_api_token_fallback"` 15 | 16 | // where to store the packages 17 | ParmPkgPath string `mapstructure:"parm_pkg_path"` 18 | 19 | // directory added to PATH where symlinked binaries reside 20 | ParmBinPath string `mapstructure:"parm_bin_path"` 21 | } 22 | 23 | var defaultPkgDir = getOrCreateDefaultPkgDir() 24 | var defaultBinDir = getOrCreateDefaultBinDir() 25 | var DefaultCfg = &Config{ 26 | GitHubApiTokenFallback: "", 27 | ParmPkgPath: defaultPkgDir, 28 | ParmBinPath: defaultBinDir, 29 | } 30 | 31 | func setEnvVars(v *viper.Viper) { 32 | v.BindEnv("github_api_token", "PARM_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN") 33 | } 34 | 35 | func setConfigDefaults(v *viper.Viper) error { 36 | var cfgMap map[string]any 37 | if err := mapstructure.Decode(DefaultCfg, &cfgMap); err != nil { 38 | return err 39 | } 40 | 41 | for k, val := range cfgMap { 42 | v.SetDefault(k, val) 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func GetDefaultPrefixDir() (string, error) { 49 | var path string 50 | switch runtime.GOOS { 51 | case "linux": 52 | if dir, ok := os.LookupEnv("XDG_DATA_HOME"); ok && dir != "" { 53 | path = filepath.Join(dir, "parm") 54 | return path, nil 55 | } 56 | home, err := os.UserHomeDir() 57 | if err != nil { 58 | return "", err 59 | } 60 | path = filepath.Join(home, ".local", "share", "parm") 61 | return path, nil 62 | case "darwin": 63 | home, err := os.UserHomeDir() 64 | if err != nil { 65 | return "", err 66 | } 67 | return filepath.Join(home, "Library", "Application Support", "parm"), nil 68 | case "windows": 69 | var err error 70 | if pf, err := os.UserHomeDir(); err != nil && pf != "" { 71 | return filepath.Join(pf, ".parm"), nil 72 | } 73 | return "", err 74 | default: 75 | return "", fmt.Errorf("error: os not supported") 76 | } 77 | } 78 | 79 | func getOrCreateDefaultPkgDir() string { 80 | return getOrCreatDefaultDir("pkg") 81 | } 82 | 83 | func getOrCreateDefaultBinDir() string { 84 | return getOrCreatDefaultDir("bin") 85 | } 86 | 87 | // TODO: return err? 88 | func getOrCreatDefaultDir(addedPath string) string { 89 | prefix, err := GetDefaultPrefixDir() 90 | if err != nil { 91 | return "" 92 | } 93 | 94 | path := filepath.Join(prefix, filepath.Clean(addedPath)) 95 | 96 | fi, err := os.Stat(path) 97 | if err != nil { 98 | if os.IsNotExist(err) { 99 | if mkErr := os.MkdirAll(path, 0o700); mkErr != nil { 100 | return "" 101 | } 102 | return path 103 | } 104 | return "" 105 | } 106 | if !fi.IsDir() { 107 | return "" 108 | } 109 | return path 110 | } 111 | -------------------------------------------------------------------------------- /pkg/archive/extract.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "archive/tar" 5 | "archive/zip" 6 | "compress/gzip" 7 | "io" 8 | "io/fs" 9 | "os" 10 | "parm/pkg/sysutil" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | func ExtractTarGz(srcPath, destPath string) error { 16 | file, err := os.Open(srcPath) 17 | if err != nil { 18 | return err 19 | } 20 | defer file.Close() 21 | 22 | gz, err := gzip.NewReader(file) 23 | if err != nil { 24 | return err 25 | } 26 | defer gz.Close() 27 | 28 | tr := tar.NewReader(gz) 29 | for { 30 | hdr, err := tr.Next() 31 | if err == io.EOF { 32 | break 33 | } 34 | if err != nil { 35 | return err 36 | } 37 | 38 | name := hdr.Name 39 | target, err := sysutil.SafeJoin(destPath, name) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | switch hdr.Typeflag { 45 | case tar.TypeDir: 46 | if err := os.MkdirAll(target, fs.FileMode(hdr.Mode)); err != nil { 47 | return err 48 | } 49 | case tar.TypeReg: 50 | if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { 51 | return err 52 | } 53 | out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, fs.FileMode(hdr.Mode)) 54 | if err != nil { 55 | return err 56 | } 57 | if _, err := io.Copy(out, tr); err != nil { 58 | out.Close() 59 | return err 60 | } 61 | out.Close() 62 | case tar.TypeSymlink: 63 | if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { 64 | return err 65 | } 66 | linkTarget := hdr.Linkname 67 | cleanedTarget := filepath.Clean(filepath.Join(filepath.Dir(name), linkTarget)) 68 | if _, err := sysutil.SafeJoin(destPath, cleanedTarget); err != nil { 69 | return err 70 | } 71 | _ = os.Symlink(linkTarget, target) 72 | default: 73 | // nothing? 74 | } 75 | } 76 | return nil 77 | } 78 | 79 | func ExtractZip(srcPath, destPath string) error { 80 | r, err := zip.OpenReader(srcPath) 81 | if err != nil { 82 | return err 83 | } 84 | defer r.Close() 85 | 86 | for _, f := range r.File { 87 | name := f.Name 88 | 89 | rc, err := f.Open() 90 | if err != nil { 91 | return err 92 | } 93 | defer rc.Close() 94 | 95 | fpath, err := sysutil.SafeJoin(destPath, name) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | if f.FileInfo().IsDir() { 101 | os.MkdirAll(fpath, 0o755) 102 | } else { 103 | var fdir string 104 | if lastIndex := strings.LastIndex(fpath, string(os.PathSeparator)); lastIndex > -1 { 105 | fdir = fpath[:lastIndex] 106 | } 107 | 108 | err = os.MkdirAll(fdir, 0o755) 109 | if err != nil { 110 | return err 111 | } 112 | f, err := os.OpenFile( 113 | fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 114 | if err != nil { 115 | return err 116 | } 117 | defer f.Close() 118 | 119 | _, err = io.Copy(f, rc) 120 | if err != nil { 121 | return err 122 | } 123 | } 124 | } 125 | return nil 126 | } 127 | -------------------------------------------------------------------------------- /internal/core/updater/updater.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "parm/internal/core/installer" 8 | "parm/internal/gh" 9 | "parm/internal/manifest" 10 | "parm/internal/parmutil" 11 | "parm/pkg/progress" 12 | 13 | "github.com/Masterminds/semver/v3" 14 | "github.com/google/go-github/v74/github" 15 | ) 16 | 17 | // TODO: modify updater to use new symlinking logic 18 | 19 | type Updater struct { 20 | client *github.RepositoriesService 21 | installer installer.Installer 22 | } 23 | 24 | type UpdateResult struct { 25 | OldManifest *manifest.Manifest 26 | *installer.InstallResult 27 | } 28 | 29 | type UpdateFlags struct { 30 | Strict bool 31 | } 32 | 33 | func New(cli *github.RepositoriesService, rel *installer.Installer) *Updater { 34 | return &Updater{ 35 | client: cli, 36 | installer: *rel, 37 | } 38 | } 39 | 40 | // TODO: update concurrently? 41 | func (up *Updater) Update(ctx context.Context, owner, repo string, installPath string, flags *UpdateFlags, hooks *progress.Hooks) (*UpdateResult, error) { 42 | installDir := parmutil.GetInstallDir(owner, repo) 43 | man, err := manifest.Read(installDir) 44 | 45 | if err != nil { 46 | if os.IsNotExist(err) { 47 | return nil, fmt.Errorf("package %s/%s does not exist", owner, repo) 48 | } 49 | return nil, fmt.Errorf("could not read manifest for %s/%s: %w", owner, repo, err) 50 | } 51 | 52 | var rel *github.RepositoryRelease 53 | 54 | switch man.InstallType { 55 | case manifest.Release: 56 | rel, _, err = up.client.GetLatestRelease(ctx, owner, repo) 57 | case manifest.PreRelease: 58 | rel, err = gh.GetLatestPreRelease(ctx, up.client, owner, repo) 59 | // TODO: DRY @installer.go 60 | if !flags.Strict { 61 | // expensive! 62 | relStable, _, err := up.client.GetLatestRelease(ctx, owner, repo) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | // TODO: abstract elsewhere cuz it's similar to updater.NeedsUpdate? 68 | currVer, _ := semver.NewVersion(rel.GetTagName()) 69 | stableVer, _ := semver.NewVersion(relStable.GetTagName()) 70 | if stableVer.GreaterThan(currVer) { 71 | rel = relStable 72 | } 73 | } 74 | } 75 | 76 | newVer := rel.GetTagName() 77 | 78 | // only need to check for equality 79 | if man.Version == newVer { 80 | return nil, fmt.Errorf("%s/%s is already up to date (ver %s)", owner, repo, man.Version) 81 | } 82 | 83 | if err != nil { 84 | return nil, fmt.Errorf("could not fetch latest release for %s/%s: %w", owner, repo, err) 85 | } 86 | 87 | opts := installer.InstallFlags{ 88 | Type: man.InstallType, 89 | Version: &newVer, 90 | Asset: nil, 91 | Strict: flags.Strict, 92 | VerifyLevel: 0, 93 | } 94 | 95 | res, err := up.installer.Install(ctx, owner, repo, installPath, opts, hooks) 96 | if err != nil { 97 | return nil, err 98 | } 99 | actual := UpdateResult{ 100 | OldManifest: man, 101 | InstallResult: res, 102 | } 103 | return &actual, nil 104 | } 105 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile 2 | 3 | # Define default Go build flags 4 | VERSION ?= $(shell git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0-dev) 5 | LDFLAGS = -s -w -X 'parm/parmver.StringVersion=$(VERSION)' 6 | DEBUG_FLAGS = 7 | 8 | # Set GoOS and GoARCH (can override in command line) 9 | GOOS ?= linux 10 | GOARCH ?= amd64 11 | 12 | # The binary name and output location 13 | BINARY_NAME = parm 14 | OUTPUT_DIR = ./bin 15 | 16 | # Make sure the output directory exists 17 | .PHONY: $(OUTPUT_DIR) 18 | $(OUTPUT_DIR): 19 | mkdir -p $(OUTPUT_DIR) 20 | 21 | # Default target (build all platforms and create tarballs/zips) 22 | all: build 23 | release: release-linux release-darwin release-windows 24 | 25 | build: | $(OUTPUT_DIR) 26 | GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags="$(LDFLAGS)" -o $(OUTPUT_DIR)/$(BINARY_NAME) 27 | 28 | debug: | $(OUTPUT_DIR) 29 | GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags="$(DEBUG_FLAGS)" -o $(OUTPUT_DIR)/$(BINARY_NAME) 30 | 31 | .PHONY: test 32 | test: 33 | @echo "Running tests..." 34 | go test ./... 35 | 36 | # Build and create tarball for Linux (amd64 + arm64) 37 | release-linux: | $(OUTPUT_DIR) 38 | @echo "Building for Linux amd64..." 39 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(OUTPUT_DIR)/$(BINARY_NAME) 40 | @echo "Creating tarball for Linux amd64..." 41 | tar -czvf $(OUTPUT_DIR)/$(BINARY_NAME)-linux-amd64.tar.gz -C $(OUTPUT_DIR) $(BINARY_NAME) 42 | rm -f $(OUTPUT_DIR)/$(BINARY_NAME) 43 | 44 | @echo "Building for Linux arm64..." 45 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(OUTPUT_DIR)/$(BINARY_NAME) 46 | @echo "Creating tarball for Linux arm64..." 47 | tar -czvf $(OUTPUT_DIR)/$(BINARY_NAME)-linux-arm64.tar.gz -C $(OUTPUT_DIR) $(BINARY_NAME) 48 | rm -f $(OUTPUT_DIR)/$(BINARY_NAME) 49 | 50 | # Build and create tarball for macOS (amd64 + arm64) 51 | release-darwin: | $(OUTPUT_DIR) 52 | @echo "Building for macOS amd64..." 53 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(OUTPUT_DIR)/$(BINARY_NAME) 54 | @echo "Creating tarball for macOS amd64..." 55 | tar -czvf $(OUTPUT_DIR)/$(BINARY_NAME)-darwin-amd64.tar.gz -C $(OUTPUT_DIR) $(BINARY_NAME) 56 | rm -f $(OUTPUT_DIR)/$(BINARY_NAME) 57 | 58 | @echo "Building for macOS arm64..." 59 | CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(OUTPUT_DIR)/$(BINARY_NAME) 60 | @echo "Creating tarball for macOS arm64..." 61 | tar -czvf $(OUTPUT_DIR)/$(BINARY_NAME)-darwin-arm64.tar.gz -C $(OUTPUT_DIR) $(BINARY_NAME) 62 | rm -f $(OUTPUT_DIR)/$(BINARY_NAME) 63 | 64 | # Build and create zip file for Windows 65 | release-windows: | $(OUTPUT_DIR) 66 | @echo "Building for Windows..." 67 | GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(OUTPUT_DIR)/$(BINARY_NAME).exe 68 | @echo "Creating zip file for Windows..." 69 | zip -r $(OUTPUT_DIR)/$(BINARY_NAME)-windows-amd64.zip $(OUTPUT_DIR)/$(BINARY_NAME).exe 70 | @echo "Deleting binary for Windows..." 71 | rm -f $(OUTPUT_DIR)/$(BINARY_NAME).exe 72 | 73 | # Clean up build artifacts 74 | clean: 75 | rm -rf $(OUTPUT_DIR) 76 | 77 | format: 78 | gofmt -w . 79 | -------------------------------------------------------------------------------- /internal/manifest/manifest.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "parm/internal/parmutil" 7 | "parm/pkg/sysutil" 8 | "path/filepath" 9 | "time" 10 | ) 11 | 12 | const ManifestFileName string = ".curdfile.json" 13 | 14 | type InstallType string 15 | 16 | const ( 17 | Release InstallType = "release" 18 | PreRelease InstallType = "pre-release" 19 | ) 20 | 21 | type Manifest struct { 22 | Owner string `json:"owner"` 23 | Repo string `json:"repo"` 24 | LastUpdated string `json:"last_updated"` 25 | Executables []string `json:"executables"` 26 | InstallType InstallType `json:"install_type"` 27 | Version string `json:"version"` 28 | } 29 | 30 | // TODO: create manifest options struct?? 31 | func New(owner, repo, version string, installType InstallType, installDir string) (*Manifest, error) { 32 | m := &Manifest{ 33 | Owner: owner, 34 | Repo: repo, 35 | LastUpdated: time.Now().UTC().Format(time.DateTime), 36 | Executables: []string{}, 37 | InstallType: installType, 38 | Version: version, 39 | } 40 | 41 | binM, err := getBinExecutables(installDir) 42 | if err != nil { 43 | // just return no bins 44 | m.Executables = nil 45 | return m, err 46 | } 47 | m.Executables = binM 48 | return m, nil 49 | } 50 | 51 | func (m *Manifest) GetFullExecPaths() []string { 52 | var res []string 53 | for _, path := range m.Executables { 54 | srcPath := parmutil.GetInstallDir(m.Owner, m.Repo) 55 | newPath := filepath.Join(srcPath, path) 56 | res = append(res, newPath) 57 | } 58 | return res 59 | } 60 | 61 | func (m *Manifest) Write(installDir string) error { 62 | path := filepath.Join(installDir, ManifestFileName) 63 | data, err := json.MarshalIndent(m, "", " ") 64 | if err != nil { 65 | return err 66 | } 67 | return os.WriteFile(path, data, 0o644) 68 | } 69 | 70 | func Read(installDir string) (*Manifest, error) { 71 | path := filepath.Join(installDir, ManifestFileName) 72 | data, err := os.ReadFile(path) 73 | if err != nil { 74 | return nil, err 75 | } 76 | var m Manifest 77 | err = json.Unmarshal(data, &m) 78 | return &m, err 79 | } 80 | 81 | func getBinExecutables(installDir string) ([]string, error) { 82 | var paths []string 83 | scanDir := installDir 84 | binDir := filepath.Join(installDir, "bin") 85 | if info, err := os.Stat(binDir); err == nil && info.IsDir() { 86 | scanDir = binDir 87 | } 88 | 89 | err := filepath.WalkDir(scanDir, func(path string, d os.DirEntry, err error) error { 90 | if err != nil { 91 | return err 92 | } 93 | if d.Name() == ManifestFileName || d.IsDir() { 94 | return nil 95 | } 96 | 97 | isExec, err := sysutil.IsValidBinaryExecutable(path) 98 | if err != nil { 99 | return err 100 | } 101 | if isExec { 102 | relPath, _ := filepath.Rel(installDir, path) 103 | paths = append(paths, filepath.ToSlash(relPath)) 104 | } 105 | return nil 106 | }) 107 | 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | return paths, err 113 | } 114 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "testing" 8 | ) 9 | 10 | func TestGetDefaultPrefixDir(t *testing.T) { 11 | // Save original env vars 12 | origXDG := os.Getenv("XDG_DATA_HOME") 13 | defer os.Setenv("XDG_DATA_HOME", origXDG) 14 | 15 | tests := []struct { 16 | name string 17 | goos string 18 | xdgDataHome string 19 | shouldContain string 20 | }{ 21 | { 22 | name: "Linux with XDG_DATA_HOME", 23 | goos: "linux", 24 | xdgDataHome: "/custom/data", 25 | shouldContain: "parm", 26 | }, 27 | { 28 | name: "Linux without XDG_DATA_HOME", 29 | goos: "linux", 30 | xdgDataHome: "", 31 | shouldContain: "parm", 32 | }, 33 | } 34 | 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | if tt.goos != runtime.GOOS { 38 | t.Skipf("Skipping test for %s on %s", tt.goos, runtime.GOOS) 39 | } 40 | 41 | if tt.xdgDataHome != "" { 42 | os.Setenv("XDG_DATA_HOME", tt.xdgDataHome) 43 | } else { 44 | os.Unsetenv("XDG_DATA_HOME") 45 | } 46 | 47 | path, err := GetDefaultPrefixDir() 48 | if err != nil { 49 | t.Fatalf("GetDefaultPrefixDir() error: %v", err) 50 | } 51 | 52 | if path == "" { 53 | t.Error("GetDefaultPrefixDir() returned empty string") 54 | } 55 | 56 | // Check if path contains expected substring 57 | if tt.shouldContain != "" && filepath.Base(path) != tt.shouldContain { 58 | t.Errorf("GetDefaultPrefixDir() = %v, should contain %v", path, tt.shouldContain) 59 | } 60 | }) 61 | } 62 | } 63 | 64 | func TestGetDefaultPrefixDir_AllPlatforms(t *testing.T) { 65 | path, err := GetDefaultPrefixDir() 66 | if err != nil { 67 | t.Fatalf("GetDefaultPrefixDir() error: %v", err) 68 | } 69 | 70 | if path == "" { 71 | t.Error("GetDefaultPrefixDir() returned empty string") 72 | } 73 | 74 | // Verify it ends with "parm" 75 | if filepath.Base(path) != "parm" { 76 | t.Errorf("GetDefaultPrefixDir() = %v, expected to end with 'parm'", path) 77 | } 78 | 79 | // Platform-specific checks 80 | switch runtime.GOOS { 81 | case "linux": 82 | // Should be in .local/share or XDG_DATA_HOME 83 | if !filepath.IsAbs(path) { 84 | t.Error("Expected absolute path on Linux") 85 | } 86 | case "darwin": 87 | // Should be in Library/Application Support 88 | if !filepath.IsAbs(path) { 89 | t.Error("Expected absolute path on macOS") 90 | } 91 | case "windows": 92 | // Should be in home directory 93 | if !filepath.IsAbs(path) { 94 | t.Error("Expected absolute path on Windows") 95 | } 96 | } 97 | } 98 | 99 | func TestDefaultConfig(t *testing.T) { 100 | if DefaultCfg == nil { 101 | t.Fatal("DefaultCfg is nil") 102 | } 103 | 104 | if DefaultCfg.ParmPkgPath == "" { 105 | t.Error("DefaultCfg.ParmPkgPath is empty") 106 | } 107 | 108 | if DefaultCfg.ParmBinPath == "" { 109 | t.Error("DefaultCfg.ParmBinPath is empty") 110 | } 111 | 112 | // GitHubApiTokenFallback can be empty 113 | } 114 | -------------------------------------------------------------------------------- /internal/core/catalog/info.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "parm/internal/manifest" 8 | "parm/internal/parmutil" 9 | "strings" 10 | "time" 11 | 12 | "github.com/google/go-github/v74/github" 13 | ) 14 | 15 | type Info struct { 16 | Owner string 17 | Repo string 18 | Version string 19 | LastUpdated string 20 | *DownstreamInfo 21 | *UpstreamInfo 22 | } 23 | 24 | type DownstreamInfo struct { 25 | InstallPath string 26 | } 27 | 28 | type UpstreamInfo struct { 29 | Stars int 30 | License string 31 | Description string 32 | } 33 | 34 | func (info *Info) String() string { 35 | var out []string 36 | out = append(out, fmt.Sprintf("Owner: %s", info.Owner)) 37 | out = append(out, fmt.Sprintf("Repo: %s", info.Repo)) 38 | out = append(out, fmt.Sprintf("Version: %s", info.Version)) 39 | out = append(out, fmt.Sprintf("LastUpdated: %s", info.LastUpdated)) 40 | if info.DownstreamInfo != nil { 41 | out = append(out, info.DownstreamInfo.string()) 42 | } else if info.UpstreamInfo != nil { 43 | out = append(out, info.UpstreamInfo.string()) 44 | } 45 | return strings.Join(out, "\n") 46 | } 47 | 48 | func (info *DownstreamInfo) string() string { 49 | var out []string 50 | out = append(out, fmt.Sprintf("InstallPath: %s", info.InstallPath)) 51 | return strings.Join(out, "\n") 52 | } 53 | 54 | func (info *UpstreamInfo) string() string { 55 | var out []string 56 | out = append(out, fmt.Sprintf("Stars: %d", info.Stars)) 57 | out = append(out, fmt.Sprintf("License: %s", info.License)) 58 | out = append(out, fmt.Sprintf("Description: %s", info.Description)) 59 | return strings.Join(out, "\n") 60 | } 61 | 62 | func GetPackageInfo(ctx context.Context, client *github.RepositoriesService, owner, repo string, isUpstream bool) (Info, error) { 63 | info := Info{ 64 | Owner: owner, 65 | Repo: repo, 66 | } 67 | 68 | if isUpstream { 69 | gitRepo, _, err := client.Get(ctx, owner, repo) 70 | if err != nil { 71 | return info, err 72 | } 73 | rel, _, err := client.GetLatestRelease(ctx, owner, repo) 74 | if err != nil { 75 | return info, err 76 | } 77 | 78 | info.Version = rel.GetTagName() 79 | info.LastUpdated = rel.GetPublishedAt().Format(time.DateTime) 80 | 81 | upInfo := UpstreamInfo{ 82 | Stars: gitRepo.GetStargazersCount(), 83 | License: gitRepo.GetLicense().GetName(), 84 | Description: gitRepo.GetDescription(), 85 | } 86 | info.UpstreamInfo = &upInfo 87 | info.DownstreamInfo = nil 88 | return info, nil 89 | } 90 | 91 | pkgPath := parmutil.GetInstallDir(owner, repo) 92 | _, err := os.Stat(pkgPath) 93 | if err != nil { 94 | return info, fmt.Errorf(`couldn't access %s: 95 | %w or %s/%s doesn't exist. 96 | Try using the --get-upstream flag`, pkgPath, err, owner, repo) 97 | } 98 | man, err := manifest.Read(pkgPath) 99 | if err != nil { 100 | return info, err 101 | } 102 | info.Version = man.Version 103 | info.LastUpdated = man.LastUpdated 104 | 105 | downInfo := DownstreamInfo{ 106 | InstallPath: pkgPath, 107 | } 108 | info.DownstreamInfo = &downInfo 109 | info.UpstreamInfo = nil 110 | 111 | return info, nil 112 | } 113 | -------------------------------------------------------------------------------- /pkg/sysutil/file.go: -------------------------------------------------------------------------------- 1 | package sysutil 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/h2non/filetype" 11 | "github.com/h2non/filetype/types" 12 | ) 13 | 14 | func SafeJoin(root, name string) (string, error) { 15 | cleaned := filepath.Clean(name) 16 | target := filepath.Join(root, cleaned) 17 | 18 | root = filepath.Clean(root) + string(os.PathSeparator) 19 | if !strings.HasPrefix(target, root) { 20 | return "", fmt.Errorf("tar entry %q escapes extraction dir", name) 21 | } 22 | return target, nil 23 | } 24 | 25 | func GetParentDir(path string) (string, error) { 26 | abs := filepath.IsAbs(path) 27 | if !abs { 28 | return "", fmt.Errorf("filepath must be absolute") 29 | } 30 | 31 | return filepath.Dir(path), nil 32 | } 33 | 34 | // checks if a file is a binary executable, and then checks if it's even able to be run by the user. 35 | func IsValidBinaryExecutable(path string) (bool, error) { 36 | isBin, kind, err := IsBinaryExecutable(path) 37 | if err != nil { 38 | return false, err 39 | } 40 | if kind == nil || !isBin { 41 | return false, err 42 | } 43 | 44 | switch runtime.GOOS { 45 | case "windows": 46 | return kind.Extension == "exe", nil 47 | case "darwin": 48 | return kind.Extension == "macho", nil 49 | case "linux": 50 | return kind.Extension == "elf", nil 51 | } 52 | 53 | return false, nil 54 | } 55 | 56 | // uses magic numbers to determine if a file is a binary executable 57 | func IsBinaryExecutable(path string) (bool, *types.Type, error) { 58 | info, err := os.Stat(path) 59 | if err != nil { 60 | return false, nil, err 61 | } 62 | 63 | if info.IsDir() { 64 | return false, nil, nil 65 | } 66 | 67 | // precheck 68 | if runtime.GOOS == "windows" { 69 | // TODO: check for .msi files? 70 | if !strings.HasSuffix(strings.ToLower(info.Name()), ".exe") { 71 | return false, nil, nil 72 | } 73 | } else { // on unix system 74 | if info.Mode()&0111 == 0 { 75 | return false, nil, nil 76 | } 77 | } 78 | 79 | file, err := os.Open(path) 80 | if err != nil { 81 | return false, nil, err 82 | } 83 | defer file.Close() 84 | 85 | buf, err := os.ReadFile(path) 86 | if err != nil { 87 | return false, nil, err 88 | } 89 | 90 | kind, _ := filetype.Match(buf) 91 | if kind != types.Unknown { 92 | if kind.Extension == "elf" || 93 | kind.Extension == "exe" || 94 | kind.Extension == "macho" { 95 | return true, &kind, nil 96 | } 97 | } 98 | 99 | return false, nil, nil 100 | } 101 | 102 | func SymlinkBinToPath(binPath, destPath string) error { 103 | isBin, err := IsValidBinaryExecutable(binPath) 104 | if err != nil { 105 | return err 106 | } 107 | if !isBin { 108 | return fmt.Errorf("error: provided dir is not a binary") 109 | } 110 | 111 | if _, err := os.Lstat(destPath); err == nil { 112 | if err := os.Remove(destPath); err != nil { 113 | return fmt.Errorf("failed to remove existing symlink at %s\n%w", destPath, err) 114 | } 115 | } else if !os.IsNotExist(err) { 116 | return fmt.Errorf("failed to check destination path: \n%w", err) 117 | } 118 | 119 | if err := os.Symlink(binPath, destPath); err != nil { 120 | return err 121 | } 122 | 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct - Parm 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behaviour that contributes to a positive environment for our 15 | community include: 16 | 17 | * Demonstrating empathy and kindness toward other people 18 | * Being respectful of differing opinions, viewpoints, and experiences 19 | * Giving and gracefully accepting constructive feedback 20 | * Accepting responsibility and apologising to those affected by our mistakes, 21 | and learning from the experience 22 | * Focusing on what is best not just for us as individuals, but for the 23 | overall community 24 | 25 | Examples of unacceptable behaviour include: 26 | 27 | * The use of sexualised language or imagery, and sexual attention or advances 28 | * Trolling, insulting or derogatory comments, and personal or political attacks 29 | * Public or private harassment 30 | * Publishing others' private information, such as a physical or email 31 | address, without their explicit permission 32 | * Other conduct which could reasonably be considered inappropriate in a 33 | professional setting 34 | 35 | ## Our Responsibilities 36 | 37 | Project maintainers are responsible for clarifying and enforcing our standards of 38 | acceptable behaviour and will take appropriate and fair corrective action in 39 | response to any instances of unacceptable behaviour. 40 | 41 | Project maintainers have the right and responsibility to remove, edit, or reject 42 | comments, commits, code, wiki edits, issues, and other contributions that are 43 | not aligned to this Code of Conduct, or to ban 44 | temporarily or permanently any contributor for other behaviours that they deem 45 | inappropriate, threatening, offensive, or harmful. 46 | 47 | ## Scope 48 | 49 | This Code of Conduct applies within all community spaces, and also applies when 50 | an individual is officially representing the community in public spaces. 51 | Examples of representing our community include using an official e-mail address, 52 | posting via an official social media account, or acting as an appointed 53 | representative at an online or offline event. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behaviour may be 58 | reported to the community leaders responsible for enforcement at <>. 59 | All complaints will be reviewed and investigated promptly and fairly. 60 | 61 | All community leaders are obligated to respect the privacy and security of the 62 | reporter of any incident. 63 | 64 | ## Attribution 65 | 66 | This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org/), version 67 | [1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/code_of_conduct.md) and 68 | [2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/code_of_conduct.md), 69 | and was generated by [contributing.md](https://contributing.md/generator). 70 | -------------------------------------------------------------------------------- /internal/gh/requests.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/google/go-github/v74/github" 10 | ) 11 | 12 | func GetLatestPreRelease( 13 | ctx context.Context, 14 | client *github.RepositoriesService, 15 | owner, repo string, 16 | ) (*github.RepositoryRelease, error) { 17 | // WARNING: this doesn't always work, especially if the latest pre-release is not within the past 30 (?) releases, or if maintainer releases versions out of order 18 | rels, _, err := client.ListReleases(ctx, owner, repo, nil) 19 | if err != nil { 20 | return nil, fmt.Errorf("could not list releases for %s/%s: \n%w", owner, repo, err) 21 | } 22 | 23 | for _, rel := range rels { 24 | if rel.GetPrerelease() { 25 | return rel, nil 26 | } 27 | } 28 | 29 | return nil, nil 30 | } 31 | 32 | func validatePreRelease( 33 | ctx context.Context, 34 | client *github.RepositoriesService, 35 | owner, repo string, 36 | ) (bool, *github.RepositoryRelease, error) { 37 | rel, err := GetLatestPreRelease(ctx, client, owner, repo) 38 | 39 | if err != nil { 40 | return false, nil, err 41 | } 42 | 43 | if rel != nil { 44 | return true, rel, nil 45 | } 46 | 47 | return false, nil, nil 48 | } 49 | 50 | func validateRelease( 51 | ctx context.Context, 52 | client *github.RepositoriesService, 53 | owner, repo, releaseTag string) (bool, *github.RepositoryRelease, error) { 54 | 55 | repository, _, err := client.GetReleaseByTag(ctx, owner, repo, releaseTag) 56 | 57 | if err == nil { 58 | return true, repository, nil 59 | } 60 | 61 | var ghErr *github.ErrorResponse 62 | if errors.As(err, &ghErr) && ghErr.Response.StatusCode != http.StatusOK { 63 | // release does not exist 64 | return false, nil, nil 65 | } 66 | 67 | // error parsing release 68 | return false, nil, err 69 | } 70 | 71 | func ResolvePreRelease(ctx context.Context, client *github.RepositoriesService, owner, repo string) (*github.RepositoryRelease, error) { 72 | valid, rel, err := validatePreRelease(ctx, client, owner, repo) 73 | if err != nil { 74 | return nil, fmt.Errorf("err: cannot resolve pre-release on %s/%s: \n%w", owner, repo, err) 75 | } 76 | if !valid { 77 | return nil, fmt.Errorf("error: no valid pre-release found for %s/%s", owner, repo) 78 | } 79 | 80 | return rel, nil 81 | } 82 | 83 | // Retrieves a GitHub RepositoryRelease. If provided version string is nil, then return the latest stable release 84 | func ResolveReleaseByTag(ctx context.Context, client *github.RepositoriesService, owner, repo string, version *string) (*github.RepositoryRelease, error) { 85 | if version != nil { 86 | valid, rel, err := validateRelease(ctx, client, owner, repo, *version) 87 | if err != nil { 88 | return nil, fmt.Errorf("error: Cannot resolve release %s on %s/%s", *version, owner, repo) 89 | } 90 | if !valid { 91 | return nil, fmt.Errorf("error: Release %s not valid", *version) 92 | } 93 | return rel, nil 94 | } else { 95 | rel, _, err := client.GetLatestRelease(ctx, owner, repo) 96 | if err != nil { 97 | var ghErr *github.ErrorResponse 98 | if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { 99 | return nil, fmt.Errorf("error: no stable release found for %s/%s", owner, repo) 100 | } 101 | return nil, fmt.Errorf("error: could not fetch latest release: \n%w", err) 102 | } 103 | return rel, nil 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /internal/config/loader_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | func TestGetParmConfigDir(t *testing.T) { 12 | dir, err := GetParmConfigDir() 13 | if err != nil { 14 | t.Fatalf("GetParmConfigDir() error: %v", err) 15 | } 16 | 17 | if dir == "" { 18 | t.Error("GetParmConfigDir() returned empty string") 19 | } 20 | 21 | // Should end with "parm" 22 | if filepath.Base(dir) != "parm" { 23 | t.Errorf("GetParmConfigDir() = %v, expected to end with 'parm'", dir) 24 | } 25 | } 26 | 27 | func TestInit_CreatesConfigDir(t *testing.T) { 28 | // Save original config 29 | origConfigDir := os.Getenv("XDG_CONFIG_HOME") 30 | if origConfigDir == "" && os.Getenv("APPDATA") != "" { 31 | origConfigDir = os.Getenv("APPDATA") 32 | } 33 | 34 | // Use temporary directory for testing 35 | tmpDir := t.TempDir() 36 | 37 | // Set config home to temp dir 38 | os.Setenv("XDG_CONFIG_HOME", tmpDir) 39 | if origConfigDir != "" { 40 | defer os.Setenv("XDG_CONFIG_HOME", origConfigDir) 41 | } else { 42 | defer os.Unsetenv("XDG_CONFIG_HOME") 43 | } 44 | 45 | // Reset viper 46 | // v := viper.New() 47 | // viper.Reset() 48 | 49 | // Call Init 50 | err := Init() 51 | if err != nil { 52 | t.Fatalf("Init() error: %v", err) 53 | } 54 | 55 | // Verify config was set 56 | if Cfg.ParmPkgPath == "" { 57 | t.Error("Init() did not set ParmPkgPath") 58 | } 59 | 60 | if Cfg.ParmBinPath == "" { 61 | t.Error("Init() did not set ParmBinPath") 62 | } 63 | 64 | // Cleanup 65 | // v.Reset() 66 | } 67 | 68 | func TestInit_CreatesConfigFile(t *testing.T) { 69 | tmpDir := t.TempDir() 70 | 71 | // Set config home to temp dir 72 | origXDG := os.Getenv("XDG_CONFIG_HOME") 73 | os.Setenv("XDG_CONFIG_HOME", tmpDir) 74 | defer func() { 75 | if origXDG != "" { 76 | os.Setenv("XDG_CONFIG_HOME", origXDG) 77 | } else { 78 | os.Unsetenv("XDG_CONFIG_HOME") 79 | } 80 | }() 81 | 82 | viper.Reset() 83 | 84 | err := Init() 85 | if err != nil { 86 | t.Fatalf("Init() error: %v", err) 87 | } 88 | 89 | // Check if config file was created 90 | configPath := filepath.Join(tmpDir, "parm", "config.toml") 91 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 92 | t.Errorf("Init() did not create config file at %s", configPath) 93 | } 94 | } 95 | 96 | func TestSetEnvVars(t *testing.T) { 97 | v := viper.New() 98 | 99 | // Set a test token 100 | testToken := "test_token_123" 101 | os.Setenv("GITHUB_TOKEN", testToken) 102 | defer os.Unsetenv("GITHUB_TOKEN") 103 | 104 | setEnvVars(v) 105 | 106 | // Viper should be able to read it 107 | v.AutomaticEnv() 108 | token := v.GetString("github_api_token") 109 | 110 | // Note: Viper's env binding is complex, so this test might not work as expected 111 | // The important thing is that setEnvVars doesn't crash 112 | t.Logf("Token from env: %v", token) 113 | } 114 | 115 | func TestSetConfigDefaults(t *testing.T) { 116 | v := viper.New() 117 | 118 | err := setConfigDefaults(v) 119 | if err != nil { 120 | t.Fatalf("setConfigDefaults() error: %v", err) 121 | } 122 | 123 | // Check if defaults were set 124 | pkgPath := v.GetString("parm_pkg_path") 125 | if pkgPath == "" { 126 | t.Error("setConfigDefaults() did not set parm_pkg_path") 127 | } 128 | 129 | binPath := v.GetString("parm_bin_path") 130 | if binPath == "" { 131 | t.Error("setConfigDefaults() did not set parm_bin_path") 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /docs/roadmap.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | > [!IMPORTANT] 4 | > [Here](https://github.com/users/yhoundz/projects/2) is the GitHub Projects related to Parm. I will try to keep this file and the Projects board in sync with each other, but there are no promises regarding that. Assume if that item is *not* on the GitHub Projects, then it is not being worked on, or there are no plans to work on it. 5 | 6 | Parm is still in a very early state, and breaking changes are to be expected. Additionally, a lot of features may not be implemented yet, or working as expected. If you would like to propose a new feature, [create a new issue](https://github.com/yhoundz/parm/issues/new). 7 | 8 | Below are a list of planned features and improvements: 9 | 10 | ## Planned for Completion by v0.2.0 11 | 12 | ### Feature Improvements 13 | - Boostrapping: Allowing the user to update Parm itself without having to rerun the install script again and do it from within the CLI. 14 | - Switching release channels: Allow the user to switch between the "Release" and "Pre-release" channels, changing how the update command behaves. 15 | - Add verification levels with the --verify flag 16 | - Flags would then be --verify, --no-verify (in v0.1.0) and --sha256 17 | - level 0: No verification 18 | - level 1 (default): 19 | * Generated hash gets compared to upstream post-download (pre-extraction) 20 | - level 2: 21 | * User-provided hash gets compared to upstream pre-download 22 | * Generated hash gets compared to upstream post-download (pre-extraction) 23 | - level 3: 24 | * User-provided hash gets compared to upstream pre-download 25 | * Generated hash gets compared to upstream post-download (pre-extraction) 26 | * Computed hash of untarred/unzipped files gets compared to upstream post-extraction (this is moreso a file integrity check more than a security check) 27 | - Create .ps1 and .fish install scripts. 28 | 29 | ### General Improvements 30 | - Vetting/replacing AI-generated tests with better ones, more test coverage. 31 | - Create containerized tests via Docker. 32 | - Refactor CLI commands to be generated via a method, not statically 33 | - Logging to a file, both informational and error logging. 34 | - Replacing fmt.Println(), logging instead which will write to stdout and a file 35 | - Implement GraphQL (githubv4) support 36 | - Caching API calls or expensive operations (like listing installed packages) 37 | - Use golangci-lint in CI/CD pipeline 38 | 39 | ## Planned for Later Versions 40 | - Better version management: Entails being able to install multiple versions at once and switching between them easily. 41 | - Search feature, allowing users to search for repositories through parm via the GraphQL API. 42 | - Shell autocompletion (for --asset flag, uninstalling packages, updating packages) 43 | - Parse binaries for dependenices myself without using `objdump` or `otool -L`. 44 | - The current solution is to parse the output of `objdump` and `otool -L`, but their outputs are designed to be human-readable and not machine-readable. Implementing this would mitigate that. 45 | - Better dependency resolution; implement an algorithm mimicking the linker's shared library searching algorithm to only find dependencies that are NOT currently on the user's system. 46 | - Allow users to be able to choose which asset to release if a direct match isn't found. 47 | - "doctor" command to verify everything works as intended. 48 | 49 | ## To be Determined 50 | - Add migrate command if user changes bin or install dir in config. 51 | - Resolve potential collisions between installed repos and symlinked binaries if two "owners" have packages with the same name. 52 | - Parsing different kinds of binary files (not just ELF/Macho/PE) 53 | -------------------------------------------------------------------------------- /internal/core/installer/installer.go: -------------------------------------------------------------------------------- 1 | package installer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "parm/internal/core/uninstaller" 10 | "parm/internal/gh" 11 | "parm/internal/manifest" 12 | "parm/pkg/progress" 13 | "path/filepath" 14 | 15 | "github.com/Masterminds/semver/v3" 16 | "github.com/google/go-github/v74/github" 17 | ) 18 | 19 | type Installer struct { 20 | client *github.RepositoriesService 21 | } 22 | 23 | type InstallFlags struct { 24 | Type manifest.InstallType 25 | Version *string 26 | Asset *string 27 | Strict bool 28 | VerifyLevel uint8 29 | } 30 | 31 | type InstallResult struct { 32 | InstallPath string 33 | Version string 34 | } 35 | 36 | func New(cli *github.RepositoriesService) *Installer { 37 | return &Installer{ 38 | client: cli, 39 | } 40 | } 41 | 42 | func (in *Installer) Install(ctx context.Context, owner, repo string, installPath string, opts InstallFlags, hooks *progress.Hooks) (*InstallResult, error) { 43 | f, _ := os.Stat(installPath) 44 | var err error 45 | 46 | // if error is something else, ignore it for now and hope it propogates downwards if it's actually serious 47 | if f != nil { 48 | if err := uninstaller.Uninstall(ctx, owner, repo); err != nil { 49 | return nil, err 50 | } 51 | } 52 | 53 | var rel *github.RepositoryRelease 54 | if opts.Type == manifest.PreRelease { 55 | rel, _ = gh.ResolvePreRelease(ctx, in.client, owner, repo) 56 | if !opts.Strict { 57 | // expensive! 58 | relStable, err := gh.ResolveReleaseByTag(ctx, in.client, owner, repo, nil) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | // TODO: abstract elsewhere cuz it's similar to updater.NeedsUpdate 64 | currVer, _ := semver.NewVersion(rel.GetTagName()) 65 | newVer, _ := semver.NewVersion(relStable.GetTagName()) 66 | if newVer.GreaterThan(currVer) { 67 | rel = relStable 68 | } 69 | } 70 | } else { 71 | rel, err = gh.ResolveReleaseByTag(ctx, in.client, owner, repo, opts.Version) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | // we get to this point if the user installs a pre-release using the --release flag 77 | // e.g. if the user runs "parm install yhoundz/parm-e2e --release v1.0.1-beta" 78 | // correct the release channel to use pre-release instead to match user intent 79 | if rel.GetPrerelease() { 80 | opts.Type = manifest.PreRelease 81 | } 82 | } 83 | 84 | return in.installFromRelease(ctx, installPath, owner, repo, rel, opts, hooks) 85 | } 86 | 87 | func downloadTo(ctx context.Context, destPath, url string, hooks *progress.Hooks) error { 88 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | resp, err := http.DefaultClient.Do(req) 94 | if err != nil { 95 | return err 96 | } 97 | defer resp.Body.Close() 98 | 99 | if resp.StatusCode != http.StatusOK { 100 | return fmt.Errorf("GET %s: %s", url, resp.Status) 101 | } 102 | 103 | err = os.MkdirAll(filepath.Dir(destPath), 0o755) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | file, err := os.Create(destPath) 109 | if err != nil { 110 | return err 111 | } 112 | defer file.Close() 113 | 114 | if hooks == nil { 115 | _, err = io.Copy(file, resp.Body) 116 | return err 117 | } 118 | 119 | var r io.Reader = resp.Body 120 | var closer io.Closer 121 | 122 | // maybe move hooks out of here? 123 | if hooks.Decorator != nil { 124 | wr := hooks.Decorator(progress.StageDownload, resp.Body, resp.ContentLength) 125 | if rc, ok := wr.(io.ReadCloser); ok { 126 | r, closer = rc, rc 127 | } else { 128 | r = wr 129 | } 130 | } 131 | 132 | _, err = io.Copy(file, r) 133 | if closer != nil { 134 | _ = closer.Close() 135 | } 136 | 137 | return err 138 | } 139 | -------------------------------------------------------------------------------- /cmd/update/update.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 Alexander Wang 3 | */ 4 | package update 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "parm/internal/cmdutil" 10 | "parm/internal/core/catalog" 11 | "parm/internal/core/installer" 12 | "parm/internal/core/updater" 13 | "parm/internal/gh" 14 | "parm/internal/manifest" 15 | "parm/internal/parmutil" 16 | "parm/pkg/cmdparser" 17 | "parm/pkg/sysutil" 18 | 19 | "github.com/spf13/cobra" 20 | "github.com/spf13/viper" 21 | ) 22 | 23 | func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { 24 | type argsKey struct{} 25 | var strict bool 26 | var aKey argsKey 27 | 28 | // updateCmd represents the update command 29 | var updateCmd = &cobra.Command{ 30 | Use: "update /", 31 | Short: "Updates a package", 32 | Long: `Updates a package to the latest available version.`, 33 | PreRun: func(cmd *cobra.Command, args []string) { 34 | var normArgs []string 35 | 36 | if len(args) == 0 { 37 | mans, err := catalog.GetAllPkgManifest() 38 | if err != nil { 39 | fmt.Println("failed to retrieve packages") 40 | return 41 | } 42 | if len(mans) == 0 { 43 | fmt.Println("no packages to update") 44 | return 45 | } 46 | 47 | var newArgs = make([]string, len(mans)) 48 | for i, man := range mans { 49 | pair := fmt.Sprintf("%s/%s", man.Owner, man.Repo) 50 | newArgs[i] = pair 51 | } 52 | normArgs = newArgs 53 | } else { 54 | ignored := make(map[string]bool) 55 | 56 | // remove duplicates and incorrectly formatted or nonexistent packages 57 | for _, arg := range args { 58 | owner, repo, err := cmdparser.ParseRepoRef(arg) 59 | if err != nil { 60 | owner, repo, err = cmdparser.ParseGithubUrlPattern(arg) 61 | if err != nil { 62 | ignored[arg] = true 63 | fmt.Printf("error: package %s not found, skipping...\n", arg) 64 | continue 65 | } 66 | } 67 | if _, ok := ignored[arg]; ok { 68 | // already updated package 69 | continue 70 | } 71 | parsed := fmt.Sprintf("%s/%s", owner, repo) 72 | normArgs = append(normArgs, parsed) 73 | ignored[arg] = true 74 | } 75 | } 76 | ctx := context.WithValue(cmd.Context(), aKey, normArgs) 77 | cmd.SetContext(ctx) 78 | }, 79 | RunE: func(cmd *cobra.Command, _ []string) error { 80 | ctx := cmd.Context() 81 | args, _ := ctx.Value(aKey).([]string) 82 | 83 | token, err := gh.GetStoredApiKey(viper.GetViper()) 84 | if err != nil { 85 | fmt.Printf("%s\ncontinuing without api key.\n", err) 86 | } 87 | client := f.Provider(ctx, token).Repos() 88 | inst := installer.New(client) 89 | up := updater.New(client, inst) 90 | flags := updater.UpdateFlags{ 91 | Strict: strict, 92 | } 93 | 94 | for _, pkg := range args { 95 | // guaranteed to work now 96 | owner, repo, _ := cmdparser.ParseRepoRef(pkg) 97 | 98 | installPath := parmutil.GetInstallDir(owner, repo) 99 | parentDir, _ := sysutil.GetParentDir(installPath) 100 | 101 | res, err := up.Update(ctx, owner, repo, installPath, &flags, nil) 102 | if err != nil { 103 | _ = parmutil.Cleanup(parentDir) 104 | fmt.Printf("error: failed to update %s/%s:\n\t%q \n", owner, repo, err) 105 | continue 106 | } 107 | 108 | // Write new manifest 109 | old := res.OldManifest 110 | man, err := manifest.New(owner, repo, res.Version, old.InstallType, res.InstallPath) 111 | if err != nil { 112 | return fmt.Errorf("error: failed to create manifest: \n%w", err) 113 | } 114 | err = man.Write(res.InstallPath) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | // Symlinked executables to PATH 120 | binPaths := man.GetFullExecPaths() 121 | for _, execPath := range binPaths { 122 | pathToSymLinkTo := parmutil.GetBinDir(man.Repo) 123 | 124 | // TODO: use shims for windows instead? 125 | err = sysutil.SymlinkBinToPath(execPath, pathToSymLinkTo) 126 | if err != nil { 127 | fmt.Println("error: could not symlink binary to PATH") 128 | continue 129 | } 130 | } 131 | fmt.Printf("* Updated %s/%s: %s -> %s.\n", owner, repo, old.Version, res.Version) 132 | } 133 | return nil 134 | }, 135 | } 136 | 137 | updateCmd.Flags().BoolVarP(&strict, "strict", "s", false, "Only available on pre-release channels. Will only install pre-release versions and not stable releases, even if there exists a stable version more up-to-date than a pre-release.") 138 | 139 | return updateCmd 140 | } 141 | -------------------------------------------------------------------------------- /pkg/deps/deps_test.go: -------------------------------------------------------------------------------- 1 | package deps 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "testing" 9 | ) 10 | 11 | func TestHasExternalDep(t *testing.T) { 12 | // Test for a common command that should exist 13 | commonCmd := "go" 14 | if runtime.GOOS == "windows" { 15 | commonCmd = "cmd" 16 | } 17 | 18 | err := HasExternalDep(commonCmd) 19 | if err != nil { 20 | t.Errorf("HasExternalDep(%s) failed: %v", commonCmd, err) 21 | } 22 | 23 | // Test for non-existent command 24 | err = HasExternalDep("this-command-definitely-does-not-exist-12345") 25 | if err == nil { 26 | t.Error("HasExternalDep() should return error for non-existent command") 27 | } 28 | } 29 | 30 | func TestGetMissingLibs_NonExistentFile(t *testing.T) { 31 | ctx := context.Background() 32 | 33 | _, err := GetMissingLibs(ctx, "/nonexistent/binary") 34 | if err == nil { 35 | t.Error("GetMissingLibs() should return error for non-existent file") 36 | } 37 | } 38 | 39 | func TestGetMissingLibs_TextFile(t *testing.T) { 40 | ctx := context.Background() 41 | tmpDir := t.TempDir() 42 | 43 | // Create a text file 44 | textFile := filepath.Join(tmpDir, "text.txt") 45 | os.WriteFile(textFile, []byte("not a binary"), 0644) 46 | 47 | libs, err := GetMissingLibs(ctx, textFile) 48 | if err != nil { 49 | t.Logf("Expected error for text file: %v", err) 50 | } 51 | 52 | // Should return empty or error 53 | if len(libs) > 0 { 54 | t.Error("GetMissingLibs() returned libraries for text file") 55 | } 56 | } 57 | 58 | func TestGetBinDeps_InvalidBinary(t *testing.T) { 59 | tmpDir := t.TempDir() 60 | 61 | // Create invalid binary file 62 | invalidBin := filepath.Join(tmpDir, "invalid") 63 | os.WriteFile(invalidBin, []byte("not a binary"), 0755) 64 | 65 | _, err := GetBinDeps(invalidBin) 66 | if err == nil { 67 | t.Error("GetBinDeps() should return error for invalid binary") 68 | } 69 | } 70 | 71 | func TestGetBinDeps_NonExistent(t *testing.T) { 72 | _, err := GetBinDeps("/nonexistent/binary") 73 | if err == nil { 74 | t.Error("GetBinDeps() should return error for non-existent file") 75 | } 76 | } 77 | 78 | func TestHasSharedLib_CommonLibs(t *testing.T) { 79 | if runtime.GOOS == "windows" { 80 | t.Skip("Skipping shared library test on Windows") 81 | } 82 | 83 | // Test for a common library that should exist on most systems 84 | commonLib := "libc.so.6" 85 | if runtime.GOOS == "darwin" { 86 | commonLib = "libSystem.B.dylib" 87 | } 88 | 89 | hasLib, err := hasSharedLib(commonLib) 90 | if err != nil { 91 | t.Logf("Error checking for %s: %v", commonLib, err) 92 | } 93 | 94 | // Note: This test is environment-dependent 95 | t.Logf("Has %s: %v", commonLib, hasLib) 96 | } 97 | 98 | func TestHasSharedLib_NonExistent(t *testing.T) { 99 | if runtime.GOOS == "windows" { 100 | t.Skip("Skipping shared library test on Windows") 101 | } 102 | 103 | hasLib, err := hasSharedLib("libnonexistent12345.so") 104 | if err != nil { 105 | t.Logf("Error (expected): %v", err) 106 | } 107 | 108 | if hasLib { 109 | t.Error("hasSharedLib() returned true for non-existent library") 110 | } 111 | } 112 | 113 | func TestGetMissingLibsFallBack(t *testing.T) { 114 | tmpDir := t.TempDir() 115 | 116 | // Create invalid binary 117 | invalidBin := filepath.Join(tmpDir, "invalid") 118 | os.WriteFile(invalidBin, []byte("not a binary"), 0755) 119 | 120 | _, err := getMissingLibsFallBack(invalidBin) 121 | if err == nil { 122 | t.Log("getMissingLibsFallBack() returned no error (acceptable)") 123 | } 124 | } 125 | 126 | func TestGetMissingLibsLinux(t *testing.T) { 127 | if runtime.GOOS != "linux" { 128 | t.Skip("Skipping Linux-specific test") 129 | } 130 | 131 | ctx := context.Background() 132 | tmpDir := t.TempDir() 133 | 134 | // Create a dummy file 135 | dummyBin := filepath.Join(tmpDir, "dummy") 136 | os.WriteFile(dummyBin, []byte("dummy"), 0755) 137 | 138 | // This will likely fail or return empty, but shouldn't crash 139 | _, err := getMissingLibsLinux(ctx, dummyBin) 140 | if err != nil { 141 | t.Logf("Expected error or empty result: %v", err) 142 | } 143 | } 144 | 145 | func TestGetMissingLibsDarwin(t *testing.T) { 146 | if runtime.GOOS != "darwin" { 147 | t.Skip("Skipping macOS-specific test") 148 | } 149 | 150 | ctx := context.Background() 151 | tmpDir := t.TempDir() 152 | 153 | // Create a dummy file 154 | dummyBin := filepath.Join(tmpDir, "dummy") 155 | os.WriteFile(dummyBin, []byte("dummy"), 0755) 156 | 157 | // This will likely fail or return empty, but shouldn't crash 158 | _, err := getMissingLibsDarwin(ctx, dummyBin) 159 | if err != nil { 160 | t.Logf("Expected error or empty result: %v", err) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /internal/core/verify/verify_test.go: -------------------------------------------------------------------------------- 1 | package verify 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func TestGetSha256(t *testing.T) { 12 | tmpDir := t.TempDir() 13 | 14 | // Create test file with known content 15 | testFile := filepath.Join(tmpDir, "test.txt") 16 | content := []byte("test content") 17 | os.WriteFile(testFile, content, 0644) 18 | 19 | // Calculate expected hash 20 | h := sha256.New() 21 | h.Write(content) 22 | expected := fmt.Sprintf("%x", h.Sum(nil)) 23 | 24 | // Get hash using function 25 | hash, err := GetSha256(testFile) 26 | if err != nil { 27 | t.Fatalf("GetSha256() error: %v", err) 28 | } 29 | 30 | if hash != expected { 31 | t.Errorf("GetSha256() = %v, want %v", hash, expected) 32 | } 33 | } 34 | 35 | func TestGetSha256_NonExistent(t *testing.T) { 36 | _, err := GetSha256("/nonexistent/file") 37 | if err == nil { 38 | t.Error("GetSha256() should return error for non-existent file") 39 | } 40 | } 41 | 42 | func TestGetSha256_EmptyFile(t *testing.T) { 43 | tmpDir := t.TempDir() 44 | 45 | emptyFile := filepath.Join(tmpDir, "empty.txt") 46 | os.WriteFile(emptyFile, []byte{}, 0644) 47 | 48 | hash, err := GetSha256(emptyFile) 49 | if err != nil { 50 | t.Fatalf("GetSha256() error: %v", err) 51 | } 52 | 53 | // SHA256 of empty file 54 | expected := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 55 | if hash != expected { 56 | t.Errorf("GetSha256() = %v, want %v", hash, expected) 57 | } 58 | } 59 | 60 | func TestVerifyLevel1_MatchingChecksum(t *testing.T) { 61 | tmpDir := t.TempDir() 62 | 63 | // Create test file 64 | testFile := filepath.Join(tmpDir, "test.txt") 65 | content := []byte("test content") 66 | os.WriteFile(testFile, content, 0644) 67 | 68 | // Calculate hash 69 | h := sha256.New() 70 | h.Write(content) 71 | expectedHash := fmt.Sprintf("sha256:%x", h.Sum(nil)) 72 | 73 | // Verify 74 | ok, generatedHash, err := VerifyLevel1(testFile, expectedHash) 75 | if err != nil { 76 | t.Fatalf("VerifyLevel1() error: %v", err) 77 | } 78 | 79 | if !ok { 80 | t.Error("VerifyLevel1() returned false for matching checksum") 81 | } 82 | 83 | if generatedHash == nil { 84 | t.Error("VerifyLevel1() returned nil generated hash") 85 | } else if *generatedHash != expectedHash { 86 | t.Errorf("Generated hash = %v, want %v", *generatedHash, expectedHash) 87 | } 88 | } 89 | 90 | func TestVerifyLevel1_MismatchedChecksum(t *testing.T) { 91 | tmpDir := t.TempDir() 92 | 93 | // Create test file 94 | testFile := filepath.Join(tmpDir, "test.txt") 95 | content := []byte("test content") 96 | os.WriteFile(testFile, content, 0644) 97 | 98 | // Use wrong hash 99 | wrongHash := "sha256:0000000000000000000000000000000000000000000000000000000000000000" 100 | 101 | // Verify 102 | ok, _, err := VerifyLevel1(testFile, wrongHash) 103 | if err != nil { 104 | t.Fatalf("VerifyLevel1() error: %v", err) 105 | } 106 | 107 | if ok { 108 | t.Error("VerifyLevel1() returned true for mismatched checksum") 109 | } 110 | } 111 | 112 | func TestVerifyLevel1_NonExistentFile(t *testing.T) { 113 | _, _, err := VerifyLevel1("/nonexistent/file", "sha256:abc123") 114 | if err == nil { 115 | t.Error("VerifyLevel1() should return error for non-existent file") 116 | } 117 | } 118 | 119 | func TestVerifyLevel1_KnownHash(t *testing.T) { 120 | tmpDir := t.TempDir() 121 | 122 | // Create file with known content 123 | testFile := filepath.Join(tmpDir, "hello.txt") 124 | os.WriteFile(testFile, []byte("hello world"), 0644) 125 | 126 | // Known SHA256 hash of "hello world" 127 | knownHash := "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" 128 | 129 | ok, _, err := VerifyLevel1(testFile, knownHash) 130 | if err != nil { 131 | t.Fatalf("VerifyLevel1() error: %v", err) 132 | } 133 | 134 | if !ok { 135 | t.Error("VerifyLevel1() returned false for known correct hash") 136 | } 137 | } 138 | 139 | func TestVerifyLevel1_DifferentContent(t *testing.T) { 140 | tmpDir := t.TempDir() 141 | 142 | // Create two files with different content 143 | file1 := filepath.Join(tmpDir, "file1.txt") 144 | file2 := filepath.Join(tmpDir, "file2.txt") 145 | os.WriteFile(file1, []byte("content1"), 0644) 146 | os.WriteFile(file2, []byte("content2"), 0644) 147 | 148 | // Get hash of file1 149 | hash1, _ := GetSha256(file1) 150 | upstreamHash := fmt.Sprintf("sha256:%s", hash1) 151 | 152 | // Verify file2 with file1's hash (should fail) 153 | ok, _, err := VerifyLevel1(file2, upstreamHash) 154 | if err != nil { 155 | t.Fatalf("VerifyLevel1() error: %v", err) 156 | } 157 | 158 | if ok { 159 | t.Error("VerifyLevel1() returned true for different content") 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /internal/core/catalog/info_test.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "parm/internal/config" 10 | "parm/internal/manifest" 11 | 12 | "github.com/google/go-github/v74/github" 13 | "github.com/migueleliasweb/go-github-mock/src/mock" 14 | ) 15 | 16 | func TestGetPackageInfo_Downstream(t *testing.T) { 17 | tmpDir := t.TempDir() 18 | config.Cfg.ParmPkgPath = tmpDir 19 | 20 | // Create installed package 21 | pkgDir := filepath.Join(tmpDir, "owner", "repo") 22 | os.MkdirAll(pkgDir, 0755) 23 | 24 | m := &manifest.Manifest{ 25 | Owner: "owner", 26 | Repo: "repo", 27 | Version: "v1.0.0", 28 | InstallType: manifest.Release, 29 | Executables: []string{"bin/app"}, 30 | LastUpdated: "2025-01-01 12:00:00", 31 | } 32 | m.Write(pkgDir) 33 | 34 | ctx := context.Background() 35 | info, err := GetPackageInfo(ctx, nil, "owner", "repo", false) 36 | if err != nil { 37 | t.Fatalf("GetPackageInfo() error: %v", err) 38 | } 39 | 40 | if info.Owner != "owner" { 41 | t.Errorf("Owner = %v, want owner", info.Owner) 42 | } 43 | 44 | if info.Repo != "repo" { 45 | t.Errorf("Repo = %v, want repo", info.Repo) 46 | } 47 | 48 | if info.Version != "v1.0.0" { 49 | t.Errorf("Version = %v, want v1.0.0", info.Version) 50 | } 51 | 52 | if info.DownstreamInfo == nil { 53 | t.Error("DownstreamInfo should not be nil") 54 | } 55 | 56 | if info.UpstreamInfo != nil { 57 | t.Error("UpstreamInfo should be nil for downstream") 58 | } 59 | } 60 | 61 | func TestGetPackageInfo_Upstream(t *testing.T) { 62 | // Create mock GitHub client 63 | mockedHTTPClient := mock.NewMockedHTTPClient( 64 | mock.WithRequestMatch( 65 | mock.GetReposByOwnerByRepo, 66 | &github.Repository{ 67 | Name: github.Ptr("repo"), 68 | Owner: &github.User{Login: github.Ptr("owner")}, 69 | StargazersCount: github.Ptr(100), 70 | License: &github.License{Name: github.Ptr("MIT")}, 71 | Description: github.Ptr("Test repository"), 72 | }, 73 | ), 74 | mock.WithRequestMatch( 75 | mock.GetReposReleasesLatestByOwnerByRepo, 76 | &github.RepositoryRelease{ 77 | TagName: github.Ptr("v1.0.0"), 78 | PublishedAt: &github.Timestamp{}, 79 | }, 80 | ), 81 | ) 82 | 83 | client := github.NewClient(mockedHTTPClient) 84 | ctx := context.Background() 85 | 86 | info, err := GetPackageInfo(ctx, client.Repositories, "owner", "repo", true) 87 | if err != nil { 88 | t.Fatalf("GetPackageInfo() error: %v", err) 89 | } 90 | 91 | if info.Owner != "owner" { 92 | t.Errorf("Owner = %v, want owner", info.Owner) 93 | } 94 | 95 | if info.Repo != "repo" { 96 | t.Errorf("Repo = %v, want repo", info.Repo) 97 | } 98 | 99 | if info.UpstreamInfo == nil { 100 | t.Fatal("UpstreamInfo should not be nil") 101 | } 102 | 103 | if info.Stars != 100 { 104 | t.Errorf("Stars = %v, want 100", info.Stars) 105 | } 106 | 107 | if info.License != "MIT" { 108 | t.Errorf("License = %v, want MIT", info.License) 109 | } 110 | 111 | if info.DownstreamInfo != nil { 112 | t.Error("DownstreamInfo should be nil for upstream") 113 | } 114 | } 115 | 116 | func TestGetPackageInfo_DownstreamNotInstalled(t *testing.T) { 117 | tmpDir := t.TempDir() 118 | config.Cfg.ParmPkgPath = tmpDir 119 | 120 | ctx := context.Background() 121 | _, err := GetPackageInfo(ctx, nil, "owner", "nonexistent", false) 122 | if err == nil { 123 | t.Error("GetPackageInfo() should return error for non-existent package") 124 | } 125 | } 126 | 127 | func TestInfo_String(t *testing.T) { 128 | info := Info{ 129 | Owner: "owner", 130 | Repo: "repo", 131 | Version: "v1.0.0", 132 | LastUpdated: "2025-01-01 12:00:00", 133 | DownstreamInfo: &DownstreamInfo{ 134 | InstallPath: "/path/to/install", 135 | }, 136 | } 137 | 138 | str := info.String() 139 | 140 | if str == "" { 141 | t.Error("String() returned empty string") 142 | } 143 | 144 | // Should contain key information 145 | if !anySubstring(str, "owner") { 146 | t.Error("String() should contain owner") 147 | } 148 | 149 | if !anySubstring(str, "repo") { 150 | t.Error("String() should contain repo") 151 | } 152 | 153 | if !anySubstring(str, "v1.0.0") { 154 | t.Error("String() should contain version") 155 | } 156 | } 157 | 158 | func TestDownstreamInfo_String(t *testing.T) { 159 | info := &DownstreamInfo{ 160 | InstallPath: "/path/to/install", 161 | } 162 | 163 | str := info.string() 164 | 165 | if str == "" { 166 | t.Error("string() returned empty string") 167 | } 168 | 169 | if !anySubstring(str, "/path/to/install") { 170 | t.Error("string() should contain install path") 171 | } 172 | } 173 | 174 | func TestUpstreamInfo_String(t *testing.T) { 175 | info := &UpstreamInfo{ 176 | Stars: 100, 177 | License: "MIT", 178 | Description: "Test description", 179 | } 180 | 181 | str := info.string() 182 | 183 | if str == "" { 184 | t.Error("string() returned empty string") 185 | } 186 | 187 | if !anySubstring(str, "100") { 188 | t.Error("string() should contain stars count") 189 | } 190 | 191 | if !anySubstring(str, "MIT") { 192 | t.Error("string() should contain license") 193 | } 194 | 195 | if !anySubstring(str, "Test description") { 196 | t.Error("string() should contain description") 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /internal/core/uninstaller/uninstaller_integration_test.go: -------------------------------------------------------------------------------- 1 | package uninstaller 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "parm/internal/config" 10 | "parm/internal/manifest" 11 | ) 12 | 13 | func TestUninstall_Success(t *testing.T) { 14 | tmpDir := t.TempDir() 15 | config.Cfg.ParmPkgPath = tmpDir 16 | 17 | // Create installed package structure 18 | pkgDir := filepath.Join(tmpDir, "owner", "repo") 19 | os.MkdirAll(pkgDir, 0755) 20 | 21 | // Create manifest 22 | m := &manifest.Manifest{ 23 | Owner: "owner", 24 | Repo: "repo", 25 | Version: "v1.0.0", 26 | InstallType: manifest.Release, 27 | Executables: []string{}, 28 | LastUpdated: "2025-01-01 12:00:00", 29 | } 30 | m.Write(pkgDir) 31 | 32 | // Create a file in the package 33 | testFile := filepath.Join(pkgDir, "test.txt") 34 | os.WriteFile(testFile, []byte("test"), 0644) 35 | 36 | ctx := context.Background() 37 | err := Uninstall(ctx, "owner", "repo") 38 | if err != nil { 39 | t.Fatalf("Uninstall() error: %v", err) 40 | } 41 | 42 | // Verify package directory was removed 43 | if _, err := os.Stat(pkgDir); !os.IsNotExist(err) { 44 | t.Error("Package directory still exists after uninstall") 45 | } 46 | } 47 | 48 | func TestUninstall_NonExistent(t *testing.T) { 49 | tmpDir := t.TempDir() 50 | config.Cfg.ParmPkgPath = tmpDir 51 | 52 | ctx := context.Background() 53 | err := Uninstall(ctx, "owner", "nonexistent") 54 | if err == nil { 55 | t.Error("Uninstall() should return error for non-existent package") 56 | } 57 | } 58 | 59 | func TestUninstall_NoManifest(t *testing.T) { 60 | tmpDir := t.TempDir() 61 | config.Cfg.ParmPkgPath = tmpDir 62 | 63 | // Create directory without manifest 64 | pkgDir := filepath.Join(tmpDir, "owner", "repo") 65 | os.MkdirAll(pkgDir, 0755) 66 | 67 | ctx := context.Background() 68 | err := Uninstall(ctx, "owner", "repo") 69 | if err == nil { 70 | t.Error("Uninstall() should return error when manifest is missing") 71 | } 72 | } 73 | 74 | func TestUninstall_WithExecutables(t *testing.T) { 75 | tmpDir := t.TempDir() 76 | config.Cfg.ParmPkgPath = tmpDir 77 | 78 | // Create installed package 79 | pkgDir := filepath.Join(tmpDir, "owner", "repo") 80 | binDir := filepath.Join(pkgDir, "bin") 81 | os.MkdirAll(binDir, 0755) 82 | 83 | // Create fake executable 84 | binPath := filepath.Join(binDir, "testbin") 85 | os.WriteFile(binPath, []byte("fake binary"), 0755) 86 | 87 | // Create manifest with executable 88 | m := &manifest.Manifest{ 89 | Owner: "owner", 90 | Repo: "repo", 91 | Version: "v1.0.0", 92 | InstallType: manifest.Release, 93 | Executables: []string{"bin/testbin"}, 94 | LastUpdated: "2025-01-01 12:00:00", 95 | } 96 | m.Write(pkgDir) 97 | 98 | ctx := context.Background() 99 | err := Uninstall(ctx, "owner", "repo") 100 | if err != nil { 101 | t.Fatalf("Uninstall() error: %v", err) 102 | } 103 | 104 | // Verify removal 105 | if _, err := os.Stat(pkgDir); !os.IsNotExist(err) { 106 | t.Error("Package directory still exists after uninstall") 107 | } 108 | } 109 | 110 | func TestRemovePkgSymlinks_Success(t *testing.T) { 111 | t.Skip("TODO: Symlink removal logic change, test must be rewritten") 112 | tmpDir := t.TempDir() 113 | config.Cfg.ParmBinPath = tmpDir 114 | 115 | // Create symlink 116 | linkPath := filepath.Join(tmpDir, "repo") 117 | targetPath := "/some/target" 118 | 119 | // On Windows, this might require admin privileges, so skip if it fails 120 | err := os.Symlink(targetPath, linkPath) 121 | if err != nil { 122 | t.Skipf("Cannot create symlink: %v", err) 123 | } 124 | 125 | ctx := context.Background() 126 | err = RemovePkgSymlinks(ctx, "owner", "repo") 127 | if err != nil { 128 | t.Logf("RemovePkgSymlinks() error: %v", err) 129 | } 130 | 131 | // Verify symlink was removed 132 | if _, err := os.Lstat(linkPath); !os.IsNotExist(err) { 133 | t.Error("Symlink still exists after RemovePkgSymlinks") 134 | } 135 | } 136 | 137 | func TestRemovePkgSymlinks_NonExistent(t *testing.T) { 138 | tmpDir := t.TempDir() 139 | config.Cfg.ParmBinPath = tmpDir 140 | 141 | ctx := context.Background() 142 | err := RemovePkgSymlinks(ctx, "owner", "nonexistent") 143 | // Should not error on non-existent symlink 144 | if err != nil { 145 | t.Logf("RemovePkgSymlinks() returned error (acceptable): %v", err) 146 | } 147 | } 148 | 149 | func TestUninstall_CleansUpParentDir(t *testing.T) { 150 | tmpDir := t.TempDir() 151 | config.Cfg.ParmPkgPath = tmpDir 152 | 153 | // Create installed package 154 | pkgDir := filepath.Join(tmpDir, "owner", "repo") 155 | os.MkdirAll(pkgDir, 0755) 156 | 157 | // Create manifest 158 | m := &manifest.Manifest{ 159 | Owner: "owner", 160 | Repo: "repo", 161 | Version: "v1.0.0", 162 | InstallType: manifest.Release, 163 | Executables: []string{}, 164 | LastUpdated: "2025-01-01 12:00:00", 165 | } 166 | m.Write(pkgDir) 167 | 168 | ctx := context.Background() 169 | err := Uninstall(ctx, "owner", "repo") 170 | if err != nil { 171 | t.Fatalf("Uninstall() error: %v", err) 172 | } 173 | 174 | // Parent directory (owner) should be removed if empty 175 | ownerDir := filepath.Join(tmpDir, "owner") 176 | entries, err := os.ReadDir(ownerDir) 177 | if err == nil && len(entries) == 0 { 178 | t.Log("Parent directory is empty (will be cleaned up)") 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /pkg/deps/deps.go: -------------------------------------------------------------------------------- 1 | package deps 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "debug/elf" 8 | "debug/macho" 9 | "debug/pe" 10 | "fmt" 11 | "io" 12 | "os" 13 | "os/exec" 14 | "path/filepath" 15 | "regexp" 16 | "runtime" 17 | "strings" 18 | ) 19 | 20 | type binFile interface { 21 | ImportedLibraries() ([]string, error) 22 | io.Closer 23 | } 24 | 25 | func HasExternalDep(dep string) error { 26 | if _, err := exec.LookPath(dep); err != nil { 27 | return fmt.Errorf("error: dependency '%q' not found in PATH", dep) 28 | } 29 | return nil 30 | } 31 | 32 | func GetMissingLibs(ctx context.Context, binPath string) ([]string, error) { 33 | if _, err := os.Stat(binPath); err != nil { 34 | return nil, err 35 | } 36 | 37 | switch runtime.GOOS { 38 | case "windows": 39 | return nil, nil 40 | case "linux": 41 | return getMissingLibsLinux(ctx, binPath) 42 | case "darwin": 43 | return getMissingLibsDarwin(ctx, binPath) 44 | default: 45 | return getMissingLibsFallBack(binPath) 46 | } 47 | } 48 | 49 | // uses objdump to find dynamically linked libs 50 | func getMissingLibsLinux(ctx context.Context, binPath string) ([]string, error) { 51 | objdump := "objdump" 52 | if err := HasExternalDep(objdump); err != nil { 53 | return getMissingLibsFallBack(binPath) 54 | } 55 | 56 | out, err := exec.CommandContext(ctx, objdump, "-p", "--", binPath).Output() 57 | if err != nil { 58 | return getMissingLibsFallBack(binPath) 59 | } 60 | 61 | var deps []string 62 | reg := regexp.MustCompile(`^\s*NEEDED\s+(.+)$`) 63 | r := bytes.NewReader(out) 64 | sc := bufio.NewScanner(r) 65 | for sc.Scan() { 66 | line := sc.Text() 67 | if match := reg.FindStringSubmatch(line); len(match) == 2 { 68 | trim := strings.TrimSpace(match[1]) 69 | deps = append(deps, trim) 70 | } 71 | } 72 | 73 | return deps, nil 74 | } 75 | 76 | // uses otool to find dynamically linked libs 77 | func getMissingLibsDarwin(ctx context.Context, binPath string) ([]string, error) { 78 | otool := "otool" 79 | if err := HasExternalDep(otool); err != nil { 80 | return getMissingLibsFallBack(binPath) 81 | } 82 | 83 | cmd := exec.CommandContext(ctx, otool, "-L", binPath) 84 | out, err := cmd.Output() 85 | if err != nil { 86 | return getMissingLibsFallBack(binPath) 87 | } 88 | 89 | var deps []string 90 | r := bytes.NewReader(out) 91 | sc := bufio.NewScanner(r) 92 | first := true 93 | for sc.Scan() { 94 | line := strings.TrimSpace(sc.Text()) 95 | if first { 96 | first = false 97 | continue 98 | } 99 | 100 | if line == "" { 101 | continue 102 | } 103 | 104 | // skip fat headers 105 | isFat := strings.HasSuffix(line, ":") && strings.Contains(line, "(architecture") 106 | if isFat { 107 | continue 108 | } 109 | 110 | if i := strings.Index(line, " ("); i > 0 { 111 | name := strings.TrimSpace(line[:i]) 112 | deps = append(deps, name) 113 | } 114 | } 115 | 116 | return deps, nil 117 | } 118 | 119 | func getMissingLibsFallBack(path string) ([]string, error) { 120 | var libs []string 121 | deps, err := GetBinDeps(path) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | for _, dep := range deps { 127 | hasLib, err := hasSharedLib(dep) 128 | if err != nil { 129 | continue 130 | } 131 | if !hasLib { 132 | libs = append(libs, dep) 133 | } 134 | } 135 | 136 | return libs, nil 137 | } 138 | 139 | func hasSharedLib(name string) (bool, error) { 140 | var searchPaths []string 141 | switch runtime.GOOS { 142 | case "linux": 143 | // WARNING: only for 64-bit OSes for linux 144 | searchPaths = []string{ 145 | "/usr/local/lib/x86_64-linux-gnu", 146 | "/lib/x86_64-linux-gnu", 147 | "/usr/lib/x86_64-linux-gnu", 148 | "/usr/lib/x86_64-linux-gnu64", 149 | "/usr/local/lib64", 150 | "/lib64", 151 | "/usr/lib64", 152 | "/usr/local/lib", 153 | "/lib", 154 | "/usr/lib", 155 | "/usr/x86_64-linux-gnu/lib64", 156 | "/usr/x86_64-linux-gnu/lib", 157 | } 158 | if env := os.Getenv("LD_LIBRARY_PATH"); env != "" { 159 | searchPaths = append(strings.Split(env, ":"), searchPaths...) 160 | } 161 | case "darwin": 162 | searchPaths = []string{ 163 | "/usr/lib/", 164 | "/System/Library/Frameworks/", 165 | "/System/Library/PrivateFrameworks/", 166 | "/Library/Frameworks/", 167 | "/usr/local/lib/", 168 | } 169 | if env := os.Getenv("DYLD_LIBRARY_PATH"); env != "" { 170 | searchPaths = append(strings.Split(env, ":"), searchPaths...) 171 | } 172 | case "windows": 173 | return false, fmt.Errorf("warning: cannot check dependencies at this time") 174 | } 175 | 176 | for _, dir := range searchPaths { 177 | if _, err := os.Stat(filepath.Join(dir, name)); err == nil { 178 | return true, nil 179 | } 180 | } 181 | return false, nil 182 | } 183 | 184 | func GetBinDeps(path string) ([]string, error) { 185 | var file binFile 186 | var err error 187 | 188 | switch runtime.GOOS { 189 | case "windows": 190 | file, err = pe.Open(path) 191 | case "darwin": 192 | file, err = macho.Open(path) 193 | case "linux": 194 | file, err = elf.Open(path) 195 | default: 196 | return nil, fmt.Errorf("error: unsupported system") 197 | } 198 | if err != nil { 199 | return nil, fmt.Errorf("error: failed to open binary: '%s': \n%w", path, err) 200 | } 201 | 202 | defer file.Close() 203 | 204 | libs, err := file.ImportedLibraries() 205 | if err != nil { 206 | return nil, fmt.Errorf("error: failed to get imported libs on %s: \n%w", path, err) 207 | } 208 | 209 | return libs, nil 210 | } 211 | -------------------------------------------------------------------------------- /pkg/cmdparser/cmdparser_test.go: -------------------------------------------------------------------------------- 1 | package cmdparser 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | ) 7 | 8 | func TestParseRepoRef(t *testing.T) { 9 | refs := map[string][]string{ 10 | "neovim/neovim": {"neovim", "neovim", "false"}, 11 | "AvaloniaUI/Avalonia.Samples": {"AvaloniaUI", "Avalonia.Samples", "false"}, 12 | "": {"", "", "true"}, 13 | "/": {"", "", "true"}, 14 | "godotengine/": {"", "", "true"}, 15 | ";.;:-/godot": {"", "", "true"}, 16 | } 17 | for ref, val := range refs { 18 | own, rep, err := ParseRepoRef(ref) 19 | expErr, _ := strconv.ParseBool(val[2]) 20 | actErr := expErr != (err != nil) 21 | if val[0] != own || val[1] != rep || actErr { 22 | t.Errorf("error, got '%s'/'%s', wanted %s/%s with err %q", own, rep, val[0], val[1], err) 23 | return 24 | } 25 | } 26 | } 27 | 28 | func TestParseRepoReleaseRef(t *testing.T) { 29 | refs := map[string][]string{ 30 | "neovim/neovim@v0.11.3": {"neovim", "neovim", "v0.11.3", "false"}, 31 | "AvaloniaUI/Avalonia.Samples@samples": {"AvaloniaUI", "Avalonia.Samples", "samples", "false"}, 32 | "": {"", "", "", "true"}, 33 | "/@j": {"", "", "", "true"}, 34 | "godotengine/@4.4.1-stable": {"", "", "", "true"}, 35 | ";.;:-/godot@4.4.1-stable": {"", "", "", "true"}, 36 | } 37 | for ref, val := range refs { 38 | own, rep, tag, err := ParseRepoReleaseRef(ref) 39 | expErr, _ := strconv.ParseBool(val[3]) 40 | actErr := expErr != (err != nil) 41 | if val[0] != own || val[1] != rep || val[2] != tag || actErr { 42 | t.Errorf("error, got '%s'/'%s', wanted %s/%s with err %q", own, rep, val[0], val[1], err) 43 | return 44 | } 45 | } 46 | } 47 | 48 | func TestParseGithubUrlPattern(t *testing.T) { 49 | refs := map[string][]string{ 50 | // https 51 | "https://github.com/neovim/neovim.git": {"neovim", "neovim", "false"}, 52 | "https://github.com/AvaloniaUI/Avalonia.Samples.git": {"AvaloniaUI", "Avalonia.Samples", "false"}, 53 | "https://github.com/.git": {"", "", "true"}, 54 | "https://github.com//.git": {"", "", "true"}, 55 | "https://github.com/godotengine/.git": {"", "", "true"}, 56 | "https://github.com/;.;:-/godot.git": {"", "", "true"}, 57 | 58 | // ssh 59 | "git@github.com:neovim/neovim.git": {"neovim", "neovim", "false"}, 60 | "git@github.com:AvaloniaUI/Avalonia.Samples.git": {"AvaloniaUI", "Avalonia.Samples", "false"}, 61 | "git@github.com:.git": {"", "", "true"}, 62 | "git@github.com:/.git": {"", "", "true"}, 63 | "git@github.com:godotengine/.git": {"", "", "true"}, 64 | "git@github.com:;.;:-/godot.git": {"", "", "true"}, 65 | } 66 | for ref, val := range refs { 67 | own, rep, err := ParseGithubUrlPattern(ref) 68 | expErr, _ := strconv.ParseBool(val[2]) 69 | actErr := expErr != (err != nil) 70 | if val[0] != own || val[1] != rep || actErr { 71 | t.Errorf("error, got '%s'/'%s', wanted %s/%s with err %q", own, rep, val[0], val[1], err) 72 | return 73 | } 74 | } 75 | } 76 | 77 | func TestParseGithubUrlPatternWithRelease(t *testing.T) { 78 | refs := map[string][]string{ 79 | // https 80 | "https://github.com/neovim/neovim.git@v0.11.3": {"neovim", "neovim", "v0.11.3", "false"}, 81 | "https://github.com/AvaloniaUI/Avalonia.Samples.git@v0.1.1": {"AvaloniaUI", "Avalonia.Samples", "v0.1.1", "false"}, 82 | "https://github.com/.git@": {"", "", "", "true"}, 83 | "https://github.com//.git@i@tag": {"", "", "", "true"}, 84 | "https://github.com/godotengine/.git@tt0.v1": {"", "", "", "true"}, 85 | "https://github.com/;.;:-/godot.git@irn": {"", "", "", "true"}, 86 | 87 | // ssh 88 | "git@github.com:neovim/neovim.git@v0.11.3": {"neovim", "neovim", "v0.11.3", "false"}, 89 | "git@github.com:AvaloniaUI/Avalonia.Samples.git@v0.1.1": {"AvaloniaUI", "Avalonia.Samples", "v0.1.1", "false"}, 90 | "git@github.com:.git@": {"", "", "", "true"}, 91 | "git@github.com:/.git@i@tag": {"", "", "", "true"}, 92 | "git@github.com:godotengine/.git@tt0.v1": {"", "", "", "true"}, 93 | "git@github.com:;.;:-/godot.git@irn": {"", "", "", "true"}, 94 | } 95 | for ref, val := range refs { 96 | own, rep, tag, err := ParseGithubUrlPatternWithRelease(ref) 97 | expErr, _ := strconv.ParseBool(val[3]) 98 | actErr := expErr != (err != nil) 99 | if val[0] != own || val[1] != rep || val[2] != tag || actErr { 100 | t.Errorf("error, got '%s'/'%s', wanted %s/%s with err %q", own, rep, val[0], val[1], err) 101 | return 102 | } 103 | } 104 | } 105 | 106 | func TestStringToString(t *testing.T) { 107 | refs := map[string][]string{ 108 | // https 109 | "key=value": {"key", "value", "false"}, 110 | "key=value=key": {"key", "value=key", "false"}, 111 | "hello world": {"", "", "true"}, 112 | "======": {"", "=====", "false"}, 113 | "hello = world": {"hello ", " world", "false"}, 114 | "": {"", "", "true"}, 115 | "six=seven": {"six", "seven", "false"}, 116 | } 117 | for ref, val := range refs { 118 | s1, s2, err := StringToString(ref) 119 | act1, act2 := val[0], val[1] 120 | expErr, _ := strconv.ParseBool(val[2]) 121 | actErr := expErr != (err != nil) 122 | if actErr || s1 != act1 || s2 != act2 { 123 | t.Errorf("error: got %s and %s, wanted %s and %s. returned with err: %q", s1, s2, act1, act2, err) 124 | } 125 | } 126 | } 127 | 128 | func TestBuildGitLink(t *testing.T) { 129 | owner := "foo" 130 | repo := "bar" 131 | h, s := BuildGitLink(owner, repo) 132 | if h != "https://github.com/foo/bar.git" { 133 | t.Fatalf("unexpected https link: %s", h) 134 | } 135 | if s != "git@github.com:foo/bar.git" { 136 | t.Fatalf("unexpected ssh link: %s", s) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -eu 3 | 4 | # Minimal installer for parm (Linux/macOS). Installs latest release. 5 | # Installs to OS-appropriate data dir and adds /bin to PATH. 6 | # Optional: GITHUB_TOKEN to avoid API rate limiting. 7 | # Optional: Use WRITE_TOKEN=1 to write the API key to the shell profile 8 | 9 | need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "error: need $1" >&2; exit 1; }; } 10 | need_cmd uname 11 | need_cmd tar 12 | { command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; } || { echo "error: need curl or wget" >&2; exit 1; } 13 | 14 | OS="$(uname -s)" 15 | ARCH="$(uname -m)" 16 | 17 | case "$OS" in 18 | Linux) os="linux" ;; 19 | Darwin) os="darwin" ;; 20 | *) echo "error: unsupported OS: $OS" >&2; exit 1 ;; 21 | esac 22 | 23 | case "$ARCH" in 24 | x86_64|amd64) arch="amd64" ;; 25 | arm64|aarch64) arch="arm64" ;; 26 | *) echo "error: unsupported arch: $ARCH" >&2; exit 1 ;; 27 | esac 28 | 29 | # Resolve config dir: $XDG_CONFIG_HOME/parm or ~/.config/parm 30 | if [ -n "${XDG_CONFIG_HOME:-}" ]; then 31 | cfg_dir="${XDG_CONFIG_HOME%/}/parm" 32 | else 33 | cfg_dir="${HOME}/.config/parm" 34 | fi 35 | 36 | # Resolve data prefix (install prefix) per config.go 37 | case "$OS" in 38 | Linux) 39 | if [ -n "${XDG_DATA_HOME:-}" ]; then 40 | prefix="${XDG_DATA_HOME%/}/parm" 41 | else 42 | prefix="${HOME}/.local/share/parm" 43 | fi 44 | ;; 45 | Darwin) 46 | prefix="${HOME}/Library/Application Support/parm" 47 | ;; 48 | esac 49 | 50 | bin_dir="${prefix}/bin" 51 | 52 | mkdir -p "$cfg_dir" "$bin_dir" 53 | 54 | http_get() { 55 | if command -v curl >/dev/null 2>&1; then 56 | if [ -n "${GITHUB_TOKEN:-}" ]; then 57 | curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" "$1" 58 | else 59 | curl -fsSL "$1" 60 | fi 61 | else 62 | if [ -n "${GITHUB_TOKEN:-}" ]; then 63 | wget -qO- --header="Authorization: Bearer $GITHUB_TOKEN" "$1" 64 | else 65 | wget -qO- "$1" 66 | fi 67 | fi 68 | } 69 | 70 | http_download() { 71 | dst="$2" 72 | if command -v curl >/dev/null 2>&1; then 73 | if [ -n "${GITHUB_TOKEN:-}" ]; then 74 | curl -fL --retry 3 -H "Authorization: Bearer $GITHUB_TOKEN" -o "$dst" "$1" 75 | else 76 | curl -fL --retry 3 -o "$dst" "$1" 77 | fi 78 | else 79 | if [ -n "${GITHUB_TOKEN:-}" ]; then 80 | wget -q --header="Authorization: Bearer $GITHUB_TOKEN" -O "$dst" "$1" 81 | else 82 | wget -q -O "$dst" "$1" 83 | fi 84 | fi 85 | } 86 | 87 | latest_tag="$(http_get "https://api.github.com/repos/yhoundz/parm/releases/latest" \ 88 | | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" 89 | [ -n "$latest_tag" ] || { echo "error: could not resolve latest version" >&2; exit 1; } 90 | 91 | try_url() { 92 | http_download "$1" "$2" 2>/dev/null && return 0 || return 1 93 | } 94 | 95 | tmpdir="$(mktemp -d)" 96 | trap 'rm -rf "$tmpdir"' EXIT INT TERM 97 | archive="$tmpdir/parm.tar.gz" 98 | 99 | # macOS: try arm64 first; if 404, try amd64 (Rosetta) 100 | if [ "$os" = "darwin" ] && [ "$arch" = "arm64" ]; then 101 | url_arm64="https://github.com/yhoundz/parm/releases/download/${latest_tag}/parm-darwin-arm64.tar.gz" 102 | url_amd64="https://github.com/yhoundz/parm/releases/download/${latest_tag}/parm-darwin-amd64.tar.gz" 103 | if try_url "$url_arm64" "$archive"; then 104 | : 105 | elif try_url "$url_amd64" "$archive"; then 106 | echo "warning: using amd64 binary on Apple Silicon (Rosetta required)" >&2 107 | else 108 | echo "error: no darwin arm64/amd64 assets found for ${latest_tag}" >&2 109 | exit 1 110 | fi 111 | else 112 | asset="parm-${os}-${arch}.tar.gz" 113 | url="https://github.com/yhoundz/parm/releases/download/${latest_tag}/${asset}" 114 | http_download "$url" "$archive" || { echo "error: download failed" >&2; exit 1; } 115 | fi 116 | 117 | work="$tmpdir/extract" 118 | mkdir -p "$work" 119 | tar -C "$work" -xzf "$archive" 120 | 121 | if [ -f "$work/parm" ]; then 122 | src="$work/parm" 123 | else 124 | src="$(find "$work" -type f -name 'parm' -maxdepth 2 | head -n1 || true)" 125 | fi 126 | [ -n "${src:-}" ] && [ -f "$src" ] || { echo "error: parm binary not found after extract" >&2; exit 1; } 127 | chmod +x "$src" 128 | mv -f "$src" "$bin_dir/parm" 129 | 130 | echo "Installed: $bin_dir/parm" 131 | 132 | # Pick a profile once (used for PATH and optional token persistence) 133 | if [ -z "${profile:-}" ]; then 134 | if [ -f "$HOME/.zshrc" ]; then 135 | profile="$HOME/.zshrc" 136 | elif [ -f "$HOME/.bashrc" ]; then 137 | profile="$HOME/.bashrc" 138 | elif [ -f "$HOME/.profile" ]; then 139 | profile="$HOME/.profile" 140 | else 141 | profile="$HOME/.profile" 142 | fi 143 | fi 144 | 145 | # Ensure /bin is in PATH; avoid duplicates by checking env and profile content 146 | ensure_line='export PATH="'"$bin_dir"':$PATH"' 147 | 148 | need_add_env=1 149 | case ":$PATH:" in 150 | *:"$bin_dir":*) need_add_env=0 ;; 151 | esac 152 | 153 | need_add_profile=1 154 | if [ -f "$profile" ]; then 155 | if grep -qs "$bin_dir" "$profile"; then 156 | need_add_profile=0 157 | fi 158 | fi 159 | 160 | if [ "$need_add_env" -eq 1 ] || [ "$need_add_profile" -eq 1 ]; then 161 | if [ ! -f "$profile" ]; then 162 | printf "%s\n" "$ensure_line" > "$profile" 163 | echo "Created $(basename "$profile") and added PATH. Open a new shell to use it." 164 | else 165 | if [ "$need_add_profile" -eq 1 ]; then 166 | printf "\n# Added by parm installer\n%s\n" "$ensure_line" >> "$profile" 167 | echo "Added $bin_dir to PATH in $(basename "$profile"). Open a new shell to use it." 168 | fi 169 | fi 170 | fi 171 | 172 | if [ -n "${GITHUB_TOKEN:-}" ]; then 173 | if [ "${WRITE_TOKEN:-}" = "1" ]; then 174 | echo "export GITHUB_TOKEN=$GITHUB_TOKEN" >> "$profile" 175 | echo "Wrote GITHUB_TOKEN to $(basename "$profile"). Open a new shell or run: . \"$profile\"" 176 | else 177 | echo "Add your GitHub API Key to your shell profile via the following:" 178 | echo " echo 'export GITHUB_TOKEN=…' >> \"$profile\"" 179 | echo " or: parm config set github_api_token_fallback=…" 180 | fi 181 | fi 182 | 183 | # Show version if available 184 | if "$bin_dir/parm" --version >/dev/null 2>&1; then 185 | "$bin_dir/parm" --version 186 | fi 187 | 188 | echo "Done." 189 | -------------------------------------------------------------------------------- /internal/core/catalog/list_test.go: -------------------------------------------------------------------------------- 1 | package catalog 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "parm/internal/manifest" 9 | 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | func TestGetAllPkgManifest_Empty(t *testing.T) { 14 | tmpDir := t.TempDir() 15 | 16 | v := viper.New() 17 | v.Set("parm_pkg_path", tmpDir) 18 | viper.Set("parm_pkg_path", tmpDir) 19 | defer viper.Reset() 20 | 21 | manifests, err := GetAllPkgManifest() 22 | if err != nil { 23 | t.Fatalf("GetAllPkgManifest() error: %v", err) 24 | } 25 | 26 | if len(manifests) != 0 { 27 | t.Errorf("GetAllPkgManifest() returned %d manifests, want 0", len(manifests)) 28 | } 29 | } 30 | 31 | func TestGetAllPkgManifest_SinglePackage(t *testing.T) { 32 | tmpDir := t.TempDir() 33 | 34 | // Create package structure 35 | pkgDir := filepath.Join(tmpDir, "owner", "repo") 36 | os.MkdirAll(pkgDir, 0755) 37 | 38 | // Create manifest 39 | m := &manifest.Manifest{ 40 | Owner: "owner", 41 | Repo: "repo", 42 | Version: "v1.0.0", 43 | InstallType: manifest.Release, 44 | Executables: []string{"bin/app"}, 45 | LastUpdated: "2025-01-01 12:00:00", 46 | } 47 | m.Write(pkgDir) 48 | 49 | viper.Set("parm_pkg_path", tmpDir) 50 | defer viper.Reset() 51 | 52 | manifests, err := GetAllPkgManifest() 53 | if err != nil { 54 | t.Fatalf("GetAllPkgManifest() error: %v", err) 55 | } 56 | 57 | if len(manifests) != 1 { 58 | t.Fatalf("GetAllPkgManifest() returned %d manifests, want 1", len(manifests)) 59 | } 60 | 61 | if manifests[0].Owner != "owner" { 62 | t.Errorf("Manifest owner = %v, want owner", manifests[0].Owner) 63 | } 64 | 65 | if manifests[0].Repo != "repo" { 66 | t.Errorf("Manifest repo = %v, want repo", manifests[0].Repo) 67 | } 68 | } 69 | 70 | func TestGetAllPkgManifest_MultiplePackages(t *testing.T) { 71 | tmpDir := t.TempDir() 72 | 73 | // Create multiple packages 74 | packages := []struct { 75 | owner string 76 | repo string 77 | }{ 78 | {"owner1", "repo1"}, 79 | {"owner1", "repo2"}, 80 | {"owner2", "repo1"}, 81 | } 82 | 83 | for _, pkg := range packages { 84 | pkgDir := filepath.Join(tmpDir, pkg.owner, pkg.repo) 85 | os.MkdirAll(pkgDir, 0755) 86 | 87 | m := &manifest.Manifest{ 88 | Owner: pkg.owner, 89 | Repo: pkg.repo, 90 | Version: "v1.0.0", 91 | InstallType: manifest.Release, 92 | Executables: []string{"bin/app"}, 93 | LastUpdated: "2025-01-01 12:00:00", 94 | } 95 | m.Write(pkgDir) 96 | } 97 | 98 | viper.Set("parm_pkg_path", tmpDir) 99 | defer viper.Reset() 100 | 101 | manifests, err := GetAllPkgManifest() 102 | if err != nil { 103 | t.Fatalf("GetAllPkgManifest() error: %v", err) 104 | } 105 | 106 | if len(manifests) != 3 { 107 | t.Errorf("GetAllPkgManifest() returned %d manifests, want 3", len(manifests)) 108 | } 109 | } 110 | 111 | func TestGetAllPkgManifest_SkipInvalid(t *testing.T) { 112 | tmpDir := t.TempDir() 113 | 114 | // Create valid package 115 | validDir := filepath.Join(tmpDir, "owner1", "repo1") 116 | os.MkdirAll(validDir, 0755) 117 | 118 | m1 := &manifest.Manifest{ 119 | Owner: "owner1", 120 | Repo: "repo1", 121 | Version: "v1.0.0", 122 | InstallType: manifest.Release, 123 | Executables: []string{"bin/app"}, 124 | LastUpdated: "2025-01-01 12:00:00", 125 | } 126 | m1.Write(validDir) 127 | 128 | // Create directory without manifest 129 | invalidDir := filepath.Join(tmpDir, "owner2", "repo2") 130 | os.MkdirAll(invalidDir, 0755) 131 | 132 | // Create directory with corrupted manifest 133 | corruptDir := filepath.Join(tmpDir, "owner3", "repo3") 134 | os.MkdirAll(corruptDir, 0755) 135 | manifestPath := filepath.Join(corruptDir, manifest.ManifestFileName) 136 | os.WriteFile(manifestPath, []byte("corrupted json"), 0644) 137 | 138 | viper.Set("parm_pkg_path", tmpDir) 139 | defer viper.Reset() 140 | 141 | manifests, err := GetAllPkgManifest() 142 | if err != nil { 143 | t.Fatalf("GetAllPkgManifest() error: %v", err) 144 | } 145 | 146 | // Should only return the valid manifest 147 | if len(manifests) != 1 { 148 | t.Errorf("GetAllPkgManifest() returned %d manifests, want 1", len(manifests)) 149 | } 150 | 151 | if manifests[0].Owner != "owner1" { 152 | t.Errorf("Manifest owner = %v, want owner1", manifests[0].Owner) 153 | } 154 | } 155 | 156 | func TestGetInstalledPkgInfo(t *testing.T) { 157 | tmpDir := t.TempDir() 158 | 159 | // Create package 160 | pkgDir := filepath.Join(tmpDir, "owner", "repo") 161 | os.MkdirAll(pkgDir, 0755) 162 | 163 | m := &manifest.Manifest{ 164 | Owner: "owner", 165 | Repo: "repo", 166 | Version: "v1.0.0", 167 | InstallType: manifest.Release, 168 | Executables: []string{"bin/app"}, 169 | LastUpdated: "2025-01-01 12:00:00", 170 | } 171 | m.Write(pkgDir) 172 | 173 | viper.Set("parm_pkg_path", tmpDir) 174 | defer viper.Reset() 175 | 176 | infos, data, err := GetInstalledPkgInfo() 177 | if err != nil { 178 | t.Fatalf("GetInstalledPkgInfo() error: %v", err) 179 | } 180 | 181 | if len(infos) != 1 { 182 | t.Fatalf("GetInstalledPkgInfo() returned %d infos, want 1", len(infos)) 183 | } 184 | 185 | if data.NumPkgs != 1 { 186 | t.Errorf("NumPkgs = %v, want 1", data.NumPkgs) 187 | } 188 | 189 | // Check info string format 190 | expectedSubstr := "owner/repo" 191 | if !contains(infos[0], expectedSubstr) { 192 | t.Errorf("Info string %q should contain %q", infos[0], expectedSubstr) 193 | } 194 | } 195 | 196 | func TestGetInstalledPkgInfo_Empty(t *testing.T) { 197 | tmpDir := t.TempDir() 198 | 199 | viper.Set("parm_pkg_path", tmpDir) 200 | defer viper.Reset() 201 | 202 | infos, data, err := GetInstalledPkgInfo() 203 | if err != nil { 204 | t.Fatalf("GetInstalledPkgInfo() error: %v", err) 205 | } 206 | 207 | if len(infos) != 0 { 208 | t.Errorf("GetInstalledPkgInfo() returned %d infos, want 0", len(infos)) 209 | } 210 | 211 | if data.NumPkgs != 0 { 212 | t.Errorf("NumPkgs = %v, want 0", data.NumPkgs) 213 | } 214 | } 215 | 216 | func TestGetAllPkgManifest_NoConfigPath(t *testing.T) { 217 | viper.Reset() 218 | viper.Set("parm_pkg_path", "") 219 | 220 | _, err := GetAllPkgManifest() 221 | if err == nil { 222 | t.Error("GetAllPkgManifest() should return error when parm_pkg_path is empty") 223 | } 224 | } 225 | 226 | // Helper function 227 | func contains(s, substr string) bool { 228 | return len(s) >= len(substr) && (s == substr || len(substr) == 0 || 229 | (len(s) > len(substr) && anySubstring(s, substr))) 230 | } 231 | 232 | func anySubstring(s, substr string) bool { 233 | for i := 0; i <= len(s)-len(substr); i++ { 234 | if s[i:i+len(substr)] == substr { 235 | return true 236 | } 237 | } 238 | return false 239 | } 240 | -------------------------------------------------------------------------------- /internal/core/installer/release.go: -------------------------------------------------------------------------------- 1 | package installer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | "os" 8 | "parm/internal/core/verify" 9 | "parm/internal/parmutil" 10 | "parm/pkg/archive" 11 | "parm/pkg/progress" 12 | "path/filepath" 13 | "runtime" 14 | "slices" 15 | "strings" 16 | 17 | "github.com/google/go-github/v74/github" 18 | ) 19 | 20 | // Does NOT validate the release. 21 | func (in *Installer) installFromRelease(ctx context.Context, pkgPath, owner, repo string, rel *github.RepositoryRelease, opts InstallFlags, hooks *progress.Hooks) (*InstallResult, error) { 22 | var ass *github.ReleaseAsset 23 | var err error 24 | if opts.Asset == nil { 25 | matches, err := selectReleaseAsset(rel.Assets, runtime.GOOS, runtime.GOARCH) 26 | if err != nil { 27 | return nil, err 28 | } 29 | if len(matches) == 0 { 30 | // TODO: allow users to choose match 31 | return nil, fmt.Errorf("err: no compatible binary found for release %s", rel.GetTagName()) 32 | } 33 | // if len(matches) > 1 { 34 | // // TODO: allow users to choose what asset they want installed instead 35 | // return nil 36 | // } 37 | ass = matches[0] 38 | } else { 39 | ass, err = getAssetByName(rel, *opts.Asset) 40 | if err != nil { 41 | return nil, err 42 | } 43 | } 44 | 45 | tmpDir, err := parmutil.MakeStagingDir(owner, repo) 46 | if err != nil { 47 | return nil, err 48 | } 49 | // TODO: Cleanup() instead 50 | defer os.RemoveAll(tmpDir) 51 | 52 | archivePath := filepath.Join(tmpDir, ass.GetName()) // download destination 53 | if err := downloadTo(ctx, archivePath, ass.GetBrowserDownloadURL(), hooks); err != nil { 54 | return nil, fmt.Errorf("error: failed to download asset: \n%w", err) 55 | } 56 | 57 | // TODO: change based on actual verify-level 58 | if opts.VerifyLevel > 0 { 59 | if ass.Digest == nil { 60 | return nil, fmt.Errorf("error: no upstream digest available for %q; re-run with --no-verify", ass.GetName()) 61 | } 62 | ok, gen, err := verify.VerifyLevel1(archivePath, *ass.Digest) 63 | if err != nil { 64 | return nil, fmt.Errorf("error: could not verify checksum:\n%q", err) 65 | } 66 | if !ok { 67 | return nil, fmt.Errorf("fatal: checksum invalid:\n\thad %s\n\twanted %s", *gen, *ass.Digest) 68 | } 69 | } 70 | 71 | switch { 72 | case strings.HasSuffix(archivePath, ".tar.gz"), strings.HasSuffix(archivePath, ".tgz"): 73 | if err := archive.ExtractTarGz(archivePath, tmpDir); err != nil { 74 | return nil, fmt.Errorf("error: failed to extract tarball: \n%w", err) 75 | } 76 | case strings.HasSuffix(archivePath, ".zip"): 77 | if err := archive.ExtractZip(archivePath, tmpDir); err != nil { 78 | return nil, fmt.Errorf("error: failed to extract zip: \n%w", err) 79 | } 80 | default: 81 | if runtime.GOOS != "windows" { 82 | if err := os.Chmod(archivePath, 0o755); err != nil { 83 | return nil, fmt.Errorf("failed to make binary executable: \n%w", err) 84 | } 85 | } 86 | } 87 | 88 | // TODO: create manifest elsewhere for better separation of concerns? 89 | // TODO: Return an InstallResult and let the CLI call a manifest writer service. 90 | // will also help with symlinking 91 | 92 | finalDir, err := parmutil.PromoteStagingDir(pkgPath, tmpDir) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | return &InstallResult{ 98 | InstallPath: finalDir, 99 | Version: rel.GetTagName(), 100 | }, nil 101 | } 102 | 103 | // gets release asset by name 104 | func getAssetByName(rel *github.RepositoryRelease, name string) (*github.ReleaseAsset, error) { 105 | for _, ass := range rel.Assets { 106 | if *ass.Name == name { 107 | return ass, nil 108 | } 109 | } 110 | return nil, fmt.Errorf("error: no asset by the name of %s was found in release %s", name, rel) 111 | } 112 | 113 | // infers the proper release asset based on the name of the asset 114 | func selectReleaseAsset(assets []*github.ReleaseAsset, goos, goarch string) ([]*github.ReleaseAsset, error) { 115 | type match struct { 116 | asset *github.ReleaseAsset 117 | score int 118 | } 119 | 120 | gooses := map[string][]string{ 121 | "windows": {"windows", "win64", "win32", "win"}, 122 | "darwin": {"macos", "darwin", "mac", "osx"}, 123 | "linux": {"linux"}, 124 | } 125 | goarchs := map[string][]string{ 126 | "amd64": {"amd64", "x86_64", "x64", "64bit", "64-bit"}, 127 | "386": {"386", "x86", "i386", "32bit", "32-bit"}, 128 | "arm64": {"arm64", "aarch64"}, 129 | "arm": {"armv7", "armv6", "armhf", "armv7l"}, 130 | } 131 | 132 | extPref := []string{".tar.gz", ".tgz", ".tar.xz", ".zip", ".bin", ".AppImage"} 133 | if goos == "windows" { 134 | extPref = []string{".zip", ".exe", ".msi", ".bin"} 135 | } 136 | 137 | // other score modifiers 138 | scoreMods := map[string]int{ 139 | "musl": -1, 140 | } 141 | if goos == "windows" { 142 | scoreMods = map[string]int{} 143 | } 144 | 145 | // scoring 146 | scoredMatches := make([]match, len(assets)) 147 | for i, a := range assets { 148 | scoredMatches[i] = match{asset: a, score: 0} 149 | } 150 | 151 | const goosMatch = 11 152 | const goarchMatch = 7 153 | const prefMatch = 3 // actually a multiplier for preference match 154 | // INFO: to be used later when adding interactive install. 155 | // const minScoreMatch = goosMatch + goarchMatch 156 | 157 | for i := range scoredMatches { 158 | a := &scoredMatches[i] 159 | name := a.asset.GetName() 160 | if containsAny(name, gooses[goos]) { 161 | a.score += goosMatch 162 | } 163 | if containsAny(name, goarchs[goarch]) { 164 | a.score += goarchMatch 165 | } 166 | 167 | for j, ext := range extPref { 168 | var mult = float64(prefMatch) * float64((len(extPref) - j)) 169 | var multRounded = int(math.Round(mult)) 170 | if strings.HasSuffix(name, ext) { 171 | a.score += multRounded 172 | } 173 | } 174 | 175 | for j, m := range scoreMods { 176 | if strings.Contains(name, j) { 177 | a.score += m 178 | } 179 | } 180 | } 181 | 182 | // sort 183 | slices.SortStableFunc(scoredMatches, func(a, b match) int { 184 | if a.score < b.score { 185 | return 1 186 | } 187 | if a.score > b.score { 188 | return -1 189 | } 190 | return 0 191 | }) 192 | 193 | minMatch := scoredMatches[0].score 194 | // if minMatch < minScoreMatch { 195 | // fmt.Println("warning: selected release asset may not be completely accurate") 196 | // } 197 | 198 | // find top candidate(s) 199 | var candidates []*github.ReleaseAsset 200 | for _, m := range scoredMatches { 201 | if m.score == minMatch { 202 | candidates = append(candidates, m.asset) 203 | continue 204 | } 205 | break 206 | } 207 | 208 | return candidates, nil 209 | } 210 | 211 | func containsAny(src string, tokens []string) bool { 212 | for _, a := range tokens { 213 | if strings.Contains(src, a) { 214 | return true 215 | } 216 | } 217 | return false 218 | } 219 | -------------------------------------------------------------------------------- /internal/manifest/manifest_test.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | "testing" 10 | 11 | "parm/internal/config" 12 | ) 13 | 14 | func TestNew(t *testing.T) { 15 | tmpDir := t.TempDir() 16 | 17 | // Create a mock binary 18 | binPath := filepath.Join(tmpDir, "testbin") 19 | if runtime.GOOS == "windows" { 20 | binPath += ".exe" 21 | } 22 | 23 | // Create a simple executable file 24 | content := []byte{0x7f, 0x45, 0x4c, 0x46} // ELF magic 25 | switch runtime.GOOS { 26 | case "darwin": 27 | content = []byte{0xcf, 0xfa, 0xed, 0xfe} // Mach-O 28 | case "windows": 29 | content = []byte{0x4d, 0x5a} // PE 30 | } 31 | os.WriteFile(binPath, content, 0755) 32 | 33 | m, err := New("owner", "repo", "v1.0.0", Release, tmpDir) 34 | if err != nil { 35 | t.Fatalf("New() error: %v", err) 36 | } 37 | 38 | if m.Owner != "owner" { 39 | t.Errorf("Owner = %v, want %v", m.Owner, "owner") 40 | } 41 | 42 | if m.Repo != "repo" { 43 | t.Errorf("Repo = %v, want %v", m.Repo, "repo") 44 | } 45 | 46 | if m.Version != "v1.0.0" { 47 | t.Errorf("Version = %v, want %v", m.Version, "v1.0.0") 48 | } 49 | 50 | if m.InstallType != Release { 51 | t.Errorf("InstallType = %v, want %v", m.InstallType, Release) 52 | } 53 | 54 | if m.LastUpdated == "" { 55 | t.Error("LastUpdated is empty") 56 | } 57 | } 58 | 59 | func TestManifest_Write(t *testing.T) { 60 | tmpDir := t.TempDir() 61 | 62 | m := &Manifest{ 63 | Owner: "owner", 64 | Repo: "repo", 65 | LastUpdated: "2025-01-01 12:00:00", 66 | Executables: []string{"bin/app"}, 67 | InstallType: Release, 68 | Version: "v1.0.0", 69 | } 70 | 71 | err := m.Write(tmpDir) 72 | if err != nil { 73 | t.Fatalf("Write() error: %v", err) 74 | } 75 | 76 | // Check if file was created 77 | manifestPath := filepath.Join(tmpDir, ManifestFileName) 78 | if _, err := os.Stat(manifestPath); os.IsNotExist(err) { 79 | t.Error("Write() did not create manifest file") 80 | } 81 | 82 | // Verify content 83 | data, err := os.ReadFile(manifestPath) 84 | if err != nil { 85 | t.Fatal(err) 86 | } 87 | 88 | var readManifest Manifest 89 | err = json.Unmarshal(data, &readManifest) 90 | if err != nil { 91 | t.Fatalf("Failed to unmarshal manifest: %v", err) 92 | } 93 | 94 | if readManifest.Owner != m.Owner { 95 | t.Errorf("Read Owner = %v, want %v", readManifest.Owner, m.Owner) 96 | } 97 | } 98 | 99 | func TestRead(t *testing.T) { 100 | tmpDir := t.TempDir() 101 | 102 | // Create a manifest file 103 | m := &Manifest{ 104 | Owner: "owner", 105 | Repo: "repo", 106 | LastUpdated: "2025-01-01 12:00:00", 107 | Executables: []string{"bin/app"}, 108 | InstallType: Release, 109 | Version: "v1.0.0", 110 | } 111 | 112 | err := m.Write(tmpDir) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | 117 | // Read it back 118 | readManifest, err := Read(tmpDir) 119 | if err != nil { 120 | t.Fatalf("Read() error: %v", err) 121 | } 122 | 123 | if readManifest.Owner != m.Owner { 124 | t.Errorf("Owner = %v, want %v", readManifest.Owner, m.Owner) 125 | } 126 | 127 | if readManifest.Repo != m.Repo { 128 | t.Errorf("Repo = %v, want %v", readManifest.Repo, m.Repo) 129 | } 130 | 131 | if readManifest.Version != m.Version { 132 | t.Errorf("Version = %v, want %v", readManifest.Version, m.Version) 133 | } 134 | } 135 | 136 | func TestRead_NonExistent(t *testing.T) { 137 | tmpDir := t.TempDir() 138 | 139 | _, err := Read(tmpDir) 140 | if err == nil { 141 | t.Error("Read() should return error for non-existent manifest") 142 | } 143 | } 144 | 145 | func TestRead_CorruptedManifest(t *testing.T) { 146 | tmpDir := t.TempDir() 147 | 148 | // Create corrupted manifest 149 | manifestPath := filepath.Join(tmpDir, ManifestFileName) 150 | os.WriteFile(manifestPath, []byte("not valid json"), 0644) 151 | 152 | _, err := Read(tmpDir) 153 | if err == nil { 154 | t.Error("Read() should return error for corrupted manifest") 155 | } 156 | } 157 | 158 | func TestManifest_GetFullExecPaths(t *testing.T) { 159 | // Set up config with temp directory 160 | tmpDir := t.TempDir() 161 | config.Cfg.ParmPkgPath = tmpDir 162 | 163 | m := &Manifest{ 164 | Owner: "owner", 165 | Repo: "repo", 166 | Executables: []string{"bin/app", "bin/tool"}, 167 | } 168 | 169 | paths := m.GetFullExecPaths() 170 | 171 | if len(paths) != 2 { 172 | t.Errorf("GetFullExecPaths() returned %d paths, want 2", len(paths)) 173 | } 174 | 175 | // Each path should be absolute and contain the executable name 176 | for i, path := range paths { 177 | if !filepath.IsAbs(path) { 178 | t.Errorf("Path %d is not absolute: %v", i, path) 179 | } 180 | // Path should contain owner/repo 181 | if !strings.Contains(path, "owner") || !strings.Contains(path, "repo") { 182 | t.Errorf("Path %d doesn't contain owner/repo: %v", i, path) 183 | } 184 | } 185 | } 186 | 187 | func TestGetBinExecutables(t *testing.T) { 188 | tmpDir := t.TempDir() 189 | 190 | // Create a bin directory with mock executables 191 | binDir := filepath.Join(tmpDir, "bin") 192 | os.MkdirAll(binDir, 0755) 193 | 194 | // TODO: actually create a binary with go build 195 | // Create mock binary 196 | binPath := filepath.Join(binDir, "testbin") 197 | if runtime.GOOS == "windows" { 198 | binPath += ".exe" 199 | } 200 | 201 | content := []byte{0x7f, 0x45, 0x4c, 0x46} // ELF magic 202 | switch runtime.GOOS { 203 | case "darwin": 204 | content = []byte{0xcf, 0xfa, 0xed, 0xfe} 205 | case "windows": 206 | content = []byte{0x4d, 0x5a} 207 | } 208 | os.WriteFile(binPath, content, 0755) 209 | 210 | paths, err := getBinExecutables(tmpDir) 211 | if err != nil { 212 | t.Fatalf("getBinExecutables() error: %v", err) 213 | } 214 | 215 | if len(paths) == 0 { 216 | t.Log("No executables found (expected if magic number check fails)") 217 | } else { 218 | t.Logf("Found %d executables", len(paths)) 219 | } 220 | } 221 | 222 | func TestGetBinExecutables_EmptyDir(t *testing.T) { 223 | tmpDir := t.TempDir() 224 | 225 | paths, err := getBinExecutables(tmpDir) 226 | if err != nil { 227 | t.Fatalf("getBinExecutables() error: %v", err) 228 | } 229 | 230 | if len(paths) != 0 { 231 | t.Errorf("getBinExecutables() returned %d paths for empty dir, want 0", len(paths)) 232 | } 233 | } 234 | 235 | func TestGetBinExecutables_WithNonBinaries(t *testing.T) { 236 | tmpDir := t.TempDir() 237 | 238 | // Create text file 239 | textFile := filepath.Join(tmpDir, "readme.txt") 240 | os.WriteFile(textFile, []byte("not a binary"), 0644) 241 | 242 | paths, err := getBinExecutables(tmpDir) 243 | if err != nil { 244 | t.Fatalf("getBinExecutables() error: %v", err) 245 | } 246 | 247 | // Should skip text files 248 | for _, path := range paths { 249 | if filepath.Base(path) == "readme.txt" { 250 | t.Error("getBinExecutables() included text file") 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Installing a Release 2 | 3 | To install the latest stable release of a package, run 4 | ```sh 5 | parm install / 6 | ``` 7 | 8 | For example: 9 | ```sh 10 | parm install yhoundz/parm 11 | ``` 12 | 13 | > [!WARNING] 14 | > Don't actually try to install Parm using Parm. It is unsupported and may or may not work how it was intended. Use the install script in the README instead. 15 | 16 | If you want, you can also specify the full https/ssh link for installation: 17 | ```sh 18 | parm install https://github.com/yhoundz/parm.git 19 | ``` 20 | or 21 | ```sh 22 | parm install git@github.com:yhoundz/parm.git 23 | ``` 24 | 25 | Installing with no flags will automatically install the latest stable release of a package. 26 | If you want to install a specific version of a package, you can specify with the --release flag: 27 | ```sh 28 | parm install yhoundz/parm --release v0.1.0 29 | ``` 30 | 31 | This will install the specific GitHub release assoicated with the release tag. 32 | You can also use the "@" keyword as a shorthand, as follows: 33 | ```sh 34 | parm install yhoundz/parm@v0.1.0 35 | ``` 36 | 37 | Currently, Parm uses a naive text-matching scoring algorithm on asset names to determine which asset to download. However, this algorithm can be inaccurate and may not download the correct asset if the asset name is ambiguous. 38 | 39 | To get around this, specify the asset name with the `--asset` flag when installing with the `--release` or `--pre-release` flag(s). Here is an example installing tmux, which has ambiguous asset names: 40 | 41 | ```sh 42 | parm install tmux/tmux --release 3.5a --asset tmux-3.5a.tar.gz 43 | ``` 44 | 45 | An asset name is ambiguous if the algorithm cannot detect the intended architecture or OS the asset is intended for. For example, the algorithm will correctly detect the OS/arch for "parm-linux-x86_64.tar.gz" or "parm-macos-arm64.tar.gz", but will not detect the intended OS/arch name for "tmux-3.5a.tar.gz". 46 | 47 | By default, Parm will also verify the downloaded tarball/zipball once it has been downloaded by generating a sha256 hash from the installed tarball and comparing it to the sha256 hash provided by the release asset upstream. To skip this verification, use the `--no-verify` flag: 48 | 49 | ```sh 50 | parm install yhoundz/parm --no-verify 51 | ``` 52 | 53 | More options for checksum verification will be added in later versions. 54 | 55 | ## Installing a Pre-Release 56 | 57 | You can install the latest pre-release as follows: 58 | ```sh 59 | parm install yhoundz/parm --pre-release 60 | ``` 61 | 62 | By default, installing a pre-release will actually just install the latest possible release, which may be a stable release. For example, if a GitHub repository has a pre-release labelled "v2.0.0-beta", and the latest possible release is "v2.0.0", then it will install "v2.0.0" instead, even though it isn't a pre-release. "Pre-release" is just an umbrella term for installing the cutting-edge releases. 63 | 64 | If you *strictly* want to install pre-releases, use the `--strict` flag. Note that the `--strict` flag only works when using `--pre-release`: 65 | ```sh 66 | parm install yhoundz/parm --pre-release --strict 67 | ``` 68 | 69 | --- 70 | 71 | # Updating a Package 72 | 73 | To update a package, you can run the following command: 74 | ```sh 75 | parm update / 76 | ``` 77 | 78 | Like the `install` command, if the package in question is on the pre-release channel, then you can use the `--strict` flag to only install pre-release versions, and not the latest cutting-edge version: 79 | 80 | ```sh 81 | parm update yhoundz/parm --strict # assuming this is on the pre-release channel 82 | ``` 83 | 84 | --- 85 | 86 | # Uninstalling a Package 87 | 88 | To remove/uninstall a package, you can run the following command: 89 | ```sh 90 | parm remove / / ... 91 | ``` 92 | 93 | You can also use the `uninstall` command too if you wish; it is functionally the exact same as the `remove command`: 94 | ```sh 95 | parm uninstall / ... 96 | ``` 97 | 98 | --- 99 | 100 | # Listing Installed Packages 101 | 102 | You can list the currently installed packages with: 103 | ```sh 104 | parm list 105 | ``` 106 | 107 | Due to the current implementation's lack of caching, this will likely be pretty slow, but fixes are planned for v0.2.0. 108 | 109 | --- 110 | 111 | # Configuration 112 | 113 | Parm's config file is in `$XDG_CONFIG_HOME/parm/config.toml`. If it can't find the `$XDG_CONFIG_HOME` environment variable, it will default to `$HOME/.config/parm/config.toml` 114 | 115 | You can list out the current contents of the config file by running 116 | ```sh 117 | parm config 118 | ``` 119 | 120 | This will print out the current configuration settings, though it will omit some such as GitHub personal access token environment variables. Do not that the `config` command will **NOT** omit the `github_api_token_fallback` configuration setting, so if you store your API key here, it will get printed out in its entirety. There will likely be a fix for this in future versions. 121 | 122 | ## Setting Configuration Options 123 | 124 | You can set configuration options by running the `config set` subcommand, followed by a `key=value` pair for configuration settings you want to set. Ensure there are no spaces in between `key` and `value` (i.e. `key = value` is wrong). 125 | ```sh 126 | parm config set key1=value1 key2=value2 ... 127 | ``` 128 | 129 | Parm comes with a set of default options. If for some reason you messed up your config, you can reset the config options using the `config reset` subcommand. 130 | ```sh 131 | parm config reset key1 key2 ... 132 | ``` 133 | 134 | If you want to reset all configuration options back to their default, use the `--all` flag. Note this won't allow any additional arguments. 135 | ```sh 136 | parm config reset --all 137 | ``` 138 | 139 | # Retrieving Package Information 140 | 141 | To retrieve certain information about a package, use the `info` command. 142 | ```sh 143 | parm info yhouhdz/parm 144 | ``` 145 | 146 | Here is a sample output of what it may look like: 147 | 148 | ```md 149 | Owner: yhoundz 150 | Repo: parm 151 | Version: v0.1.0 152 | LastUpdated: 2025-10-10 04:50:27 153 | InstallPath: /home/user/.local/share/parm/pkg/yhoundz/parm 154 | ``` 155 | 156 | This displays most fields written to the manifest file upon installation. The full manifest file for a package, go to `$XDG_DATA_HOME/parm/pkg///.curdfile.json` 157 | 158 | If you want more detailed information on a package, you can instead look at its upstream information by using the `--get-upstream` flag. 159 | ```sh 160 | parm info yhoundz/parm --upstream 161 | ``` 162 | 163 | A sample output would look like this: 164 | 165 | ```md 166 | Owner: yhoundz 167 | Repo: parm 168 | Version: v0.1.0 169 | LastUpdated: 2025-10-03 22:34:28 170 | Stars: 67 171 | License: GPL-3.0 license 172 | Description: Install any program from your terminal. 173 | ``` 174 | 175 | The information displayed will likely be tweaked and is not final at the moment. 176 | -------------------------------------------------------------------------------- /cmd/install/install.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2025 Alexander Wang 3 | */ 4 | package install 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "parm/internal/cmdutil" 10 | "parm/internal/core/installer" 11 | "parm/internal/gh" 12 | "parm/internal/manifest" 13 | "parm/internal/parmutil" 14 | "parm/pkg/cmdparser" 15 | "parm/pkg/cmdx" 16 | "parm/pkg/deps" 17 | "parm/pkg/progress" 18 | "parm/pkg/sysutil" 19 | "path/filepath" 20 | 21 | "github.com/spf13/cobra" 22 | "github.com/spf13/viper" 23 | "github.com/vbauerster/mpb/v8" 24 | "github.com/vbauerster/mpb/v8/decor" 25 | ) 26 | 27 | func NewInstallCmd(f *cmdutil.Factory) *cobra.Command { 28 | var pre_release bool 29 | var release string 30 | var asset string 31 | var strict bool 32 | var no_verify bool 33 | 34 | // installCmd represents the install command 35 | var installCmd = &cobra.Command{ 36 | Use: "install /@[release-tag]", 37 | Short: "Installs a new package", 38 | Long: ``, 39 | PreRunE: func(cmd *cobra.Command, args []string) error { 40 | owner, repo, tag, err := cmdparser.ParseRepoReleaseRef(args[0]) 41 | if err != nil { 42 | owner, repo, tag, err = cmdparser.ParseGithubUrlPatternWithRelease(args[0]) 43 | } 44 | if err != nil { 45 | return fmt.Errorf("cannot resolve git repository from input: %s", args[0]) 46 | } 47 | 48 | if tag != "" { 49 | confFlags := []string{"release", "pre-release"} 50 | for _, flag := range confFlags { 51 | if cmd.Flags().Changed(flag) { 52 | return fmt.Errorf("cannot use @version shorthand with the --%s flag", flag) 53 | } 54 | } 55 | cmd.Flags().Set("release", tag) 56 | args[0] = owner + "/" + repo 57 | } 58 | 59 | if !cmd.Flags().Changed("release") && !cmd.Flags().Changed("pre-release") { 60 | cmd.Flags().Set("release", "") 61 | } 62 | 63 | if err := cmdx.MarkFlagsRequireFlag(cmd, "release", "asset"); err != nil { 64 | if err := cmdx.MarkFlagsRequireFlag(cmd, "pre-release", "asset", "strict"); err != nil { 65 | return err 66 | } 67 | } 68 | 69 | return nil 70 | }, 71 | Args: cobra.ExactArgs(1), 72 | RunE: func(cmd *cobra.Command, args []string) error { 73 | pkg := args[0] 74 | 75 | ctx := cmd.Context() 76 | token, _ := gh.GetStoredApiKey(viper.GetViper()) 77 | client := f.Provider(ctx, token).Repos() 78 | 79 | inst := installer.New(client) 80 | 81 | var owner, repo string 82 | var err error 83 | 84 | owner, repo, err = cmdparser.ParseRepoRef(pkg) 85 | if err != nil { 86 | owner, repo, err = cmdparser.ParseGithubUrlPattern(pkg) 87 | if err != nil { 88 | return err 89 | } 90 | } 91 | 92 | var insType manifest.InstallType 93 | var version *string 94 | if release != "" { 95 | insType = manifest.Release 96 | version = &release 97 | } else if pre_release { 98 | insType = manifest.PreRelease 99 | // INFO: do nothing, populate version later 100 | version = nil 101 | } else { 102 | // release == "" 103 | insType = manifest.Release 104 | version = nil 105 | } 106 | 107 | pb := mpb.New(mpb.WithWidth(60)) 108 | 109 | var bar *mpb.Bar 110 | hooks := &progress.Hooks{ 111 | Decorator: func(stage progress.Stage, r io.Reader, total int64) io.Reader { 112 | if stage != progress.StageDownload { 113 | return r 114 | } 115 | if bar != nil { 116 | return bar.ProxyReader(r) 117 | } 118 | bar = pb.AddBar(total, 119 | mpb.PrependDecorators( 120 | // decor.Name("downloading"), 121 | decor.Percentage(decor.WCSyncSpace), 122 | ), 123 | mpb.AppendDecorators( 124 | decor.OnComplete( 125 | decor.EwmaETA(decor.ET_STYLE_GO, 60), "done", 126 | ), 127 | decor.Name(" "), 128 | decor.EwmaSpeed(decor.SizeB1024(0), "% .2f", 60), 129 | decor.Name(" "), 130 | decor.Elapsed(decor.ET_STYLE_GO, decor.WC{W: 5}), 131 | ), 132 | ) 133 | return bar.ProxyReader(r) 134 | }, 135 | Callback: nil, 136 | } 137 | 138 | var ass *string 139 | if asset == "" { 140 | ass = nil 141 | } else { 142 | ass = &asset 143 | } 144 | 145 | opts := installer.InstallFlags{ 146 | Type: insType, 147 | Version: version, 148 | Asset: ass, 149 | Strict: strict, 150 | VerifyLevel: func() uint8 { 151 | if no_verify { 152 | return 0 153 | } 154 | // TODO: change to actual verify-level once implemented 155 | return 1 156 | }(), 157 | } 158 | 159 | if opts.Version == nil { 160 | fmt.Printf("Installing %s/%s::latest\n", owner, repo) 161 | } else { 162 | fmt.Printf("Installing %s/%s::%s\n", owner, repo, *opts.Version) 163 | } 164 | 165 | installPath := parmutil.GetInstallDir(owner, repo) 166 | res, err := inst.Install(ctx, owner, repo, installPath, opts, hooks) 167 | pb.Wait() 168 | if err != nil { 169 | if res == nil { 170 | return err 171 | } 172 | parentDir, cErr := sysutil.GetParentDir(res.InstallPath) 173 | if cErr == nil { 174 | err = parmutil.Cleanup(parentDir) 175 | if err != nil { 176 | return err 177 | } 178 | } 179 | return err 180 | } 181 | 182 | man, err := manifest.New(owner, repo, res.Version, opts.Type, res.InstallPath) 183 | if err != nil { 184 | return fmt.Errorf("error: failed to create manifest: \n%w", err) 185 | } 186 | err = man.Write(res.InstallPath) 187 | if err != nil { 188 | return err 189 | } 190 | 191 | binPaths := man.GetFullExecPaths() 192 | 193 | for _, execPath := range binPaths { 194 | pathToSymLinkTo := parmutil.GetBinDir(filepath.Base(execPath)) 195 | 196 | // TODO: use shims for windows instead? 197 | err = sysutil.SymlinkBinToPath(execPath, pathToSymLinkTo) 198 | if err != nil { 199 | return err 200 | } 201 | deps, err := deps.GetMissingLibs(ctx, execPath) 202 | if err != nil { 203 | return err 204 | } 205 | if len(deps) > 0 { 206 | fmt.Printf("required dependencies found for %s/%s:\n", owner, repo) 207 | for _, dp := range deps { 208 | fmt.Println("\t" + dp) 209 | } 210 | fmt.Println("Note: this is PURELY informational, and does not necessarily mean that your machine doesn't have these dependencies.") 211 | } 212 | } 213 | 214 | fmt.Println() 215 | 216 | return nil 217 | }, 218 | } 219 | 220 | installCmd.Flags().BoolVarP(&pre_release, "pre-release", "p", false, "Installs the latest pre-release binary, if available") 221 | installCmd.Flags().BoolVarP(&strict, "strict", "s", false, "Only available with the --pre-release flag. Will only install pre-release versions and not stable releases.") 222 | installCmd.Flags().BoolVarP(&no_verify, "no-verify", "n", false, "Skips integrity check") 223 | installCmd.Flags().StringVarP(&release, "release", "r", "", "Install binary from this release tag.") 224 | installCmd.Flags().StringVarP(&asset, "asset", "a", "", "Installs a specific asset from a release.") 225 | 226 | installCmd.MarkFlagsMutuallyExclusive("release", "pre-release") 227 | installCmd.MarkFlagsMutuallyExclusive("release", "strict") 228 | 229 | return installCmd 230 | } 231 | -------------------------------------------------------------------------------- /internal/gh/requests_test.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/google/go-github/v74/github" 9 | "github.com/migueleliasweb/go-github-mock/src/mock" 10 | ) 11 | 12 | func TestGetLatestPreRelease(t *testing.T) { 13 | // Create mock client with pre-release data 14 | mockedHTTPClient := mock.NewMockedHTTPClient( 15 | mock.WithRequestMatch( 16 | mock.GetReposReleasesByOwnerByRepo, 17 | []*github.RepositoryRelease{ 18 | { 19 | TagName: github.Ptr("v1.0.1"), 20 | Prerelease: github.Ptr(false), 21 | }, 22 | { 23 | TagName: github.Ptr("v1.0.0-beta"), 24 | Prerelease: github.Ptr(true), 25 | }, 26 | }, 27 | ), 28 | ) 29 | 30 | client := github.NewClient(mockedHTTPClient) 31 | ctx := context.Background() 32 | 33 | rel, err := GetLatestPreRelease(ctx, client.Repositories, "owner", "repo") 34 | if err != nil { 35 | t.Fatalf("GetLatestPreRelease() error: %v", err) 36 | } 37 | 38 | if rel == nil { 39 | t.Fatal("GetLatestPreRelease() returned nil") 40 | } 41 | 42 | if !rel.GetPrerelease() { 43 | t.Error("GetLatestPreRelease() returned non-prerelease") 44 | } 45 | 46 | if rel.GetTagName() != "v1.0.0-beta" { 47 | t.Errorf("GetLatestPreRelease() tag = %v, want v1.0.0-beta", rel.GetTagName()) 48 | } 49 | } 50 | 51 | func TestGetLatestPreRelease_NoPreRelease(t *testing.T) { 52 | mockedHTTPClient := mock.NewMockedHTTPClient( 53 | mock.WithRequestMatch( 54 | mock.GetReposReleasesByOwnerByRepo, 55 | []*github.RepositoryRelease{ 56 | { 57 | TagName: github.Ptr("v1.0.0"), 58 | Prerelease: github.Ptr(false), 59 | }, 60 | }, 61 | ), 62 | ) 63 | 64 | client := github.NewClient(mockedHTTPClient) 65 | ctx := context.Background() 66 | 67 | rel, err := GetLatestPreRelease(ctx, client.Repositories, "owner", "repo") 68 | if err != nil { 69 | t.Fatalf("GetLatestPreRelease() error: %v", err) 70 | } 71 | 72 | if rel != nil { 73 | t.Error("GetLatestPreRelease() should return nil when no pre-release exists") 74 | } 75 | } 76 | 77 | func TestResolvePreRelease(t *testing.T) { 78 | mockedHTTPClient := mock.NewMockedHTTPClient( 79 | mock.WithRequestMatch( 80 | mock.GetReposReleasesByOwnerByRepo, 81 | []*github.RepositoryRelease{ 82 | { 83 | TagName: github.Ptr("v2.0.0-alpha"), 84 | Prerelease: github.Ptr(true), 85 | }, 86 | }, 87 | ), 88 | ) 89 | 90 | client := github.NewClient(mockedHTTPClient) 91 | ctx := context.Background() 92 | 93 | rel, err := ResolvePreRelease(ctx, client.Repositories, "owner", "repo") 94 | if err != nil { 95 | t.Fatalf("ResolvePreRelease() error: %v", err) 96 | } 97 | 98 | if rel == nil { 99 | t.Fatal("ResolvePreRelease() returned nil") 100 | } 101 | 102 | if rel.GetTagName() != "v2.0.0-alpha" { 103 | t.Errorf("ResolvePreRelease() tag = %v, want v2.0.0-alpha", rel.GetTagName()) 104 | } 105 | } 106 | 107 | func TestResolvePreRelease_NoPreRelease(t *testing.T) { 108 | mockedHTTPClient := mock.NewMockedHTTPClient( 109 | mock.WithRequestMatch( 110 | mock.GetReposReleasesByOwnerByRepo, 111 | []*github.RepositoryRelease{}, 112 | ), 113 | ) 114 | 115 | client := github.NewClient(mockedHTTPClient) 116 | ctx := context.Background() 117 | 118 | _, err := ResolvePreRelease(ctx, client.Repositories, "owner", "repo") 119 | if err == nil { 120 | t.Error("ResolvePreRelease() should return error when no pre-release found") 121 | } 122 | } 123 | 124 | func TestResolveReleaseByTag_SpecificTag(t *testing.T) { 125 | tag := "v1.0.0" 126 | 127 | mockedHTTPClient := mock.NewMockedHTTPClient( 128 | mock.WithRequestMatch( 129 | mock.GetReposReleasesTagsByOwnerByRepoByTag, 130 | &github.RepositoryRelease{ 131 | TagName: github.Ptr(tag), 132 | Prerelease: github.Ptr(false), 133 | }, 134 | ), 135 | ) 136 | 137 | client := github.NewClient(mockedHTTPClient) 138 | ctx := context.Background() 139 | 140 | rel, err := ResolveReleaseByTag(ctx, client.Repositories, "owner", "repo", &tag) 141 | if err != nil { 142 | t.Fatalf("ResolveReleaseByTag() error: %v", err) 143 | } 144 | 145 | if rel == nil { 146 | t.Fatal("ResolveReleaseByTag() returned nil") 147 | } 148 | 149 | if rel.GetTagName() != tag { 150 | t.Errorf("ResolveReleaseByTag() tag = %v, want %v", rel.GetTagName(), tag) 151 | } 152 | } 153 | 154 | func TestResolveReleaseByTag_LatestRelease(t *testing.T) { 155 | mockedHTTPClient := mock.NewMockedHTTPClient( 156 | mock.WithRequestMatch( 157 | mock.GetReposReleasesLatestByOwnerByRepo, 158 | &github.RepositoryRelease{ 159 | TagName: github.Ptr("v2.0.0"), 160 | Prerelease: github.Ptr(false), 161 | }, 162 | ), 163 | ) 164 | 165 | client := github.NewClient(mockedHTTPClient) 166 | ctx := context.Background() 167 | 168 | rel, err := ResolveReleaseByTag(ctx, client.Repositories, "owner", "repo", nil) 169 | if err != nil { 170 | t.Fatalf("ResolveReleaseByTag() error: %v", err) 171 | } 172 | 173 | if rel == nil { 174 | t.Fatal("ResolveReleaseByTag() returned nil") 175 | } 176 | 177 | if rel.GetTagName() != "v2.0.0" { 178 | t.Errorf("ResolveReleaseByTag() tag = %v, want v2.0.0", rel.GetTagName()) 179 | } 180 | } 181 | 182 | func TestResolveReleaseByTag_NonExistentTag(t *testing.T) { 183 | tag := "v99.99.99" 184 | 185 | mockedHTTPClient := mock.NewMockedHTTPClient( 186 | mock.WithRequestMatchHandler( 187 | mock.GetReposReleasesTagsByOwnerByRepoByTag, 188 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 189 | w.WriteHeader(http.StatusNotFound) 190 | w.Write([]byte(`{"message": "Not Found"}`)) 191 | }), 192 | ), 193 | ) 194 | 195 | client := github.NewClient(mockedHTTPClient) 196 | ctx := context.Background() 197 | 198 | _, err := ResolveReleaseByTag(ctx, client.Repositories, "owner", "repo", &tag) 199 | if err == nil { 200 | t.Error("ResolveReleaseByTag() should return error for non-existent tag") 201 | } 202 | } 203 | 204 | func TestValidatePreRelease(t *testing.T) { 205 | mockedHTTPClient := mock.NewMockedHTTPClient( 206 | mock.WithRequestMatch( 207 | mock.GetReposReleasesByOwnerByRepo, 208 | []*github.RepositoryRelease{ 209 | { 210 | TagName: github.Ptr("v1.0.0-rc1"), 211 | Prerelease: github.Ptr(true), 212 | }, 213 | }, 214 | ), 215 | ) 216 | 217 | client := github.NewClient(mockedHTTPClient) 218 | ctx := context.Background() 219 | 220 | valid, rel, err := validatePreRelease(ctx, client.Repositories, "owner", "repo") 221 | if err != nil { 222 | t.Fatalf("validatePreRelease() error: %v", err) 223 | } 224 | 225 | if !valid { 226 | t.Error("validatePreRelease() returned false for valid pre-release") 227 | } 228 | 229 | if rel == nil { 230 | t.Error("validatePreRelease() returned nil release") 231 | } 232 | } 233 | 234 | func TestValidateRelease(t *testing.T) { 235 | tag := "v1.0.0" 236 | 237 | mockedHTTPClient := mock.NewMockedHTTPClient( 238 | mock.WithRequestMatch( 239 | mock.GetReposReleasesTagsByOwnerByRepoByTag, 240 | &github.RepositoryRelease{ 241 | TagName: github.Ptr(tag), 242 | }, 243 | ), 244 | ) 245 | 246 | client := github.NewClient(mockedHTTPClient) 247 | ctx := context.Background() 248 | 249 | valid, rel, err := validateRelease(ctx, client.Repositories, "owner", "repo", tag) 250 | if err != nil { 251 | t.Fatalf("validateRelease() error: %v", err) 252 | } 253 | 254 | if !valid { 255 | t.Error("validateRelease() returned false for valid release") 256 | } 257 | 258 | if rel == nil { 259 | t.Error("validateRelease() returned nil release") 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /pkg/archive/extract_test.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "archive/tar" 5 | "archive/zip" 6 | "compress/gzip" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | "testing" 11 | ) 12 | 13 | func TestExtractTarGz_Valid(t *testing.T) { 14 | tmpDir := t.TempDir() 15 | 16 | // Create a test tar.gz file 17 | tarPath := filepath.Join(tmpDir, "test.tar.gz") 18 | createTestTarGz(t, tarPath, map[string]string{ 19 | "file1.txt": "content1", 20 | "dir/file2.txt": "content2", 21 | }) 22 | 23 | // Extract 24 | extractDir := filepath.Join(tmpDir, "extracted") 25 | err := ExtractTarGz(tarPath, extractDir) 26 | if err != nil { 27 | t.Fatalf("ExtractTarGz failed: %v", err) 28 | } 29 | 30 | // Verify extracted files 31 | content1, err := os.ReadFile(filepath.Join(extractDir, "file1.txt")) 32 | if err != nil || string(content1) != "content1" { 33 | t.Errorf("file1.txt not extracted correctly") 34 | } 35 | 36 | content2, err := os.ReadFile(filepath.Join(extractDir, "dir", "file2.txt")) 37 | if err != nil || string(content2) != "content2" { 38 | t.Errorf("dir/file2.txt not extracted correctly") 39 | } 40 | } 41 | 42 | func TestExtractTarGz_PathTraversal(t *testing.T) { 43 | tmpDir := t.TempDir() 44 | 45 | // Create a malicious tar.gz with path traversal 46 | tarPath := filepath.Join(tmpDir, "evil.tar.gz") 47 | f, err := os.Create(tarPath) 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | defer f.Close() 52 | 53 | gw := gzip.NewWriter(f) 54 | tw := tar.NewWriter(gw) 55 | 56 | // Try to write outside extraction directory 57 | hdr := &tar.Header{ 58 | Name: "../../../etc/evil.txt", 59 | Mode: 0644, 60 | Size: 4, 61 | } 62 | tw.WriteHeader(hdr) 63 | tw.Write([]byte("evil")) 64 | tw.Close() 65 | gw.Close() 66 | 67 | // Attempt extraction 68 | extractDir := filepath.Join(tmpDir, "extracted") 69 | err = ExtractTarGz(tarPath, extractDir) 70 | 71 | // Should fail due to path traversal protection 72 | if err == nil { 73 | t.Error("Expected error for path traversal, got nil") 74 | } 75 | } 76 | 77 | func TestExtractTarGz_InvalidFile(t *testing.T) { 78 | tmpDir := t.TempDir() 79 | 80 | // Create invalid tar.gz 81 | invalidPath := filepath.Join(tmpDir, "invalid.tar.gz") 82 | os.WriteFile(invalidPath, []byte("not a tar.gz file"), 0644) 83 | 84 | extractDir := filepath.Join(tmpDir, "extracted") 85 | err := ExtractTarGz(invalidPath, extractDir) 86 | 87 | if err == nil { 88 | t.Error("Expected error for invalid tar.gz, got nil") 89 | } 90 | } 91 | 92 | func TestExtractZip_Valid(t *testing.T) { 93 | tmpDir := t.TempDir() 94 | 95 | // Create a test zip file 96 | zipPath := filepath.Join(tmpDir, "test.zip") 97 | createTestZip(t, zipPath, map[string]string{ 98 | "file1.txt": "content1", 99 | "dir/file2.txt": "content2", 100 | }) 101 | 102 | // Extract 103 | extractDir := filepath.Join(tmpDir, "extracted") 104 | err := ExtractZip(zipPath, extractDir) 105 | if err != nil { 106 | t.Fatalf("ExtractZip failed: %v", err) 107 | } 108 | 109 | // Verify extracted files 110 | content1, err := os.ReadFile(filepath.Join(extractDir, "file1.txt")) 111 | if err != nil || string(content1) != "content1" { 112 | t.Errorf("file1.txt not extracted correctly") 113 | } 114 | 115 | content2, err := os.ReadFile(filepath.Join(extractDir, "dir", "file2.txt")) 116 | if err != nil || string(content2) != "content2" { 117 | t.Errorf("dir/file2.txt not extracted correctly") 118 | } 119 | } 120 | 121 | func TestExtractZip_PathTraversal(t *testing.T) { 122 | tmpDir := t.TempDir() 123 | 124 | // Create a malicious zip with path traversal 125 | zipPath := filepath.Join(tmpDir, "evil.zip") 126 | f, err := os.Create(zipPath) 127 | if err != nil { 128 | t.Fatal(err) 129 | } 130 | defer f.Close() 131 | 132 | zw := zip.NewWriter(f) 133 | 134 | // Try to write outside extraction directory 135 | fw, _ := zw.Create("../../../etc/evil.txt") 136 | fw.Write([]byte("evil")) 137 | zw.Close() 138 | 139 | // Attempt extraction 140 | extractDir := filepath.Join(tmpDir, "extracted") 141 | err = ExtractZip(zipPath, extractDir) 142 | 143 | // Should fail due to path traversal protection 144 | if err == nil { 145 | t.Error("Expected error for path traversal, got nil") 146 | } 147 | } 148 | 149 | func TestExtractZip_NestedDirectories(t *testing.T) { 150 | tmpDir := t.TempDir() 151 | 152 | // Create zip with nested directories 153 | zipPath := filepath.Join(tmpDir, "nested.zip") 154 | createTestZip(t, zipPath, map[string]string{ 155 | "a/b/c/file.txt": "nested", 156 | }) 157 | 158 | extractDir := filepath.Join(tmpDir, "extracted") 159 | err := ExtractZip(zipPath, extractDir) 160 | if err != nil { 161 | t.Fatalf("ExtractZip failed: %v", err) 162 | } 163 | 164 | content, err := os.ReadFile(filepath.Join(extractDir, "a", "b", "c", "file.txt")) 165 | if err != nil || string(content) != "nested" { 166 | t.Errorf("nested file not extracted correctly") 167 | } 168 | } 169 | 170 | func TestExtractZip_InvalidFile(t *testing.T) { 171 | tmpDir := t.TempDir() 172 | 173 | // Create invalid zip 174 | invalidPath := filepath.Join(tmpDir, "invalid.zip") 175 | os.WriteFile(invalidPath, []byte("not a zip file"), 0644) 176 | 177 | extractDir := filepath.Join(tmpDir, "extracted") 178 | err := ExtractZip(invalidPath, extractDir) 179 | 180 | if err == nil { 181 | t.Error("Expected error for invalid zip, got nil") 182 | } 183 | } 184 | 185 | func TestExtractTarGz_Permissions(t *testing.T) { 186 | if runtime.GOOS == "windows" { 187 | t.Skip("Skipping permission test on Windows") 188 | } 189 | 190 | tmpDir := t.TempDir() 191 | 192 | // Create tar.gz with specific permissions 193 | tarPath := filepath.Join(tmpDir, "perms.tar.gz") 194 | f, _ := os.Create(tarPath) 195 | defer f.Close() 196 | 197 | gw := gzip.NewWriter(f) 198 | tw := tar.NewWriter(gw) 199 | 200 | hdr := &tar.Header{ 201 | Name: "executable.sh", 202 | Mode: 0755, 203 | Size: 4, 204 | } 205 | tw.WriteHeader(hdr) 206 | tw.Write([]byte("test")) 207 | tw.Close() 208 | gw.Close() 209 | 210 | extractDir := filepath.Join(tmpDir, "extracted") 211 | err := ExtractTarGz(tarPath, extractDir) 212 | if err != nil { 213 | t.Fatalf("ExtractTarGz failed: %v", err) 214 | } 215 | 216 | info, err := os.Stat(filepath.Join(extractDir, "executable.sh")) 217 | if err != nil { 218 | t.Fatal(err) 219 | } 220 | 221 | if info.Mode()&0111 == 0 { 222 | t.Error("Executable permissions not preserved") 223 | } 224 | } 225 | 226 | // Helper functions 227 | func createTestTarGz(t *testing.T, path string, files map[string]string) { 228 | f, err := os.Create(path) 229 | if err != nil { 230 | t.Fatal(err) 231 | } 232 | defer f.Close() 233 | 234 | gw := gzip.NewWriter(f) 235 | defer gw.Close() 236 | 237 | tw := tar.NewWriter(gw) 238 | defer tw.Close() 239 | 240 | for name, content := range files { 241 | hdr := &tar.Header{ 242 | Name: name, 243 | Mode: 0644, 244 | Size: int64(len(content)), 245 | } 246 | if err := tw.WriteHeader(hdr); err != nil { 247 | t.Fatal(err) 248 | } 249 | if _, err := tw.Write([]byte(content)); err != nil { 250 | t.Fatal(err) 251 | } 252 | } 253 | } 254 | 255 | func createTestZip(t *testing.T, path string, files map[string]string) { 256 | f, err := os.Create(path) 257 | if err != nil { 258 | t.Fatal(err) 259 | } 260 | defer f.Close() 261 | 262 | zw := zip.NewWriter(f) 263 | defer zw.Close() 264 | 265 | for name, content := range files { 266 | // Create file header with proper permissions 267 | fh := &zip.FileHeader{ 268 | Name: name, 269 | Method: zip.Deflate, 270 | } 271 | fh.SetMode(0644) // Set readable permissions 272 | 273 | fw, err := zw.CreateHeader(fh) 274 | if err != nil { 275 | t.Fatal(err) 276 | } 277 | if _, err := fw.Write([]byte(content)); err != nil { 278 | t.Fatal(err) 279 | } 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /internal/core/installer/release_test.go: -------------------------------------------------------------------------------- 1 | package installer 2 | 3 | import ( 4 | "runtime" 5 | "testing" 6 | 7 | "github.com/google/go-github/v74/github" 8 | ) 9 | 10 | func TestSelectReleaseAsset_LinuxAmd64(t *testing.T) { 11 | if runtime.GOOS != "linux" { 12 | t.Skip("Skipping Linux-specific test") 13 | } 14 | 15 | assets := []*github.ReleaseAsset{ 16 | {Name: github.Ptr("app-linux-amd64.tar.gz")}, 17 | {Name: github.Ptr("app-darwin-amd64.tar.gz")}, 18 | {Name: github.Ptr("app-windows-amd64.zip")}, 19 | } 20 | 21 | matches, err := selectReleaseAsset(assets, "linux", "amd64") 22 | if err != nil { 23 | t.Fatalf("selectReleaseAsset() error: %v", err) 24 | } 25 | 26 | if len(matches) == 0 { 27 | t.Fatal("selectReleaseAsset() returned no matches") 28 | } 29 | 30 | // Should select Linux asset 31 | if matches[0].GetName() != "app-linux-amd64.tar.gz" { 32 | t.Errorf("selectReleaseAsset() = %v, want app-linux-amd64.tar.gz", matches[0].GetName()) 33 | } 34 | } 35 | 36 | func TestSelectReleaseAsset_DarwinArm64(t *testing.T) { 37 | assets := []*github.ReleaseAsset{ 38 | {Name: github.Ptr("app-linux-amd64.tar.gz")}, 39 | {Name: github.Ptr("app-darwin-arm64.tar.gz")}, 40 | {Name: github.Ptr("app-darwin-amd64.tar.gz")}, 41 | } 42 | 43 | matches, err := selectReleaseAsset(assets, "darwin", "arm64") 44 | if err != nil { 45 | t.Fatalf("selectReleaseAsset() error: %v", err) 46 | } 47 | 48 | if len(matches) == 0 { 49 | t.Fatal("selectReleaseAsset() returned no matches") 50 | } 51 | 52 | // Should select macOS ARM64 asset 53 | if matches[0].GetName() != "app-darwin-arm64.tar.gz" { 54 | t.Errorf("selectReleaseAsset() = %v, want app-darwin-arm64.tar.gz", matches[0].GetName()) 55 | } 56 | } 57 | 58 | func TestSelectReleaseAsset_WindowsAmd64(t *testing.T) { 59 | assets := []*github.ReleaseAsset{ 60 | {Name: github.Ptr("app-linux-amd64.tar.gz")}, 61 | {Name: github.Ptr("app-windows-amd64.zip")}, 62 | {Name: github.Ptr("app-darwin-amd64.tar.gz")}, 63 | } 64 | 65 | matches, err := selectReleaseAsset(assets, "windows", "amd64") 66 | if err != nil { 67 | t.Fatalf("selectReleaseAsset() error: %v", err) 68 | } 69 | 70 | if len(matches) == 0 { 71 | t.Fatal("selectReleaseAsset() returned no matches") 72 | } 73 | 74 | // Should select Windows asset (zip preferred) 75 | if matches[0].GetName() != "app-windows-amd64.zip" { 76 | t.Errorf("selectReleaseAsset() = %v, want app-windows-amd64.zip", matches[0].GetName()) 77 | } 78 | } 79 | 80 | func TestSelectReleaseAsset_PreferTarGz(t *testing.T) { 81 | assets := []*github.ReleaseAsset{ 82 | {Name: github.Ptr("app-linux-amd64.zip")}, 83 | {Name: github.Ptr("app-linux-amd64.tar.gz")}, 84 | } 85 | 86 | matches, err := selectReleaseAsset(assets, "linux", "amd64") 87 | if err != nil { 88 | t.Fatalf("selectReleaseAsset() error: %v", err) 89 | } 90 | 91 | if len(matches) == 0 { 92 | t.Fatal("selectReleaseAsset() returned no matches") 93 | } 94 | 95 | // Should prefer .tar.gz over .zip on Linux 96 | if matches[0].GetName() != "app-linux-amd64.tar.gz" { 97 | t.Errorf("selectReleaseAsset() = %v, want app-linux-amd64.tar.gz", matches[0].GetName()) 98 | } 99 | } 100 | 101 | func TestSelectReleaseAsset_NoMatch(t *testing.T) { 102 | assets := []*github.ReleaseAsset{ 103 | {Name: github.Ptr("app-linux-amd64.tar.gz")}, 104 | {Name: github.Ptr("app-darwin-amd64.tar.gz")}, 105 | } 106 | 107 | // Request Windows asset when only Linux/Darwin available 108 | matches, err := selectReleaseAsset(assets, "windows", "amd64") 109 | if err != nil { 110 | t.Fatalf("selectReleaseAsset() error: %v", err) 111 | } 112 | 113 | // Should return empty or low-score matches 114 | if len(matches) > 0 { 115 | t.Logf("selectReleaseAsset() returned %d matches (may have low scores)", len(matches)) 116 | } 117 | } 118 | 119 | func TestSelectReleaseAsset_AlternativeNames(t *testing.T) { 120 | assets := []*github.ReleaseAsset{ 121 | {Name: github.Ptr("app-macos-x86_64.tar.gz")}, 122 | {Name: github.Ptr("app-linux-x86_64.tar.gz")}, 123 | } 124 | 125 | // Test alternative OS/arch names 126 | matches, err := selectReleaseAsset(assets, "darwin", "amd64") 127 | if err != nil { 128 | t.Fatalf("selectReleaseAsset() error: %v", err) 129 | } 130 | 131 | if len(matches) == 0 { 132 | t.Fatal("selectReleaseAsset() returned no matches") 133 | } 134 | 135 | // Should match "macos" and "x86_64" 136 | if matches[0].GetName() != "app-macos-x86_64.tar.gz" { 137 | t.Errorf("selectReleaseAsset() = %v, want app-macos-x86_64.tar.gz", matches[0].GetName()) 138 | } 139 | } 140 | 141 | func TestSelectReleaseAsset_MuslPenalty(t *testing.T) { 142 | assets := []*github.ReleaseAsset{ 143 | {Name: github.Ptr("app-linux-amd64-musl.tar.gz")}, 144 | {Name: github.Ptr("app-linux-amd64.tar.gz")}, 145 | } 146 | 147 | matches, err := selectReleaseAsset(assets, "linux", "amd64") 148 | if err != nil { 149 | t.Fatalf("selectReleaseAsset() error: %v", err) 150 | } 151 | 152 | if len(matches) == 0 { 153 | t.Fatal("selectReleaseAsset() returned no matches") 154 | } 155 | 156 | // Should prefer non-musl version 157 | if matches[0].GetName() != "app-linux-amd64.tar.gz" { 158 | t.Errorf("selectReleaseAsset() = %v, want app-linux-amd64.tar.gz (non-musl)", matches[0].GetName()) 159 | } 160 | } 161 | 162 | func TestGetAssetByName(t *testing.T) { 163 | rel := &github.RepositoryRelease{ 164 | Assets: []*github.ReleaseAsset{ 165 | {Name: github.Ptr("asset1.tar.gz")}, 166 | {Name: github.Ptr("asset2.zip")}, 167 | }, 168 | } 169 | 170 | asset, err := getAssetByName(rel, "asset1.tar.gz") 171 | if err != nil { 172 | t.Fatalf("getAssetByName() error: %v", err) 173 | } 174 | 175 | if asset == nil { 176 | t.Fatal("getAssetByName() returned nil") 177 | } 178 | 179 | if asset.GetName() != "asset1.tar.gz" { 180 | t.Errorf("getAssetByName() = %v, want asset1.tar.gz", asset.GetName()) 181 | } 182 | } 183 | 184 | func TestGetAssetByName_NotFound(t *testing.T) { 185 | rel := &github.RepositoryRelease{ 186 | Assets: []*github.ReleaseAsset{ 187 | {Name: github.Ptr("asset1.tar.gz")}, 188 | }, 189 | } 190 | 191 | _, err := getAssetByName(rel, "nonexistent.zip") 192 | if err == nil { 193 | t.Error("getAssetByName() should return error for non-existent asset") 194 | } 195 | } 196 | 197 | func TestContainsAny(t *testing.T) { 198 | tests := []struct { 199 | name string 200 | src string 201 | tokens []string 202 | want bool 203 | }{ 204 | {"match first", "app-linux-amd64", []string{"linux", "darwin"}, true}, 205 | {"match second", "app-darwin-arm64", []string{"linux", "darwin"}, true}, 206 | {"no match", "app-windows-amd64", []string{"linux", "darwin"}, false}, 207 | {"empty tokens", "app-linux-amd64", []string{}, false}, 208 | {"empty src", "", []string{"linux"}, false}, 209 | {"partial match", "app-x86_64", []string{"x86_64", "amd64"}, true}, 210 | } 211 | 212 | for _, tt := range tests { 213 | t.Run(tt.name, func(t *testing.T) { 214 | got := containsAny(tt.src, tt.tokens) 215 | if got != tt.want { 216 | t.Errorf("containsAny(%q, %v) = %v, want %v", tt.src, tt.tokens, got, tt.want) 217 | } 218 | }) 219 | } 220 | } 221 | 222 | func TestSelectReleaseAsset_Arm32(t *testing.T) { 223 | assets := []*github.ReleaseAsset{ 224 | {Name: github.Ptr("app-linux-armv7.tar.gz")}, 225 | {Name: github.Ptr("app-linux-arm64.tar.gz")}, 226 | {Name: github.Ptr("app-linux-amd64.tar.gz")}, 227 | } 228 | 229 | matches, err := selectReleaseAsset(assets, "linux", "arm") 230 | if err != nil { 231 | t.Fatalf("selectReleaseAsset() error: %v", err) 232 | } 233 | 234 | if len(matches) == 0 { 235 | t.Fatal("selectReleaseAsset() returned no matches") 236 | } 237 | 238 | // Should select ARM32 variant 239 | if matches[0].GetName() != "app-linux-armv7.tar.gz" { 240 | t.Errorf("selectReleaseAsset() = %v, want app-linux-armv7.tar.gz", matches[0].GetName()) 241 | } 242 | } 243 | 244 | func TestSelectReleaseAsset_MultipleMatches(t *testing.T) { 245 | assets := []*github.ReleaseAsset{ 246 | {Name: github.Ptr("app-linux-amd64.tar.gz")}, 247 | {Name: github.Ptr("app-linux-x86_64.tar.gz")}, 248 | } 249 | 250 | matches, err := selectReleaseAsset(assets, "linux", "amd64") 251 | if err != nil { 252 | t.Fatalf("selectReleaseAsset() error: %v", err) 253 | } 254 | 255 | if len(matches) == 0 { 256 | t.Fatal("selectReleaseAsset() returned no matches") 257 | } 258 | 259 | // Both should match, function returns top candidates 260 | t.Logf("Found %d matches", len(matches)) 261 | } 262 | -------------------------------------------------------------------------------- /internal/parmutil/package_test.go: -------------------------------------------------------------------------------- 1 | package parmutil 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | "parm/internal/config" 10 | ) 11 | 12 | func TestGetInstallDir(t *testing.T) { 13 | // Setup config 14 | tmpDir := t.TempDir() 15 | config.Cfg.ParmPkgPath = tmpDir 16 | 17 | owner := "testowner" 18 | repo := "testrepo" 19 | 20 | dir := GetInstallDir(owner, repo) 21 | 22 | if dir == "" { 23 | t.Error("GetInstallDir() returned empty string") 24 | } 25 | 26 | // Should contain both owner and repo 27 | if !strings.Contains(dir, owner) || !strings.Contains(dir, repo) { 28 | t.Errorf("GetInstallDir() = %v, should contain %v and %v", dir, owner, repo) 29 | } 30 | 31 | // Should be under tmpDir 32 | if !strings.HasPrefix(dir, tmpDir) { 33 | t.Errorf("GetInstallDir() = %v, should be under %v", dir, tmpDir) 34 | } 35 | } 36 | 37 | func TestGetBinDir(t *testing.T) { 38 | // Setup config 39 | tmpDir := t.TempDir() 40 | config.Cfg.ParmBinPath = tmpDir 41 | 42 | repoName := "testrepo" 43 | 44 | dir := GetBinDir(repoName) 45 | 46 | if dir == "" { 47 | t.Error("GetBinDir() returned empty string") 48 | } 49 | 50 | // Should contain repo name 51 | if !strings.Contains(dir, repoName) { 52 | t.Errorf("GetBinDir() = %v, should contain %v", dir, repoName) 53 | } 54 | 55 | // Should be under tmpDir 56 | if !strings.HasPrefix(dir, tmpDir) { 57 | t.Errorf("GetBinDir() = %v, should be under %v", dir, tmpDir) 58 | } 59 | } 60 | 61 | func TestMakeInstallDir(t *testing.T) { 62 | tmpDir := t.TempDir() 63 | config.Cfg.ParmPkgPath = tmpDir 64 | 65 | owner := "testowner" 66 | repo := "testrepo" 67 | 68 | dir, err := MakeInstallDir(owner, repo, 0755) 69 | if err != nil { 70 | t.Fatalf("MakeInstallDir() error: %v", err) 71 | } 72 | 73 | if dir == "" { 74 | t.Error("MakeInstallDir() returned empty string") 75 | } 76 | 77 | // Verify directory was created 78 | info, err := os.Stat(dir) 79 | if err != nil { 80 | t.Fatalf("Directory not created: %v", err) 81 | } 82 | 83 | if !info.IsDir() { 84 | t.Error("MakeInstallDir() did not create a directory") 85 | } 86 | } 87 | 88 | func TestMakeStagingDir(t *testing.T) { 89 | tmpDir := t.TempDir() 90 | config.Cfg.ParmPkgPath = tmpDir 91 | 92 | owner := "testowner" 93 | repo := "testrepo" 94 | 95 | stagingDir, err := MakeStagingDir(owner, repo) 96 | if err != nil { 97 | t.Fatalf("MakeStagingDir() error: %v", err) 98 | } 99 | 100 | if stagingDir == "" { 101 | t.Error("MakeStagingDir() returned empty string") 102 | } 103 | 104 | // Verify directory was created 105 | info, err := os.Stat(stagingDir) 106 | if err != nil { 107 | t.Fatalf("Staging directory not created: %v", err) 108 | } 109 | 110 | if !info.IsDir() { 111 | t.Error("MakeStagingDir() did not create a directory") 112 | } 113 | 114 | // Should have staging prefix 115 | if !strings.Contains(filepath.Base(stagingDir), STAGING_DIR_PREFIX) { 116 | t.Errorf("Staging dir name should contain %v", STAGING_DIR_PREFIX) 117 | } 118 | 119 | // Clean up 120 | os.RemoveAll(stagingDir) 121 | } 122 | 123 | func TestMakeStagingDir_UniqueNames(t *testing.T) { 124 | tmpDir := t.TempDir() 125 | config.Cfg.ParmPkgPath = tmpDir 126 | 127 | owner := "testowner" 128 | repo := "testrepo" 129 | 130 | // Create two staging directories 131 | dir1, err := MakeStagingDir(owner, repo) 132 | if err != nil { 133 | t.Fatal(err) 134 | } 135 | 136 | dir2, err := MakeStagingDir(owner, repo) 137 | if err != nil { 138 | t.Fatal(err) 139 | } 140 | 141 | // Should be different 142 | if dir1 == dir2 { 143 | t.Error("MakeStagingDir() should create unique directories") 144 | } 145 | 146 | // Clean up 147 | os.RemoveAll(dir1) 148 | os.RemoveAll(dir2) 149 | } 150 | 151 | func TestPromoteStagingDir(t *testing.T) { 152 | tmpDir := t.TempDir() 153 | 154 | // Create staging directory 155 | stagingDir := filepath.Join(tmpDir, "staging") 156 | os.MkdirAll(stagingDir, 0755) 157 | 158 | // Create file in staging 159 | testFile := filepath.Join(stagingDir, "test.txt") 160 | os.WriteFile(testFile, []byte("test content"), 0644) 161 | 162 | // Promote to final location 163 | finalDir := filepath.Join(tmpDir, "final") 164 | result, err := PromoteStagingDir(finalDir, stagingDir) 165 | if err != nil { 166 | t.Fatalf("PromoteStagingDir() error: %v", err) 167 | } 168 | 169 | if result != finalDir { 170 | t.Errorf("PromoteStagingDir() = %v, want %v", result, finalDir) 171 | } 172 | 173 | // Verify final directory exists 174 | if _, err := os.Stat(finalDir); os.IsNotExist(err) { 175 | t.Error("Final directory was not created") 176 | } 177 | 178 | // Verify staging directory no longer exists 179 | if _, err := os.Stat(stagingDir); !os.IsNotExist(err) { 180 | t.Error("Staging directory still exists after promotion") 181 | } 182 | 183 | // Verify file was moved 184 | movedFile := filepath.Join(finalDir, "test.txt") 185 | content, err := os.ReadFile(movedFile) 186 | if err != nil || string(content) != "test content" { 187 | t.Error("File was not moved correctly") 188 | } 189 | } 190 | 191 | func TestPromoteStagingDir_OverwriteExisting(t *testing.T) { 192 | tmpDir := t.TempDir() 193 | 194 | // Create existing final directory 195 | finalDir := filepath.Join(tmpDir, "final") 196 | os.MkdirAll(finalDir, 0755) 197 | oldFile := filepath.Join(finalDir, "old.txt") 198 | os.WriteFile(oldFile, []byte("old content"), 0644) 199 | 200 | // Create staging directory 201 | stagingDir := filepath.Join(tmpDir, "staging") 202 | os.MkdirAll(stagingDir, 0755) 203 | newFile := filepath.Join(stagingDir, "new.txt") 204 | os.WriteFile(newFile, []byte("new content"), 0644) 205 | 206 | // Promote (should overwrite) 207 | _, err := PromoteStagingDir(finalDir, stagingDir) 208 | if err != nil { 209 | t.Fatalf("PromoteStagingDir() error: %v", err) 210 | } 211 | 212 | // Old file should not exist 213 | if _, err := os.Stat(oldFile); !os.IsNotExist(err) { 214 | t.Error("Old file still exists after promotion") 215 | } 216 | 217 | // New file should exist 218 | movedFile := filepath.Join(finalDir, "new.txt") 219 | if _, err := os.Stat(movedFile); os.IsNotExist(err) { 220 | t.Error("New file was not moved") 221 | } 222 | } 223 | 224 | func TestCleanup(t *testing.T) { 225 | tmpDir := t.TempDir() 226 | 227 | // Create owner directory 228 | ownerDir := filepath.Join(tmpDir, "owner") 229 | os.MkdirAll(ownerDir, 0755) 230 | 231 | // Create staging directories 232 | stagingDir1 := filepath.Join(ownerDir, STAGING_DIR_PREFIX+"repo1") 233 | stagingDir2 := filepath.Join(ownerDir, STAGING_DIR_PREFIX+"repo2") 234 | os.MkdirAll(stagingDir1, 0755) 235 | os.MkdirAll(stagingDir2, 0755) 236 | 237 | // Create non-staging directory 238 | normalDir := filepath.Join(ownerDir, "normalrepo") 239 | os.MkdirAll(normalDir, 0755) 240 | 241 | // Run cleanup 242 | err := Cleanup(ownerDir) 243 | if err != nil { 244 | t.Fatalf("Cleanup() error: %v", err) 245 | } 246 | 247 | // Staging directories should be removed 248 | if _, err := os.Stat(stagingDir1); !os.IsNotExist(err) { 249 | t.Error("Staging directory 1 still exists after cleanup") 250 | } 251 | if _, err := os.Stat(stagingDir2); !os.IsNotExist(err) { 252 | t.Error("Staging directory 2 still exists after cleanup") 253 | } 254 | 255 | // Normal directory should still exist 256 | if _, err := os.Stat(normalDir); os.IsNotExist(err) { 257 | t.Error("Normal directory was removed by cleanup") 258 | } 259 | } 260 | 261 | func TestCleanup_EmptyDirectory(t *testing.T) { 262 | tmpDir := t.TempDir() 263 | 264 | // Create owner directory with only staging dirs 265 | ownerDir := filepath.Join(tmpDir, "owner") 266 | os.MkdirAll(ownerDir, 0755) 267 | 268 | stagingDir := filepath.Join(ownerDir, STAGING_DIR_PREFIX+"repo") 269 | os.MkdirAll(stagingDir, 0755) 270 | 271 | // Run cleanup 272 | err := Cleanup(ownerDir) 273 | if err != nil { 274 | t.Fatalf("Cleanup() error: %v", err) 275 | } 276 | 277 | // Owner directory should be removed if empty after cleanup 278 | if _, err := os.Stat(ownerDir); !os.IsNotExist(err) { 279 | t.Log("Owner directory still exists (acceptable)") 280 | } 281 | } 282 | 283 | func TestCleanup_NonExistentDir(t *testing.T) { 284 | err := Cleanup("/nonexistent/directory") 285 | // Should not error on non-existent directory 286 | if err != nil { 287 | t.Logf("Cleanup() returned error for non-existent dir: %v", err) 288 | } 289 | } 290 | 291 | func TestCleanup_EmptyString(t *testing.T) { 292 | err := Cleanup("") 293 | // Should not error on empty string 294 | if err != nil { 295 | t.Errorf("Cleanup() returned error for empty string: %v", err) 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= 2 | github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 3 | github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= 4 | github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= 5 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= 6 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= 7 | github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 8 | github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 9 | github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= 10 | github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 12 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= 16 | github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 17 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 18 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 19 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 20 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 21 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 22 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 23 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 24 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 25 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 26 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 27 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 28 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 29 | github.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw//rG24= 30 | github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw= 31 | github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM= 32 | github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= 33 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 34 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 35 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 36 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 37 | github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= 38 | github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= 39 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 40 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 41 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 42 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 43 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 44 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 45 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= 46 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 47 | github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 48 | github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 49 | github.com/migueleliasweb/go-github-mock v1.4.0 h1:pQ6K8r348m2q79A8Khb0PbEeNQV7t3h1xgECV+jNpXk= 50 | github.com/migueleliasweb/go-github-mock v1.4.0/go.mod h1:/DUmhXkxrgVlDOVBqGoUXkV4w0ms5n1jDQHotYm135o= 51 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 52 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 53 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 54 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= 56 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 57 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 58 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 59 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 60 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 61 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 62 | github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM= 63 | github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U= 64 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 65 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 66 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 67 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 68 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 69 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 70 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 71 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 72 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 73 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 74 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 75 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 76 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 77 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 78 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 79 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 80 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 81 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 82 | github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= 83 | github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= 84 | github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= 85 | github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= 86 | github.com/vbauerster/mpb/v8 v8.11.2 h1:OqLoHznUVU7SKS/WV+1dB5/hm20YLheYupiHhL5+M1Y= 87 | github.com/vbauerster/mpb/v8 v8.11.2/go.mod h1:mEB/M353al1a7wMUNtiymmPsEkGlJgeJmtlbY5adCJ8= 88 | github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= 89 | github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 90 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 91 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 92 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 93 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 94 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 95 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 96 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 97 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 98 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 99 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 100 | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 101 | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 102 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 103 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 104 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 105 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 106 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 107 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 108 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 109 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 110 | -------------------------------------------------------------------------------- /internal/core/updater/updater_integration_test.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "archive/tar" 5 | "compress/gzip" 6 | "context" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "path/filepath" 12 | "runtime" 13 | "testing" 14 | 15 | "parm/internal/config" 16 | "parm/internal/core/installer" 17 | "parm/internal/manifest" 18 | 19 | "github.com/google/go-github/v74/github" 20 | "github.com/migueleliasweb/go-github-mock/src/mock" 21 | ) 22 | 23 | func TestUpdate_Success(t *testing.T) { 24 | tmpDir := t.TempDir() 25 | config.Cfg.ParmPkgPath = tmpDir 26 | 27 | // Create installed package with old version 28 | pkgDir := filepath.Join(tmpDir, "owner", "repo") 29 | os.MkdirAll(pkgDir, 0755) 30 | 31 | m := &manifest.Manifest{ 32 | Owner: "owner", 33 | Repo: "repo", 34 | Version: "v1.0.0", 35 | InstallType: manifest.Release, 36 | Executables: []string{}, 37 | LastUpdated: "2025-01-01 12:00:00", 38 | } 39 | m.Write(pkgDir) 40 | 41 | // Create test archive 42 | archivePath := createTestArchive(t, tmpDir) 43 | 44 | // Create HTTP server 45 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | http.ServeFile(w, r, archivePath) 47 | })) 48 | defer server.Close() 49 | 50 | // Create mock GitHub client with newer version 51 | assetName := fmt.Sprintf("test-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) 52 | releaseResponse := &github.RepositoryRelease{ 53 | TagName: github.Ptr("v2.0.0"), 54 | Assets: []*github.ReleaseAsset{ 55 | { 56 | Name: github.Ptr(assetName), 57 | BrowserDownloadURL: github.Ptr(server.URL + "/asset"), 58 | }, 59 | }, 60 | } 61 | 62 | mockedHTTPClient := mock.NewMockedHTTPClient( 63 | mock.WithRequestMatch( 64 | mock.GetReposReleasesLatestByOwnerByRepo, 65 | releaseResponse, 66 | releaseResponse, // Provide twice in case called multiple times 67 | ), 68 | mock.WithRequestMatch( 69 | mock.GetReposReleasesTagsByOwnerByRepoByTag, 70 | releaseResponse, 71 | ), 72 | ) 73 | 74 | client := github.NewClient(mockedHTTPClient) 75 | inst := installer.New(client.Repositories) 76 | updater := New(client.Repositories, inst) 77 | 78 | ctx := context.Background() 79 | installPath := pkgDir 80 | 81 | flags := &UpdateFlags{ 82 | Strict: false, 83 | } 84 | 85 | result, err := updater.Update(ctx, "owner", "repo", installPath, flags, nil) 86 | if err != nil { 87 | t.Fatalf("Update() error: %v", err) 88 | } 89 | 90 | if result == nil { 91 | t.Fatal("Update() returned nil result") 92 | } 93 | 94 | if result.OldManifest.Version != "v1.0.0" { 95 | t.Errorf("Old version = %v, want v1.0.0", result.OldManifest.Version) 96 | } 97 | 98 | if result.Version != "v2.0.0" { 99 | t.Errorf("New version = %v, want v2.0.0", result.Version) 100 | } 101 | } 102 | 103 | func TestUpdate_AlreadyUpToDate(t *testing.T) { 104 | tmpDir := t.TempDir() 105 | config.Cfg.ParmPkgPath = tmpDir 106 | 107 | // Create installed package with current version 108 | pkgDir := filepath.Join(tmpDir, "owner", "repo") 109 | os.MkdirAll(pkgDir, 0755) 110 | 111 | m := &manifest.Manifest{ 112 | Owner: "owner", 113 | Repo: "repo", 114 | Version: "v1.0.0", 115 | InstallType: manifest.Release, 116 | Executables: []string{}, 117 | LastUpdated: "2025-01-01 12:00:00", 118 | } 119 | m.Write(pkgDir) 120 | 121 | // Mock GitHub client with same version 122 | mockedHTTPClient := mock.NewMockedHTTPClient( 123 | mock.WithRequestMatch( 124 | mock.GetReposReleasesLatestByOwnerByRepo, 125 | &github.RepositoryRelease{ 126 | TagName: github.Ptr("v1.0.0"), 127 | }, 128 | ), 129 | ) 130 | 131 | client := github.NewClient(mockedHTTPClient) 132 | inst := installer.New(client.Repositories) 133 | updater := New(client.Repositories, inst) 134 | 135 | ctx := context.Background() 136 | installPath := pkgDir 137 | 138 | flags := &UpdateFlags{ 139 | Strict: false, 140 | } 141 | 142 | _, err := updater.Update(ctx, "owner", "repo", installPath, flags, nil) 143 | if err == nil { 144 | t.Error("Update() should return error when already up to date") 145 | } 146 | } 147 | 148 | func TestUpdate_PackageNotInstalled(t *testing.T) { 149 | tmpDir := t.TempDir() 150 | config.Cfg.ParmPkgPath = tmpDir 151 | 152 | mockedHTTPClient := mock.NewMockedHTTPClient() 153 | client := github.NewClient(mockedHTTPClient) 154 | inst := installer.New(client.Repositories) 155 | updater := New(client.Repositories, inst) 156 | 157 | ctx := context.Background() 158 | installPath := filepath.Join(tmpDir, "owner", "nonexistent") 159 | 160 | flags := &UpdateFlags{ 161 | Strict: false, 162 | } 163 | 164 | _, err := updater.Update(ctx, "owner", "nonexistent", installPath, flags, nil) 165 | if err == nil { 166 | t.Error("Update() should return error for non-installed package") 167 | } 168 | } 169 | 170 | func TestUpdate_PreReleaseChannel(t *testing.T) { 171 | tmpDir := t.TempDir() 172 | config.Cfg.ParmPkgPath = tmpDir 173 | 174 | // Create installed pre-release package 175 | pkgDir := filepath.Join(tmpDir, "owner", "repo") 176 | os.MkdirAll(pkgDir, 0755) 177 | 178 | m := &manifest.Manifest{ 179 | Owner: "owner", 180 | Repo: "repo", 181 | Version: "v1.0.0-beta", 182 | InstallType: manifest.PreRelease, 183 | Executables: []string{}, 184 | LastUpdated: "2025-01-01 12:00:00", 185 | } 186 | m.Write(pkgDir) 187 | 188 | archivePath := createTestArchive(t, tmpDir) 189 | 190 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 191 | http.ServeFile(w, r, archivePath) 192 | })) 193 | defer server.Close() 194 | 195 | assetName := fmt.Sprintf("test-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) 196 | 197 | preReleaseResponse := []*github.RepositoryRelease{ 198 | { 199 | TagName: github.Ptr("v2.0.0-beta"), 200 | Prerelease: github.Ptr(true), 201 | Assets: []*github.ReleaseAsset{ 202 | { 203 | Name: github.Ptr(assetName), 204 | BrowserDownloadURL: github.Ptr(server.URL + "/asset"), 205 | }, 206 | }, 207 | }, 208 | } 209 | 210 | stableReleaseResponse := &github.RepositoryRelease{ 211 | TagName: github.Ptr("v1.5.0"), 212 | Assets: []*github.ReleaseAsset{ 213 | { 214 | Name: github.Ptr(assetName), 215 | BrowserDownloadURL: github.Ptr(server.URL + "/asset"), 216 | }, 217 | }, 218 | } 219 | 220 | mockedHTTPClient := mock.NewMockedHTTPClient( 221 | mock.WithRequestMatch( 222 | mock.GetReposReleasesByOwnerByRepo, 223 | preReleaseResponse, 224 | preReleaseResponse, // Provide twice 225 | ), 226 | mock.WithRequestMatch( 227 | mock.GetReposReleasesLatestByOwnerByRepo, 228 | stableReleaseResponse, 229 | stableReleaseResponse, // Provide twice 230 | ), 231 | ) 232 | 233 | client := github.NewClient(mockedHTTPClient) 234 | inst := installer.New(client.Repositories) 235 | updater := New(client.Repositories, inst) 236 | 237 | ctx := context.Background() 238 | installPath := pkgDir 239 | 240 | flags := &UpdateFlags{ 241 | Strict: false, 242 | } 243 | 244 | result, err := updater.Update(ctx, "owner", "repo", installPath, flags, nil) 245 | if err != nil { 246 | t.Fatalf("Update() error: %v", err) 247 | } 248 | 249 | // With strict=false, should update to stable if newer 250 | t.Logf("Updated to version: %s", result.Version) 251 | } 252 | 253 | func TestUpdate_StrictPreRelease(t *testing.T) { 254 | tmpDir := t.TempDir() 255 | config.Cfg.ParmPkgPath = tmpDir 256 | 257 | // Create installed pre-release package 258 | pkgDir := filepath.Join(tmpDir, "owner", "repo") 259 | os.MkdirAll(pkgDir, 0755) 260 | 261 | m := &manifest.Manifest{ 262 | Owner: "owner", 263 | Repo: "repo", 264 | Version: "v1.0.0-alpha", 265 | InstallType: manifest.PreRelease, 266 | Executables: []string{}, 267 | LastUpdated: "2025-01-01 12:00:00", 268 | } 269 | m.Write(pkgDir) 270 | 271 | archivePath := createTestArchive(t, tmpDir) 272 | 273 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 274 | http.ServeFile(w, r, archivePath) 275 | })) 276 | defer server.Close() 277 | 278 | assetName := fmt.Sprintf("test-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) 279 | 280 | preReleaseResponse := []*github.RepositoryRelease{ 281 | { 282 | TagName: github.Ptr("v1.0.0-beta"), 283 | Prerelease: github.Ptr(true), 284 | Assets: []*github.ReleaseAsset{ 285 | { 286 | Name: github.Ptr(assetName), 287 | BrowserDownloadURL: github.Ptr(server.URL + "/asset"), 288 | }, 289 | }, 290 | }, 291 | } 292 | 293 | mockedHTTPClient := mock.NewMockedHTTPClient( 294 | mock.WithRequestMatch( 295 | mock.GetReposReleasesByOwnerByRepo, 296 | preReleaseResponse, 297 | preReleaseResponse, // Provide twice in case called multiple times 298 | ), 299 | ) 300 | 301 | client := github.NewClient(mockedHTTPClient) 302 | inst := installer.New(client.Repositories) 303 | updater := New(client.Repositories, inst) 304 | 305 | ctx := context.Background() 306 | installPath := pkgDir 307 | 308 | flags := &UpdateFlags{ 309 | Strict: true, 310 | } 311 | 312 | result, err := updater.Update(ctx, "owner", "repo", installPath, flags, nil) 313 | if err != nil { 314 | t.Fatalf("Update() error: %v", err) 315 | } 316 | 317 | // Should stay on pre-release channel 318 | if result.Version != "v1.0.0-beta" { 319 | t.Errorf("Version = %v, want v1.0.0-beta", result.Version) 320 | } 321 | } 322 | 323 | // Helper function 324 | func createTestArchive(t *testing.T, baseDir string) string { 325 | archivePath := filepath.Join(baseDir, "update-test.tar.gz") 326 | 327 | f, err := os.Create(archivePath) 328 | if err != nil { 329 | t.Fatal(err) 330 | } 331 | defer f.Close() 332 | 333 | gw := gzip.NewWriter(f) 334 | defer gw.Close() 335 | 336 | tw := tar.NewWriter(gw) 337 | defer tw.Close() 338 | 339 | content := []byte("updated binary content") 340 | 341 | hdr := &tar.Header{ 342 | Name: "testbin", 343 | Mode: 0755, 344 | Size: int64(len(content)), 345 | } 346 | 347 | if err := tw.WriteHeader(hdr); err != nil { 348 | t.Fatal(err) 349 | } 350 | 351 | if _, err := tw.Write(content); err != nil { 352 | t.Fatal(err) 353 | } 354 | 355 | return archivePath 356 | } 357 | --------------------------------------------------------------------------------