From 2a22f41facd642a8799972e2bb75c38be4863900 Mon Sep 17 00:00:00 2001 From: Squibid Date: Fri, 17 Oct 2025 14:15:37 -0400 Subject: [PATCH] feat(vendordep): add vendordep management - Add vendordep add - Add vendordep remove - Add vendordep list - Refactor downloading --- README.md | 42 +++++ cmd/root.go | 58 ++++++ cmd/template/downloadUi.go | 76 -------- cmd/template/remote.go | 27 +-- cmd/template/store.go | 54 ------ cmd/template/template.go | 37 +--- cmd/vendordep.go | 42 +++++ cmd/vendordep/artifactory/artifactory.go | 3 + cmd/vendordep/artifactory/artifactoryFs.go | 202 +++++++++++++++++++++ cmd/vendordep/remote.go | 66 +++++++ cmd/vendordep/store.go | 80 ++++++++ cmd/vendordep/vendordep.go | 129 +++++++++++++ cmd/vendordepadd.go | 147 +++++++++++++++ cmd/vendordeplist.go | 41 +++++ cmd/vendordepremove.go | 58 ++++++ utils/downloader.go | 156 ++++++++++++++++ utils/stringOrNumber.go | 24 +++ utils/utils.go | 41 +++++ 18 files changed, 1099 insertions(+), 184 deletions(-) create mode 100644 README.md delete mode 100644 cmd/template/downloadUi.go create mode 100644 cmd/vendordep.go create mode 100644 cmd/vendordep/artifactory/artifactory.go create mode 100644 cmd/vendordep/artifactory/artifactoryFs.go create mode 100644 cmd/vendordep/remote.go create mode 100644 cmd/vendordep/store.go create mode 100644 cmd/vendordep/vendordep.go create mode 100644 cmd/vendordepadd.go create mode 100644 cmd/vendordeplist.go create mode 100644 cmd/vendordepremove.go create mode 100644 utils/downloader.go create mode 100644 utils/stringOrNumber.go create mode 100644 utils/utils.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..1019d34 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# RPS (Robot Pits Helper) +Manage your FRC robot code the UNIX way. + +## Quick Start +Make a new project from a template +```sh +rph template -d MyNewRoboProject -l java -t commandbased -n 5438 -s false +``` +If you'd like to learn more about the template subcmd just run `rph template -h`. +Now let's go into the project and add a vendor dependency: +```sh +rph vendordep add photonlib-2025.3.1 +``` +And now we just need to build to make it download all the actual code: +```sh +gradle build +``` + +## But Why? +Well you probably shouldn't, but if you really want to learn more about how +computers work this is a solid starting point. As for the creation of this +project: it was made for two reasons: +1. To alleviate my everlasting frustration with WPILIB's crappy uis +2. To dust everyone in the YAMS speedrunning competition + +## TODO +- [x] template +- [ ] vendordep + - [ ] update installed vendor deps + - [ ] get info about installed vendor deps +- [ ] riolog listener, seems like the vscode extension does it which means + there's no reason we can't >:) + +- [ ] have to make sure wpilib is installed somewhere so that we can build + projects without running the wpilib installer, this might take a bit + of investigation +- [ ] make a declaritive config? (really only seems useful for speedrunning or + setting up multiple rookies machines for a new project) + +- [ ] runner? (maybe idk though cause you can just use gradlew directly) + no, but maybe we should document how to use gradle for the noobs who + use the internet diff --git a/cmd/root.go b/cmd/root.go index d7f6599..3a7090c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,12 +1,19 @@ package cmd import ( + "io/fs" + "log/slog" "os" + "path/filepath" "rph/state" + "rph/utils" "github.com/spf13/cobra" ) +var projectFs fs.FS +var projectDir string + var rootCmd = &cobra.Command{ Use: state.Name, Short: "Manage your FRC robot code the UNIX way.", @@ -15,11 +22,62 @@ giving FRC teams a simple way to interact with wpilib robot code. rph is cross platform, and should work everywhere wpilib is supported. To actually run your robot code you will still need to install wpilib.`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + dir, err := cmd.Flags().GetString("project-dir") + if err != nil { + slog.Error("Unable to set projectFs or projectDir", "error", err) + os.Exit(1) + } + + // if the user specified a directory we'll trust them + if dir != "." { + projectFs = os.DirFS(dir) + projectDir = dir + return nil + } + + // otherwise we should go find the .wpilib folder in the parent directories + path, err := utils.FindEntryDirInParents(dir, ".wpilib") + if err != nil { + slog.Error("Unable to find project directory", "error", err) + return err + } + + projectFs = os.DirFS(path) + projectDir = path + return nil + }, } func Execute() { + rootCmd.PersistentFlags().String("project-dir", ".", "Set the project directory.") + err := rootCmd.Execute() if err != nil { os.Exit(1) } } + +func superPersistentPreRun(cmd *cobra.Command, args []string) { + if parent := cmd.Parent(); parent != nil { + if parent.PersistentPreRunE != nil { + if err := parent.PersistentPreRunE(parent, args); err != nil { + return + } + } else if parent.PersistentPreRun != nil { + // Fallback to PersistentPreRun if PersistentPreRunE isn't set + parent.PersistentPreRun(parent, args) + } + } +} + +// inProjectDir handles the log message for you +func inProjectDir() bool { + _, err := os.Stat(filepath.Join(projectDir, ".wpilib", "wpilib_preferences.json")) + if err != nil { + slog.Error("Are you in a project directory?") + return false + } + + return true +} diff --git a/cmd/template/downloadUi.go b/cmd/template/downloadUi.go deleted file mode 100644 index d14e340..0000000 --- a/cmd/template/downloadUi.go +++ /dev/null @@ -1,76 +0,0 @@ -package template - -import ( - "strings" - "time" - - "github.com/charmbracelet/bubbles/progress" - tea "github.com/charmbracelet/bubbletea" -) - -const ( - padding = 2 - maxWidth = 80 -) - -type progressMsg float64 -type progressErrMsg struct{ err error } - -func finalPause() tea.Cmd { - return tea.Tick(time.Millisecond*750, func(_ time.Time) tea.Msg { - return nil - }) -} - -type downloadModel struct { - pw *progressWriter - progress progress.Model - err error -} - -func (m downloadModel) Init() tea.Cmd { - return nil -} - -func (m downloadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - return m, tea.Quit - - case tea.WindowSizeMsg: - m.progress.Width = min(msg.Width - padding * 2 - 4, maxWidth) - return m, nil - - case progressErrMsg: - m.err = msg.err - return m, tea.Quit - - case progressMsg: - var cmds []tea.Cmd - - if msg >= 1.0 { - cmds = append(cmds, tea.Sequence(finalPause(), tea.Quit)) - } - - cmds = append(cmds, m.progress.SetPercent(float64(msg))) - return m, tea.Batch(cmds...) - - // FrameMsg is sent when the progress bar wants to animate itself - case progress.FrameMsg: - progressModel, cmd := m.progress.Update(msg) - m.progress = progressModel.(progress.Model) - return m, cmd - - default: - return m, nil - } -} - -func (m downloadModel) View() string { - if m.err != nil { - return "Error downloading: " + m.err.Error() + "\n" - } - - pad := strings.Repeat(" ", padding) - return "\n" + pad + "Downloading template.zip to " + m.pw.file.Name() + "\n\n" + pad + m.progress.View() + "\n\n" + pad + "Press any key to quit" -} diff --git a/cmd/template/remote.go b/cmd/template/remote.go index f9c54aa..9f3c5c8 100644 --- a/cmd/template/remote.go +++ b/cmd/template/remote.go @@ -10,32 +10,9 @@ import ( "strconv" "rph/state" + "rph/utils" ) -type progressWriter struct { - total int - downloaded int - file *os.File - reader io.Reader - onProgress func(float64) -} - -func (pw *progressWriter) Start() { - // TeeReader calls pw.Write() each time a new response is received - _, err := io.Copy(pw.file, io.TeeReader(pw.reader, pw)) - if err != nil { - slog.Error("Error in progress writer", "error", progressErrMsg{err}) - } -} - -func (pw *progressWriter) Write(p []byte) (int, error) { - pw.downloaded += len(p) - if pw.total > 0 && pw.onProgress != nil { - pw.onProgress(float64(pw.downloaded) / float64(pw.total)) - } - return len(p), nil -} - type asset struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` @@ -104,7 +81,7 @@ func getTemplateArchive(filename string, force bool, version string) { return } - err = downloadFile(downloadURL) + err = utils.DownloadFile(downloadURL, filepath.Join(state.CachePath, zipFile)) if err != nil { slog.Error("Error downloading archive file", "error", err) os.Exit(1) diff --git a/cmd/template/store.go b/cmd/template/store.go index 9d71873..413a543 100644 --- a/cmd/template/store.go +++ b/cmd/template/store.go @@ -5,13 +5,10 @@ import ( "errors" "io/fs" "log/slog" - "net/http" "os" "path/filepath" "rph/state" - "github.com/charmbracelet/bubbles/progress" - tea "github.com/charmbracelet/bubbletea" "github.com/mholt/archives" ) @@ -34,57 +31,6 @@ func LoadArchiveVersion() (string, error) { return string(data), nil } -func downloadFile(url string) error { - var progressBar = true - - // Create the file - out, err := os.Create(filepath.Join(state.CachePath, zipFile)) - if err != nil { - return err - } - defer out.Close() - - // Get the data - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - - - if resp.ContentLength <= 0 { - slog.Warn("Can't parse content length, no progress bar will be shown.") - progressBar = false - } - - var p *tea.Program - pw := &progressWriter{ - total: int(resp.ContentLength), - file: out, - reader: resp.Body, - onProgress: func(ratio float64) { - p.Send(progressMsg(ratio)) - }, - } - - m := downloadModel{ - pw: pw, - progress: progress.New(progress.WithDefaultGradient()), - } - p = tea.NewProgram(m) - - // start the download - go pw.Start() - - if progressBar { - if _, err := p.Run(); err != nil { - slog.Error("Error starting the progress bar", "error", err) - } - } - - return err -} - func OpenArchive(ctx context.Context) (fsys fs.FS, err error) { fsys, err = archives.FileSystem(ctx, filepath.Join(state.CachePath, zipFile), nil) if err != nil { diff --git a/cmd/template/template.go b/cmd/template/template.go index 434cc9e..29dfefd 100644 --- a/cmd/template/template.go +++ b/cmd/template/template.go @@ -3,12 +3,15 @@ package template import ( "context" "encoding/json" + "fmt" "io/fs" "log/slog" "os" "path/filepath" "regexp" "rph/state" + "rph/utils" + "strings" ) type TemplateOptions struct { @@ -19,7 +22,7 @@ type TemplateOptions struct { DesktopSupport *bool } -type wpilibPreferences struct { +type WpilibPreferences struct { CppIntellisense bool `json:"enableCppIntellisense"` Lang string `json:"currentLanguage"` Year string `json:"projectYear"` @@ -85,7 +88,7 @@ func GenerateProject(opts TemplateOptions) { defer file.Close() { // scope it so I can jump over it - var p wpilibPreferences + var p WpilibPreferences decoder := json.NewDecoder(file) err = decoder.Decode(&p) if err != nil { @@ -188,14 +191,14 @@ func GenerateProject(opts TemplateOptions) { if strings.HasPrefix(projType, "xrp") { url := fmt.Sprintf("https://raw.githubusercontent.com/wpilibsuite/allwpilib/%s/xrpVendordep/XRPVendordep.json", version) - err = fetchWPIDep(url, filepath.Join(vendordepDir, "XRPVendordep.json")) + err = utils.DownloadFile(url, filepath.Join(vendordepDir, "XRPVendordep.json")) if err != nil { slog.Error("Failed to download required vendor dep you should download it yourself", "url", url, "error", err) return } } else if strings.HasPrefix(projType, "romi") { url := fmt.Sprintf("https://raw.githubusercontent.com/wpilibsuite/allwpilib/%s/romiVendordep/RomiVendordep.json", version) - err = fetchWPIDep(url, filepath.Join(vendordepDir, "RomiVendordep.json")) + err = utils.DownloadFile(url, filepath.Join(vendordepDir, "RomiVendordep.json")) if err != nil { slog.Error("Failed to download required vendor dep you should download it yourself", "url", url, "error", err) return @@ -203,7 +206,7 @@ func GenerateProject(opts TemplateOptions) { } else if strings.HasPrefix(projType, "command") { // TODO: make sure there's no other project types this has to work with url := fmt.Sprintf("https://raw.githubusercontent.com/wpilibsuite/allwpilib/%s/wpilibNewCommands/WPILibNewCommands.json", version) - err = fetchWPIDep(url, filepath.Join(vendordepDir, "WPILibNewCommands.json")) + err = utils.DownloadFile(url, filepath.Join(vendordepDir, "WPILibNewCommands.json")) if err != nil { slog.Error("Failed to download required vendor dep you should download it yourself", "url", url, "error", err) return @@ -211,27 +214,3 @@ func GenerateProject(opts TemplateOptions) { } } } - -func fetchWPIDep(url string, outpath string) error { - resp, err := http.Get(url) - if err != nil { - slog.Error("Failed to download file", "url", url, "error", err) - return err - } - defer resp.Body.Close() - - outFile, err := os.Create(outpath) - if err != nil { - slog.Error("Failed to create file", "error", err) - return err - } - defer outFile.Close() - - _, err = io.Copy(outFile, resp.Body) - if err != nil { - slog.Error("Failed to save file", "error", err) - return err - } - - return nil -} diff --git a/cmd/vendordep.go b/cmd/vendordep.go new file mode 100644 index 0000000..b585570 --- /dev/null +++ b/cmd/vendordep.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "log/slog" + "rph/cmd/vendordep" + "strings" + + "github.com/spf13/cobra" +) + +func vendorDepsComp(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + validVendordeps, err := vendordep.ListVendorDeps(projectFs) + if err != nil { + slog.Error("Unable to find vendor deps", "error", err) + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var completions []string + for _, dep := range validVendordeps { + if strings.HasPrefix(dep.Name, toComplete) { + completions = append(completions, dep.Name) + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp +} + +// vendordepCmd represents the vendordep command +var vendordepCmd = &cobra.Command{ + Use: "vendordep", + Aliases: []string{ "vend" }, + Short: "Mange your WPILIB projects vendordeps", + Long: `Mange your WPILIB projects vendordeps`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + superPersistentPreRun(cmd, args) + vendordep.MkCacheDir() + }, +} + +func init() { + rootCmd.AddCommand(vendordepCmd) +} diff --git a/cmd/vendordep/artifactory/artifactory.go b/cmd/vendordep/artifactory/artifactory.go new file mode 100644 index 0000000..e41f48f --- /dev/null +++ b/cmd/vendordep/artifactory/artifactory.go @@ -0,0 +1,3 @@ +package artifactory + +const DefaultVendorDepArtifactoryUrl = "https://frcmaven.wpi.edu/artifactory" diff --git a/cmd/vendordep/artifactory/artifactoryFs.go b/cmd/vendordep/artifactory/artifactoryFs.go new file mode 100644 index 0000000..be6bb55 --- /dev/null +++ b/cmd/vendordep/artifactory/artifactoryFs.go @@ -0,0 +1,202 @@ +package artifactory + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "io/fs" + "net/http" + "path" + "strings" + "time" +) + +type ArtifactoryFS struct { + BaseURL string + Client *http.Client +} + +func New(baseURL string) ArtifactoryFS { + return ArtifactoryFS{ + BaseURL: strings.TrimRight(baseURL, "/") + "/", + Client: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (afs ArtifactoryFS) GetUrl(name string) string { + cleanName := path.Clean(name) + return afs.BaseURL + cleanName +} + +func (afs ArtifactoryFS) Open(name string) (fs.File, error) { + cleanName := path.Clean(name) + url := afs.BaseURL + cleanName + + // Fetch metadata via storage API + metaURL := afs.BaseURL + "api/storage/" + cleanName + if cleanName == "." { + metaURL = afs.BaseURL + "api/storage/" + } + + resp, err := afs.Client.Get(metaURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, fs.ErrNotExist + } else if resp.StatusCode != 200 { + return nil, errors.New("unexpected status: " + resp.Status) + } + + var meta struct { + Repo string `json:"repo"` + Path string `json:"path"` + Children []struct { + URI string `json:"uri"` + Folder bool `json:"folder"` + } `json:"children"` + Size string `json:"size"` + } + + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + return nil, err + } + + // It's a directory + if len(meta.Children) > 0 { + return &artifactoryDir{ + entries: meta.Children, + pos: 0, + name: cleanName, + }, nil + } + + // It's a file + contentResp, err := afs.Client.Get(url) + if err != nil { + return nil, err + } + + if contentResp.StatusCode == 404 { + return nil, fs.ErrNotExist + } else if contentResp.StatusCode != 200 { + return nil, errors.New("unexpected status: " + contentResp.Status) + } + + data, err := io.ReadAll(contentResp.Body) + contentResp.Body.Close() + if err != nil { + return nil, err + } + + return &artifactoryFile{ + data: bytes.NewReader(data), + name: cleanName, + size: int64(len(data)), + }, nil +} + +type artifactoryFile struct { + data *bytes.Reader + name string + size int64 +} + +func (f *artifactoryFile) Stat() (fs.FileInfo, error) { + return &fileInfo{ + name: path.Base(f.name), + size: f.size, + mode: 0444, + }, nil +} + +func (f *artifactoryFile) Read(p []byte) (int, error) { + return f.data.Read(p) +} + +func (f *artifactoryFile) Close() error { + return nil +} + +type artifactoryDir struct { + entries []struct { + URI string `json:"uri"` + Folder bool `json:"folder"` + } + pos int + name string +} + +func (d *artifactoryDir) Stat() (fs.FileInfo, error) { + return &fileInfo{ + name: path.Base(d.name), + mode: fs.ModeDir | 0555, + }, nil +} + +func (d *artifactoryDir) Read([]byte) (int, error) { + return 0, errors.New("cannot read directory") +} + +func (d *artifactoryDir) Close() error { + return nil +} + +func (d *artifactoryDir) ReadDir(n int) ([]fs.DirEntry, error) { + if d.pos >= len(d.entries) && n > 0 { + return nil, io.EOF + } + + var entries []fs.DirEntry + max := len(d.entries) + if n > 0 && d.pos+n < max { + max = d.pos + n + } + + for ; d.pos < max; d.pos++ { + e := d.entries[d.pos] + entries = append(entries, &dirEntry{ + name: strings.TrimPrefix(e.URI, "/"), + isDir: e.Folder, + }) + } + + return entries, nil +} + +type fileInfo struct { + name string + size int64 + mode fs.FileMode +} + +func (fi *fileInfo) Name() string { return fi.name } +func (fi *fileInfo) Size() int64 { return fi.size } +func (fi *fileInfo) Mode() fs.FileMode { return fi.mode } +func (fi *fileInfo) ModTime() time.Time { return time.Time{} } +func (fi *fileInfo) IsDir() bool { return fi.mode.IsDir() } +func (fi *fileInfo) Sys() any { return nil } + +type dirEntry struct { + name string + isDir bool +} + +func (de *dirEntry) Name() string { return de.name } +func (de *dirEntry) IsDir() bool { return de.isDir } +func (de *dirEntry) Type() fs.FileMode { + if de.isDir { + return fs.ModeDir + } else { + return 0 + } +} +func (de *dirEntry) Info() (fs.FileInfo, error) { + return &fileInfo{ + name: de.name, + mode: de.Type(), + }, nil +} diff --git a/cmd/vendordep/remote.go b/cmd/vendordep/remote.go new file mode 100644 index 0000000..a014a51 --- /dev/null +++ b/cmd/vendordep/remote.go @@ -0,0 +1,66 @@ +package vendordep + +import ( + "errors" + "io/fs" + "log/slog" + "regexp" + "rph/cmd/vendordep/artifactory" + "time" +) + +type OnlineVendordep struct { + VendordepName string + Version string + FileName string + LastModTime time.Time +} + +func ListAvailableOnlineDeps(year string) (map[string][]OnlineVendordep, error) { + fsys := artifactory.New(artifactory.DefaultVendorDepArtifactoryUrl) + path := "vendordeps/vendordep-marketplace/" + year + + entries, err := fs.ReadDir(fsys, path) + if err != nil { + slog.Error("Failed to readdir from artifactory", "dir", path, "error", err) + return nil, err + } + + type fileEntry struct { + Name string + ModTime time.Time + } + + files := make([]fileEntry, len(entries)) + for i, e := range entries { + info, err := e.Info() + if err != nil { + return nil, err + } + + files[i] = fileEntry{ Name: e.Name(), ModTime: info.ModTime() } + } + + allDeps := make(map[string][]OnlineVendordep, len(entries)) + + // breaks a vendordep file name into "name-of-library" and "v2025.9.28" + re := regexp.MustCompile(`^(.+)-v?(\d+\.\d+(?:\.\d+)?)`) + for _, file := range files { + matches := re.FindStringSubmatch(file.Name) + if len(matches) > 2 { + baseName := matches[1] + version := matches[2] + + allDeps[baseName] = append(allDeps[baseName], OnlineVendordep{ + VendordepName: baseName, + Version: version, + FileName: file.Name, + LastModTime: file.ModTime, + }) + } else { + return nil, errors.New("Vendordep file name does not match format '" + file.Name + "'") + } + } + + return allDeps, nil +} diff --git a/cmd/vendordep/store.go b/cmd/vendordep/store.go new file mode 100644 index 0000000..e27b73a --- /dev/null +++ b/cmd/vendordep/store.go @@ -0,0 +1,80 @@ +package vendordep + +import ( + "log/slog" + "os" + "path/filepath" + "rph/state" +) + +const vendordepDir = "vendordeps" + +func MkCacheDir() { + os.MkdirAll(filepath.Join(state.CachePath, vendordepDir), 0755); +} + +func Trash(path string) error { + _, err := os.Stat(path) + if err != nil { + slog.Error("Can't trash file, path must be a valid file", "path", path, "error", err) + return err + } + + file, err := os.Open(path) + if err != nil { + slog.Error("Failed to open vendordep file", "error", err) + return err + } + defer file.Close() + + dep, err := Parse(file) + if err != nil { + slog.Error("Failed to parse vendordep file", "error", err) + } + + err = os.Rename(path, filepath.Join(state.CachePath, vendordepDir, dep.FileName)) + if err != nil { + slog.Info("Failed to move vendor dep", "error", err) + return err + } + + return nil +} + +// hasVendorDepOnDisk check if a vendor dep is already on your disk, this is only +// useful if you've got the uuid of the vendordep you would like to install or +// are in a very percarious situation where you have no internet and any version +// of your vendordep will do. +func hasVendorDepOnDisk(dep Vendordep, strict bool) (bool, error) { + path := filepath.Join(state.CachePath, vendordepDir) + + // by default we're not matching + matches := false + + err := filepath.WalkDir(path, func(path string, d os.DirEntry, err error) error { + if err != nil { return err } + if d.IsDir() { return nil } + + file, err := os.Open(filepath.Join(path, d.Name())) + if err != nil { + slog.Error("Failed to open vendordep file", "error", err) + return err + } + defer file.Close() + + new_dep, err := Parse(file) + if err != nil { + slog.Error("Failed to parse vendordep file", "error", err) + } + + matches = new_dep.Matches(dep, strict) + return nil + }) + + if err != nil { + slog.Error("Failed to walk the vendordep directory", "error", err) + return false, err + } + + return matches, nil +} diff --git a/cmd/vendordep/vendordep.go b/cmd/vendordep/vendordep.go new file mode 100644 index 0000000..15b0cb5 --- /dev/null +++ b/cmd/vendordep/vendordep.go @@ -0,0 +1,129 @@ +package vendordep + +import ( + "encoding/json" + "errors" + "io" + "io/fs" + "log/slog" + "rph/utils" +) + +type JavaDepedency struct { + GroupId string `json:"groupId"` + ArtifactId string `json:"artifactId"` + Version string `json:"version"` +} + +type JniDependency struct { + GroupId string `json:"groupId"` + ArtifactId string `json:"artifactId"` + Version string `json:"version"` + IsJar bool `json:"isJar"` + SkipInvalidPlatforms bool `json:"skipInvalidPlatforms"` + ValidPlatforms []string `json:"validPlatforms"` + SimMode string `json:"simMode"` +} + +type CppDependency struct { + GroupId string `json:"groupId"` + ArtifactId string `json:"artifactId"` + Version string `json:"version"` + LibName string `json:"libName"` + HeaderClassifier string `json:"headerClassifier"` + SharedLibrary bool `json:"sharedLibrary"` + SkipInvalidPlatforms bool `json:"skipInvalidPlatforms"` + BinaryPlatforms []string `json:"binaryPlatforms"` + SimMode string `json:"simMode"` +} + +type Vendordep struct { + FileName string `json:"filename"` + Name string `json:"name"` + Version string `json:"version"` + FrcYear utils.StringOrNumber `json:"frcYear"` + UUID string `json:"uuid"` + MavenUrls []string `json:"mavenUrls"` + JsonUrl string `json:"jsonUrl"` + JavaDependencies []JavaDepedency `json:"javaDependencies"` + JniDependencies []JniDependency `json:"jniDependencies"` + CppDependencies []CppDependency `json:"cppDependencies"` +} + +// Matches check if one vendor dep matches another in any real useful way +func (v *Vendordep) Matches(other Vendordep, strict bool) bool { + if other.Name != "" && v.Name == other.Name { + if other.Version != "" && v.Version == other.Version { + return true + } + } + if other.UUID != "" && v.UUID == other.UUID { return true } + if !strict { + if other.JsonUrl != "" && v.JsonUrl == other.JsonUrl { return true } + } + + return false +} + +func Parse(vendordepFile io.Reader) (*Vendordep, error) { + var vendordep Vendordep + + if err := json.NewDecoder(vendordepFile).Decode(&vendordep); err != nil { + slog.Error("Invalid vendor dependency Error decoding JSON", "error", err) + return nil, err + } + + return &vendordep, nil +} + +func ListVendorDeps(projectFs fs.FS) ([]Vendordep, error) { + var out []Vendordep + + err := fs.WalkDir(projectFs, "vendordeps", func(path string, d fs.DirEntry, + err error) error { + if err != nil { return err } + if d.IsDir() { return nil } + + file, err := projectFs.Open(path) + if err != nil { + slog.Error("unable to open vendor directory", "error", err) + return err + } + defer file.Close() + + dep, err := Parse(file) + if err != nil { + slog.Error("Unable to parse vendordep file", "file", d.Name, "error", err) + return err + } + + out = append(out, *dep) + return nil + }) + + if err != nil { + slog.Error("Unable to read vendordep fs", "error", err) + return nil, err + } + + return out, nil +} + +func FindVendorDepFromName(name string, fs fs.FS) (*Vendordep, error) { + deps, err := ListVendorDeps(fs); + if err != nil { + slog.Error("Unable to find vendor deps", "error", err) + return nil, err + } + + for _, dep := range deps { + if dep.Name == name { + return &dep, nil + } + } + + return nil, errors.New("Vendordep not found") +} + +func ShowInfo(Vendordep) { +} diff --git a/cmd/vendordepadd.go b/cmd/vendordepadd.go new file mode 100644 index 0000000..9c3e381 --- /dev/null +++ b/cmd/vendordepadd.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "encoding/json" + "log/slog" + "net/http" + "os" + "path/filepath" + "rph/cmd/template" + "rph/cmd/vendordep" + "rph/cmd/vendordep/artifactory" + "rph/utils" + "strings" + + "github.com/spf13/cobra" +) + +// vendordepaddCmd represents the vendordep add command +var vendordepaddCmd = &cobra.Command{ + Use: "add", + Short: "Add a new vendordep", + Long: `Add a new vendordep. You may pass in as many urls or vendordep names +as you wish. The vendordep names are determined by what's found at +https://frcmaven.wpi.edu/ui/native/vendordeps/`, + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + year, err := cmd.Flags().GetString("year") + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + if year == "" { + file, err := os.Open(filepath.Join(projectDir, ".wpilib", "wpilib_preferences.json")) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer file.Close() + + var wpilibPrefs template.WpilibPreferences + if err := json.NewDecoder(file).Decode(&wpilibPrefs); err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + year = wpilibPrefs.Year + } + + // TODO: refactor this into it's own func, cache it and then we can make + // less api calls + validVendordeps, err := vendordep.ListAvailableOnlineDeps(year) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var completions []string + for k, deps := range validVendordeps { + for _, dep := range deps { + comp := k + "-" + dep.Version + if strings.HasPrefix(comp, toComplete) { + completions = append(completions, comp) + } + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + if !inProjectDir() { return nil } + + year, err := cmd.Flags().GetString("year") + if err != nil { return err } + + if year == "" { + file, err := os.Open(filepath.Join(projectDir, ".wpilib", "wpilib_preferences.json")) + if err != nil { + slog.Error("Failed to open wpilib_preferences.json", "error", err) + return err + } + defer file.Close() + + var wpilibPrefs template.WpilibPreferences + if err := json.NewDecoder(file).Decode(&wpilibPrefs); err != nil { + slog.Error("Failed to decode wpilib_preferences.json", "error", err) + return err + } + + year = wpilibPrefs.Year + } + + fsys := artifactory.New(artifactory.DefaultVendorDepArtifactoryUrl) + path := "vendordeps/vendordep-marketplace/" + year + + // make sure the vendordep directory exists in the current project + os.MkdirAll(filepath.Join(projectDir, "vendordeps"), 0755); + + for _, arg := range args { + if strings.HasPrefix(arg, "http") { + resp, err := http.Get(arg) + if err != nil { + slog.Error("Failed to download file", "url", arg, "error", err) + return err + } + defer resp.Body.Close() + + dep, err := vendordep.Parse(resp.Body) + if err != nil { + slog.Error("Failed to parse vendor dep", "error", err) + return err + } + + err = utils.DownloadFile(arg, filepath.Join(projectDir, "vendordeps", dep.FileName)) + if err != nil { + slog.Error("Failed to download vendordep", "error", err) + } + } else { + vendordeps, err := vendordep.ListAvailableOnlineDeps(year) + if err != nil { + return err + } + + for k, deps := range vendordeps { + for _, dep := range deps { + d := k + "-" + dep.Version + if d == arg { + url := fsys.GetUrl(path + "/" + dep.FileName) + err := utils.DownloadFile(url, filepath.Join(projectDir, "vendordeps", dep.FileName)) + if err != nil { + slog.Error("Failed to copy the file to the filesystem", "error", err) + return err + } + + break + } + } + } + } + } + + // TODO: tell the user to gradle build + + return nil + }, +} + +func init() { + vendordepCmd.AddCommand(vendordepaddCmd) + vendordepaddCmd.Flags().StringP("year", "y", "", "override the year to search for dependencies in frcmaven.") +} diff --git a/cmd/vendordeplist.go b/cmd/vendordeplist.go new file mode 100644 index 0000000..5f7a9a9 --- /dev/null +++ b/cmd/vendordeplist.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + "log/slog" + "rph/cmd/vendordep" + + "github.com/spf13/cobra" +) + +// vendordeplistCmd represents the vendordep list command +var vendordeplistCmd = &cobra.Command{ + Use: "list", + Short: "List out your vendordeps", + Long: `A longer description that spans multiple lines and likely contains examples +and usage of using your command. For example: + +Cobra is a CLI library for Go that empowers applications. +This application is a tool to generate the needed files +to quickly create a Cobra application.`, + Aliases: []string{ "ls" }, + RunE: func(cmd *cobra.Command, args []string) error { + if !inProjectDir() { return nil } + + deps, err := vendordep.ListVendorDeps(projectFs) + if err != nil { + slog.Error("Unable to list vendor deps", "error", err) + return err + } + + for _, dep := range deps { + fmt.Println(dep.Name) + } + + return nil + }, +} + +func init() { + vendordepCmd.AddCommand(vendordeplistCmd) +} diff --git a/cmd/vendordepremove.go b/cmd/vendordepremove.go new file mode 100644 index 0000000..80f4677 --- /dev/null +++ b/cmd/vendordepremove.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "log/slog" + "os" + "path/filepath" + "rph/cmd/vendordep" + + "github.com/spf13/cobra" +) + +// vendordepremoveCmd represents the vendordep remove command +var vendordepremoveCmd = &cobra.Command{ + Use: "remove", + Short: "Remove a vendordep", + Long: `Remove a vendordep, by default this will move the vendordep file into +a safe place just incase you wish to undo this action to make sure it's been +removed from your disk you may use the -f flag.`, + Aliases: []string{ "rm" }, + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: vendorDepsComp, + RunE: func(cmd *cobra.Command, args []string) error { + if !inProjectDir() { return nil } + + force, err := cmd.Flags().GetBool("force") + if err != nil { return err } + + for _, n := range args { + dep, err := vendordep.FindVendorDepFromName(args[0], projectFs) + if err != nil { + slog.Error("Failed to get vendor dep from name", "name", n, "error", err) + return err + } + + if force { + err = os.Remove(filepath.Join(projectDir, "vendordeps", dep.FileName)) + if err != nil { + slog.Error("Failed to remove vendor dep", "error", err) + return err + } + } else { + err = vendordep.Trash(filepath.Join(projectDir, "vendordeps", dep.FileName)) + if err != nil { + slog.Error("Failed to move vendor dep to trash", "error", err) + return err + } + } + } + + return nil + }, +} + +func init() { + vendordepCmd.AddCommand(vendordepremoveCmd) + + vendordepremoveCmd.Flags().BoolP("force", "f", false, "Forcefully remove a vendor dependency.") +} diff --git a/utils/downloader.go b/utils/downloader.go new file mode 100644 index 0000000..ebaed09 --- /dev/null +++ b/utils/downloader.go @@ -0,0 +1,156 @@ +package utils + +import ( + "io" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "github.com/charmbracelet/bubbles/progress" + tea "github.com/charmbracelet/bubbletea" +) + +const ( + padding = 2 + maxWidth = 80 +) + +func DownloadFile(url string, outpath string) error { + var progressBar = true + + // Create the file + out, err := os.Create(outpath) + if err != nil { return err } + defer out.Close() + + // Get the data + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.ContentLength <= 0 { + slog.Warn("Can't parse content length, no progress bar will be shown.") + progressBar = false + } + + var p *tea.Program + pw := &progressWriter{ + total: int(resp.ContentLength), + file: out, + reader: resp.Body, + originUrl: url, + onProgress: func(ratio float64) { + p.Send(progressMsg(ratio)) + }, + } + + p = tea.NewProgram(downloadModel{ + pw: pw, + progress: progress.New(progress.WithDefaultGradient()), + }) + + + if progressBar { + // start the download + go pw.Start() + if _, err := p.Run(); err != nil { + slog.Error("Error starting the progress bar", "error", err) + } + } else { + // we need to block the file and stream from closing + pw.Start() + } + + return err +} + +type progressMsg float64 +type progressErrMsg struct{ err error } + +func finalPause() tea.Cmd { + return tea.Tick(time.Millisecond*750, func(_ time.Time) tea.Msg { + return nil + }) +} + +type progressWriter struct { + total int + downloaded int + file *os.File + reader io.Reader + originUrl string + onProgress func(float64) +} + +func (pw *progressWriter) Start() { + // TeeReader calls pw.Write() each time a new response is received + _, err := io.Copy(pw.file, io.TeeReader(pw.reader, pw)) + if err != nil { + slog.Error("Error in progress writer", "error", progressErrMsg{err}) + } +} + +func (pw *progressWriter) Write(p []byte) (int, error) { + pw.downloaded += len(p) + if pw.total > 0 && pw.onProgress != nil { + pw.onProgress(float64(pw.downloaded) / float64(pw.total)) + } + return len(p), nil +} + +type downloadModel struct { + pw *progressWriter + progress progress.Model + err error +} + +func (m downloadModel) Init() tea.Cmd { + return nil +} + +func (m downloadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m, tea.Quit + + case tea.WindowSizeMsg: + m.progress.Width = min(msg.Width - padding * 2 - 4, maxWidth) + return m, nil + + case progressErrMsg: + m.err = msg.err + return m, tea.Quit + + case progressMsg: + var cmds []tea.Cmd + + if msg >= 1.0 { + cmds = append(cmds, tea.Sequence(finalPause(), tea.Quit)) + } + + cmds = append(cmds, m.progress.SetPercent(float64(msg))) + return m, tea.Batch(cmds...) + + // FrameMsg is sent when the progress bar wants to animate itself + case progress.FrameMsg: + progressModel, cmd := m.progress.Update(msg) + m.progress = progressModel.(progress.Model) + return m, cmd + + default: + return m, nil + } +} + +func (m downloadModel) View() string { + if m.err != nil { + return "Error downloading: " + m.err.Error() + "\n" + } + + pad := strings.Repeat(" ", padding) + return "\n" + pad + "Downloading " + m.pw.originUrl + " to " + m.pw.file.Name() + "\n\n" + pad + m.progress.View() + "\n\n" + pad + "Press any key to quit" +} diff --git a/utils/stringOrNumber.go b/utils/stringOrNumber.go new file mode 100644 index 0000000..b8bb8a6 --- /dev/null +++ b/utils/stringOrNumber.go @@ -0,0 +1,24 @@ +package utils + +import ( + "encoding/json" + "fmt" +) + +type StringOrNumber string + +func (s *StringOrNumber) UnmarshalJSON(b []byte) error { + var asString string + if err := json.Unmarshal(b, &asString); err == nil { + *s = StringOrNumber(asString) + return nil + } + + var asNumber float64 + if err := json.Unmarshal(b, &asNumber); err == nil { + *s = StringOrNumber(fmt.Sprintf("%.0f", asNumber)) + return nil + } + + return fmt.Errorf("invalid frcYear format") +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..8f444ec --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,41 @@ +package utils + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" +) + +func FindEntryDirInParents(startPath string, lookingFor string) (string, error) { + absPath, err := filepath.Abs(startPath) + if err != nil { + slog.Error("Unable to get the absolute path", "path", startPath, "error", err) + return "", err + } + + // Find our depth so we don't have to do more checking than necessary + components := strings.Split(filepath.Clean(absPath), string(os.PathSeparator)) + + // Start searching from the current directory upwards + currentPath := absPath + for i := len(components); i > 0; i-- { + lookingForPath := filepath.Join(currentPath, lookingFor) + + _, err := os.Stat(lookingForPath) + if err == nil { + realLookingForPath, err := filepath.Abs(currentPath) + if err != nil { + slog.Error("Unable to get the absolute path", "path", lookingForPath, "error", err) + return "", err + } + return realLookingForPath, nil + } + + // Go up one directory level + currentPath = filepath.Dir(currentPath) + } + + return "", fmt.Errorf(lookingFor + " not found") +}