feat(vendordep): add vendordep management

- Add vendordep add
- Add vendordep remove
- Add vendordep list
- Refactor downloading
This commit is contained in:
2025-10-17 14:15:37 -04:00
parent e073c0d391
commit 2a22f41fac
18 changed files with 1099 additions and 184 deletions

42
README.md Normal file
View File

@@ -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

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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
}

42
cmd/vendordep.go Normal file
View File

@@ -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)
}

View File

@@ -0,0 +1,3 @@
package artifactory
const DefaultVendorDepArtifactoryUrl = "https://frcmaven.wpi.edu/artifactory"

View File

@@ -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
}

66
cmd/vendordep/remote.go Normal file
View File

@@ -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
}

80
cmd/vendordep/store.go Normal file
View File

@@ -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
}

129
cmd/vendordep/vendordep.go Normal file
View File

@@ -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) {
}

147
cmd/vendordepadd.go Normal file
View File

@@ -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.")
}

41
cmd/vendordeplist.go Normal file
View File

@@ -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)
}

58
cmd/vendordepremove.go Normal file
View File

@@ -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.")
}

156
utils/downloader.go Normal file
View File

@@ -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"
}

24
utils/stringOrNumber.go Normal file
View File

@@ -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")
}

41
utils/utils.go Normal file
View File

@@ -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")
}