diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..62ad9ea --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# Agent Instructions for eget Repository + +## Build Commands +- `make build` - Build the binary with version info +- `make fmt` - Format code with gofmt -s -w +- `make vet` - Run go vet for static analysis +- `make test` - Run tests (builds binary first, then runs custom test runner) + +## Test Commands +- Run all tests: `make test` +- Single test: `cd test; EGET_CONFIG=eget.toml EGET_BIN= TEST_EGET=../eget go run test_eget.go` (custom test framework) + +## Code Style Guidelines +- **Formatting**: Use `gofmt -s -w` (simplifies code, writes to files) +- **Imports**: Group standard library first, then third-party packages (blank line separator) +- **Naming**: PascalCase for exported functions/structs/fields, camelCase for unexported +- **Error Handling**: Use `fatal()` for unrecoverable errors, return errors for recoverable ones +- **Comments**: Brief function comments for exported functions, minimal inline comments +- **Types**: Use explicit types, avoid unnecessary type assertions +- **Struct Tags**: Use backtick-quoted struct tags for TOML/JSON serialization +- **Constants**: Use meaningful names, group related constants +- **Functions**: Keep functions focused, use early returns, avoid deep nesting \ No newline at end of file diff --git a/eget.go b/eget.go index bd46219..4487b19 100644 --- a/eget.go +++ b/eget.go @@ -365,6 +365,24 @@ func main() { os.Exit(0) } + if cli.ListInstalled { + err := listInstalled() + if err != nil { + fatal(err) + } + os.Exit(0) + } + + if cli.UpgradeAll { + results, err := upgradeAllPackages(cli.DryRun, cli.Interactive) + if err != nil { + fatal(err) + } + + displayUpgradeResults(results, cli.DryRun) + os.Exit(0) + } + target := "" if len(args) > 0 { @@ -401,9 +419,17 @@ func main() { err := os.Remove(filepath.Join(ebin, target)) if err != nil { fmt.Fprintln(os.Stderr, err) - os.Exit(1) + // Continue to remove from tracking even if file removal failed + } else { + fmt.Printf("Removed `%s`\n", filepath.Join(ebin, target)) + } + + // Also remove from installed tracking + err = removeInstalled(target) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to remove from installed tracking: %v\n", err) } - fmt.Printf("Removed `%s`\n", filepath.Join(ebin, target)) + os.Exit(0) } @@ -521,7 +547,9 @@ func main() { bins = []ExtractedFile{bin} } - extract := func(bin ExtractedFile) { + extractedFiles := make([]string, 0, len(bins)) + + extract := func(bin ExtractedFile) string { mode := bin.Mode() // write the extracted file to a file on disk, in the --to directory if @@ -553,13 +581,29 @@ func main() { } fmt.Fprintf(output, "Extracted `%s` to `%s`\n", bin.ArchiveName, out) + return out } if opts.All { for _, bin := range bins { - extract(bin) + outPath := extract(bin) + if outPath != "-" { // Don't track stdout output + extractedFiles = append(extractedFiles, outPath) + } } } else { - extract(bin) + outPath := extract(bin) + if outPath != "-" { // Don't track stdout output + extractedFiles = append(extractedFiles, outPath) + } + } + + // Record successful installation + if !opts.DLOnly && len(extractedFiles) > 0 { + err = recordInstallation(target, url, tool, opts, extractedFiles) + if err != nil { + // Log warning but don't fail the installation + fmt.Fprintf(os.Stderr, "Warning: failed to record installation: %v\n", err) + } } } diff --git a/flags.go b/flags.go index 2fa6596..50e8dfc 100644 --- a/flags.go +++ b/flags.go @@ -19,23 +19,27 @@ type Flags struct { } type CliFlags struct { - Tag *string `short:"t" long:"tag" description:"tagged release to use instead of latest"` - Prerelease *bool `long:"pre-release" description:"include pre-releases when fetching the latest version"` - Source *bool `long:"source" description:"download the source code for the target repo instead of a release"` - Output *string `long:"to" description:"move to given location after extracting"` - System *string `short:"s" long:"system" description:"target system to download for (use \"all\" for all choices)"` - ExtractFile *string `short:"f" long:"file" description:"glob to select files for extraction"` - All *bool `long:"all" description:"extract all candidate files"` - Quiet *bool `short:"q" long:"quiet" description:"only print essential output"` - DLOnly *bool `short:"d" long:"download-only" description:"stop after downloading the asset (no extraction)"` - UpgradeOnly *bool `long:"upgrade-only" description:"only download if release is more recent than current version"` - Asset *[]string `short:"a" long:"asset" description:"download a specific asset containing the given string; can be specified multiple times for additional filtering; use ^ for anti-match"` - Hash *bool `long:"sha256" description:"show the SHA-256 hash of the downloaded asset"` - Verify *string `long:"verify-sha256" description:"verify the downloaded asset checksum against the one provided"` - Rate bool `long:"rate" description:"show GitHub API rate limiting information"` - Remove *bool `short:"r" long:"remove" description:"remove the given file from $EGET_BIN or the current directory"` - Version bool `short:"v" long:"version" description:"show version information"` - Help bool `short:"h" long:"help" description:"show this help message"` - DownloadAll bool `short:"D" long:"download-all" description:"download all projects defined in the config file"` - DisableSSL *bool `short:"k" long:"disable-ssl" description:"disable SSL verification for download requests"` + Tag *string `short:"t" long:"tag" description:"tagged release to use instead of latest"` + Prerelease *bool `long:"pre-release" description:"include pre-releases when fetching the latest version"` + Source *bool `long:"source" description:"download the source code for the target repo instead of a release"` + Output *string `long:"to" description:"move to given location after extracting"` + System *string `short:"s" long:"system" description:"target system to download for (use \"all\" for all choices)"` + ExtractFile *string `short:"f" long:"file" description:"glob to select files for extraction"` + All *bool `long:"all" description:"extract all candidate files"` + Quiet *bool `short:"q" long:"quiet" description:"only print essential output"` + DLOnly *bool `short:"d" long:"download-only" description:"stop after downloading the asset (no extraction)"` + UpgradeOnly *bool `long:"upgrade-only" description:"only download if release is more recent than current version"` + Asset *[]string `short:"a" long:"asset" description:"download a specific asset containing the given string; can be specified multiple times for additional filtering; use ^ for anti-match"` + Hash *bool `long:"sha256" description:"show the SHA-256 hash of the downloaded asset"` + Verify *string `long:"verify-sha256" description:"verify the downloaded asset checksum against the one provided"` + Rate bool `long:"rate" description:"show GitHub API rate limiting information"` + ListInstalled bool `long:"list-installed" description:"list all installed packages"` + UpgradeAll bool `long:"upgrade-all" description:"upgrade all installed packages that have newer versions"` + DryRun bool `long:"dry-run" description:"show what would be done without making changes"` + Interactive bool `long:"interactive" description:"interactively select packages to upgrade"` + Remove *bool `short:"r" long:"remove" description:"remove the given file from $EGET_BIN or the current directory"` + Version bool `short:"v" long:"version" description:"show version information"` + Help bool `short:"h" long:"help" description:"show this help message"` + DownloadAll bool `short:"D" long:"download-all" description:"download all projects defined in the config file"` + DisableSSL *bool `short:"k" long:"disable-ssl" description:"disable SSL verification for download requests"` } diff --git a/go.mod b/go.mod index 8538420..b97fc69 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,41 @@ module github.com/zyedidia/eget -go 1.18 +go 1.24.0 + +toolchain go1.24.11 require ( github.com/BurntSushi/toml v1.2.1 github.com/blang/semver v3.5.1+incompatible + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/gobwas/glob v0.2.3 github.com/jessevdk/go-flags v1.5.0 + github.com/klauspost/compress v1.15.15 github.com/schollz/progressbar/v3 v3.8.2 github.com/ulikunitz/xz v0.5.10 ) require ( - github.com/klauspost/compress v1.15.15 // indirect - github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/stretchr/testify v1.8.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 // indirect - golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index a51f300..cb782e7 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,26 @@ github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= @@ -12,15 +28,29 @@ github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw= github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/schollz/progressbar/v3 v3.8.2 h1:2kZJwZCpb+E/V79kGO7daeq+hUwUJW0A5QD1Wv455dA= github.com/schollz/progressbar/v3 v3.8.2/go.mod h1:9KHLdyuXczIsyStQwzvW8xiELskmX7fQMaZdN23nAv8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -31,22 +61,30 @@ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PK github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA= golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/installed.go b/installed.go new file mode 100644 index 0000000..bb900a8 --- /dev/null +++ b/installed.go @@ -0,0 +1,695 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/BurntSushi/toml" + tea "github.com/charmbracelet/bubbletea" +) + +type InstalledEntry struct { + Repo string `toml:"repo"` + Target string `toml:"target"` + InstalledAt time.Time `toml:"installed_at"` + URL string `toml:"url"` + Asset string `toml:"asset"` + Tool string `toml:"tool,omitempty"` + ExtractedFiles []string `toml:"extracted_files"` + Options map[string]interface{} `toml:"options"` + Version string `toml:"version,omitempty"` + Tag string `toml:"tag,omitempty"` + ReleaseDate time.Time `toml:"release_date,omitempty"` +} + +type InstalledConfig struct { + Installed map[string]InstalledEntry `toml:"installed"` +} + +type UpgradeCandidate struct { + Repo string + Entry InstalledEntry +} + +type UpgradeResult struct { + Repo string + Current string + Latest string + Action string // "upgrade", "skip", "error" + Error string +} + +// getInstalledConfigPath returns the path to the installed packages config file +func getInstalledConfigPath() string { + homePath, _ := os.UserHomeDir() + + // Use the same logic as existing config but for installed.toml + configPath := filepath.Join(homePath, ".eget.installed.toml") + + // Check if it exists, if not try the XDG config directory + if _, err := os.Stat(configPath); os.IsNotExist(err) { + var configDir string + switch runtime.GOOS { + case "windows": + configDir = os.Getenv("LOCALAPPDATA") + default: + configDir = os.Getenv("XDG_CONFIG_HOME") + } + if configDir == "" { + configDir = filepath.Join(homePath, ".config") + } + xdgPath := filepath.Join(configDir, "eget", "installed.toml") + return xdgPath + } + + return configPath +} + +// loadInstalledConfig loads the installed packages config from file +func loadInstalledConfig() (*InstalledConfig, error) { + configPath := getInstalledConfigPath() + + var config InstalledConfig + _, err := toml.DecodeFile(configPath, &config) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to load installed config: %w", err) + } + + if config.Installed == nil { + config.Installed = make(map[string]InstalledEntry) + } + + return &config, nil +} + +// saveInstalledConfig saves the installed packages config to file +func saveInstalledConfig(config *InstalledConfig) error { + configPath := getInstalledConfigPath() + + // Create directory if it doesn't exist + dir := filepath.Dir(configPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + file, err := os.Create(configPath) + if err != nil { + return fmt.Errorf("failed to create config file: %w", err) + } + defer file.Close() + + encoder := toml.NewEncoder(file) + if err := encoder.Encode(config); err != nil { + return fmt.Errorf("failed to encode config: %w", err) + } + + return nil +} + +// normalizeRepoName converts various target formats to a consistent repo key +func normalizeRepoName(target string) string { + // Handle GitHub URLs + if strings.Contains(target, "github.com/") { + // Extract user/repo from github.com/user/repo or full URLs + parts := strings.Split(target, "github.com/") + if len(parts) > 1 { + path := parts[1] + // Remove trailing slashes and .git + path = strings.TrimSuffix(path, "/") + path = strings.TrimSuffix(path, ".git") + // Take only user/repo part + if idx := strings.Index(path, "/"); idx > 0 { + repoPart := path[:idx+1+strings.Index(path[idx+1:], "/")] + if repoPart == "" { + repoPart = path + } + return strings.TrimSuffix(repoPart, "/") + } + return path + } + } + + // For direct repo names like "user/repo" + if strings.Count(target, "/") == 1 && !strings.Contains(target, "://") { + return target + } + + // For other URLs or local paths, use as-is but clean up + return strings.TrimSuffix(target, "/") +} + +// extractOptionsMap converts Flags struct to a map for TOML storage +func extractOptionsMap(opts Flags) map[string]interface{} { + options := make(map[string]interface{}) + + // Only store meaningful options that affect installation + if opts.Tag != "" { + options["tag"] = opts.Tag + } + if opts.System != "" { + options["system"] = opts.System + } + if opts.Output != "" { + options["output"] = opts.Output + } + if opts.ExtractFile != "" { + options["extract_file"] = opts.ExtractFile + } + if opts.All { + options["all"] = opts.All + } + if opts.Quiet { + options["quiet"] = opts.Quiet + } + if opts.DLOnly { + options["download_only"] = opts.DLOnly + } + if opts.UpgradeOnly { + options["upgrade_only"] = opts.UpgradeOnly + } + if len(opts.Asset) > 0 { + options["asset"] = opts.Asset + } + if opts.Hash { + options["hash"] = opts.Hash + } + if opts.Verify != "" { + options["verify"] = opts.Verify + } + if opts.DisableSSL { + options["disable_ssl"] = opts.DisableSSL + } + + return options +} + +// getReleaseInfo fetches tag and release date from GitHub API +func getReleaseInfo(repo, url string) (string, time.Time, error) { + // Extract repo from URL if it's a GitHub URL + var apiURL string + if strings.Contains(url, "github.com/") && strings.Contains(url, "/releases/download/") { + // Parse GitHub release URL to get repo and tag + parts := strings.Split(url, "github.com/") + if len(parts) < 2 { + return "", time.Time{}, fmt.Errorf("invalid GitHub URL") + } + pathParts := strings.Split(parts[1], "/") + if len(pathParts) < 4 { + return "", time.Time{}, fmt.Errorf("invalid GitHub release URL") + } + repoName := pathParts[0] + "/" + pathParts[1] + tag := pathParts[3] // tag is in position 3 for /releases/download/tag/asset + + apiURL = fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", repoName, tag) + } else if strings.Contains(repo, "/") { + // For direct repo names, we need to find which tag was used + // This is more complex, so for now we'll leave it empty + return "", time.Time{}, nil + } else { + return "", time.Time{}, nil + } + + // Make API request + resp, err := http.Get(apiURL) + if err != nil { + return "", time.Time{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", time.Time{}, nil // Don't fail if we can't get release info + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", time.Time{}, err + } + + var release struct { + TagName string `json:"tag_name"` + CreatedAt time.Time `json:"created_at"` + } + + err = json.Unmarshal(body, &release) + if err != nil { + return "", time.Time{}, err + } + + return release.TagName, release.CreatedAt, nil +} + +// recordInstallation records a successful installation +func recordInstallation(target, url, tool string, opts Flags, extractedFiles []string) error { + config, err := loadInstalledConfig() + if err != nil { + return err + } + + repoKey := normalizeRepoName(target) + + // Try to get release information + tag, releaseDate, _ := getReleaseInfo(repoKey, url) + + entry := InstalledEntry{ + Repo: repoKey, + Target: target, + InstalledAt: time.Now(), + URL: url, + Asset: filepath.Base(url), + Tool: tool, + ExtractedFiles: extractedFiles, + Options: extractOptionsMap(opts), + Tag: tag, + ReleaseDate: releaseDate, + } + + // Store entry + if config.Installed == nil { + config.Installed = make(map[string]InstalledEntry) + } + config.Installed[repoKey] = entry + + return saveInstalledConfig(config) +} + +// removeInstalled removes an installed package from tracking +func removeInstalled(target string) error { + config, err := loadInstalledConfig() + if err != nil { + return err + } + + repoKey := normalizeRepoName(target) + delete(config.Installed, repoKey) + + return saveInstalledConfig(config) +} + +// listInstalled displays all installed packages +func listInstalled() error { + config, err := loadInstalledConfig() + if err != nil { + return err + } + + if len(config.Installed) == 0 { + fmt.Println("No packages installed.") + return nil + } + + fmt.Println("Installed packages:") + fmt.Println() + + for _, entry := range config.Installed { + fmt.Printf("%s\n", entry.Repo) + fmt.Printf(" Target: %s\n", entry.Target) + fmt.Printf(" Installed: %s\n", entry.InstalledAt.Format("2006-01-02 15:04:05")) + if len(entry.ExtractedFiles) == 1 { + fmt.Printf(" File: %s\n", entry.ExtractedFiles[0]) + } else { + fmt.Printf(" Files: %s\n", strings.Join(entry.ExtractedFiles, ", ")) + } + + if len(entry.Options) > 0 { + var opts []string + for k, v := range entry.Options { + opts = append(opts, fmt.Sprintf("%s=%v", k, v)) + } + fmt.Printf(" Options: %s\n", strings.Join(opts, ", ")) + } + fmt.Println() + } + + return nil +} + +// checkForUpgrade checks if a package has a newer version available +func checkForUpgrade(entry InstalledEntry) (bool, string, error) { + // For GitHub repos, check if there's a newer release + if !strings.Contains(entry.Repo, "/") { + return false, "", fmt.Errorf("non-GitHub repos not supported for upgrade checks") + } + + // Create a GithubAssetFinder to check for newer releases + finder := &GithubAssetFinder{ + Repo: entry.Repo, + Tag: "latest", + Prerelease: false, // Only check stable releases + MinTime: entry.ReleaseDate, + } + + // If we find assets, it means there's a newer release + _, err := finder.Find() + if err == ErrNoUpgrade { + // No upgrade available + return false, entry.Tag, nil + } else if err != nil { + return false, "", err + } + + // There are assets, so there's an upgrade available + // Get the latest tag + latestTag, err := getLatestTag(entry.Repo) + if err != nil { + return false, "", err + } + + return true, latestTag, nil +} + +// getLatestTag gets the latest stable release tag for a repo +func getLatestTag(repo string) (string, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var release struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + } + + err = json.Unmarshal(body, &release) + if err != nil { + return "", err + } + + if release.Prerelease { + // If latest is a pre-release, we might need to find the latest stable + // For simplicity, we'll accept it for now + } + + return release.TagName, nil +} + +// performUpgrade performs an upgrade for a single package +func performUpgrade(entry InstalledEntry, newTag string) error { + // This is complex - we need to recreate the installation process + // For now, we'll use a simplified approach by calling eget recursively + // In a full implementation, this would parse the stored options and recreate the installation + + // Extract options back to command line args + args := []string{entry.Target} + + // Add stored options + opts := entry.Options + if tag, ok := opts["tag"].(string); ok && tag != "" { + args = append(args, "--tag", newTag) // Use new tag instead of old + } else { + args = append(args, "--tag", newTag) // Force the new tag + } + + if system, ok := opts["system"].(string); ok && system != "" { + args = append(args, "--system", system) + } + + if extractFile, ok := opts["extract_file"].(string); ok && extractFile != "" { + args = append(args, "--file", extractFile) + } + + if all, ok := opts["all"].(bool); ok && all { + args = append(args, "--all") + } + + if quiet, ok := opts["quiet"].(bool); ok && quiet { + args = append(args, "--quiet") + } + + // Get the path to eget binary + egetPath, err := os.Executable() + if err != nil { + return err + } + + // Run eget with the constructed arguments + cmd := exec.Command(egetPath, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// updateInstalledRecord updates the installed record after successful upgrade +func updateInstalledRecord(repo, newTag string) error { + config, err := loadInstalledConfig() + if err != nil { + return err + } + + if entry, exists := config.Installed[repo]; exists { + entry.Tag = newTag + entry.InstalledAt = time.Now() + // Note: We could fetch the new release date here, but for simplicity + // we'll leave it as-is since the upgrade succeeded + config.Installed[repo] = entry + + return saveInstalledConfig(config) + } + + return fmt.Errorf("package %s not found in installed records", repo) +} + +// upgradeAllPackages checks and upgrades all installed packages +func upgradeAllPackages(dryRun, interactive bool) ([]UpgradeResult, error) { + config, err := loadInstalledConfig() + if err != nil { + return nil, err + } + + // Convert installed entries to candidates + candidates := make([]UpgradeCandidate, 0, len(config.Installed)) + for repo, entry := range config.Installed { + candidates = append(candidates, UpgradeCandidate{ + Repo: repo, + Entry: entry, + }) + } + + // If interactive mode, let user select which packages to check + if interactive && len(candidates) > 0 { + candidates = selectPackagesInteractively(candidates) + } + + results := make([]UpgradeResult, 0, len(candidates)) + + for _, candidate := range candidates { + result := UpgradeResult{Repo: candidate.Repo, Current: candidate.Entry.Tag} + + needsUpgrade, latestTag, err := checkForUpgrade(candidate.Entry) + if err != nil { + result.Action = "error" + result.Error = err.Error() + } else if !needsUpgrade { + result.Action = "skip" + result.Latest = latestTag + } else { + result.Action = "upgrade" + result.Latest = latestTag + + if !dryRun { + err := performUpgrade(candidate.Entry, latestTag) + if err != nil { + result.Action = "error" + result.Error = err.Error() + } else { + // Update the installed record + updateErr := updateInstalledRecord(candidate.Repo, latestTag) + if updateErr != nil { + // Log but don't fail the upgrade + result.Error = fmt.Sprintf("upgrade succeeded but record update failed: %v", updateErr) + } + } + } + } + + results = append(results, result) + } + + return results, nil +} + +// Bubbletea model for interactive package selection +type packageSelectModel struct { + candidates []UpgradeCandidate + cursor int + selected map[int]bool + done bool +} + +func (m packageSelectModel) Init() tea.Cmd { + return nil +} + +func (m packageSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + m.done = true + return m, tea.Quit + case "enter", " ": + // Toggle selection + if _, exists := m.selected[m.cursor]; exists { + delete(m.selected, m.cursor) + } else { + m.selected[m.cursor] = true + } + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.candidates)-1 { + m.cursor++ + } + case "a", "ctrl+a": + // Select all + for i := range m.candidates { + m.selected[i] = true + } + case "n", "ctrl+n": + // Select none + m.selected = make(map[int]bool) + case "ctrl+d": + // Done + m.done = true + return m, tea.Quit + } + } + return m, nil +} + +func (m packageSelectModel) View() string { + if m.done { + return "" + } + + var b strings.Builder + b.WriteString("Select packages to check for updates:\n\n") + + for i, candidate := range m.candidates { + cursor := " " + if m.cursor == i { + cursor = ">" + } + + checkbox := "[ ]" + if m.selected[i] { + checkbox = "[✓]" + } + + current := candidate.Entry.Tag + if current == "" { + current = "unknown" + } + + b.WriteString(fmt.Sprintf("%s %s %s (current: %s)\n", cursor, checkbox, candidate.Repo, current)) + } + + b.WriteString("\n") + b.WriteString("↑/↓ or j/k: navigate • space: toggle • a: select all • n: select none\n") + b.WriteString("ctrl+d or enter: confirm • q/esc: quit\n") + + return b.String() +} + +// selectPackagesInteractively allows users to select which packages to upgrade using bubbletea +func selectPackagesInteractively(candidates []UpgradeCandidate) []UpgradeCandidate { + if len(candidates) == 0 { + return candidates + } + + // Check if we're in an interactive terminal + if !isInteractiveTerminal() { + fmt.Fprintf(os.Stderr, "Warning: not running in interactive terminal, proceeding with all packages\n") + return candidates + } + + // Create and run the bubbletea program + model := packageSelectModel{ + candidates: candidates, + selected: make(map[int]bool), + done: false, + } + + p := tea.NewProgram(model) + finalModel, err := p.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: interactive selection failed (%v), proceeding with all packages\n", err) + return candidates + } + + m := finalModel.(packageSelectModel) + + // Collect selected candidates + selected := make([]UpgradeCandidate, 0, len(m.selected)) + for idx := range m.selected { + selected = append(selected, candidates[idx]) + } + + return selected +} + +// isInteractiveTerminal checks if we're running in an interactive terminal +func isInteractiveTerminal() bool { + // Check if stdout is a terminal + fileInfo, err := os.Stdout.Stat() + if err != nil { + return false + } + return (fileInfo.Mode() & os.ModeCharDevice) != 0 +} + +// displayUpgradeResults shows the results of the upgrade-all operation +func displayUpgradeResults(results []UpgradeResult, dryRun bool) { + if dryRun { + fmt.Println("Dry run - the following packages would be upgraded:") + } else { + fmt.Println("Upgrade results:") + } + fmt.Println() + + upgraded := 0 + skipped := 0 + errors := 0 + + for _, result := range results { + switch result.Action { + case "upgrade": + if dryRun { + fmt.Printf("✓ %s: %s → %s (would upgrade)\n", result.Repo, result.Current, result.Latest) + } else { + fmt.Printf("✓ %s: %s → %s (upgraded)\n", result.Repo, result.Current, result.Latest) + } + upgraded++ + case "skip": + fmt.Printf("• %s: %s (up to date)\n", result.Repo, result.Current) + skipped++ + case "error": + fmt.Printf("✗ %s: %s\n", result.Repo, result.Error) + errors++ + } + } + + fmt.Println() + fmt.Printf("Summary: %d upgraded, %d skipped, %d errors\n", upgraded, skipped, errors) +}