feat: inital commit

- added template subcmd which generates frc project templates
This commit is contained in:
2025-10-14 23:48:14 -04:00
commit 12cefa9092
15 changed files with 2037 additions and 0 deletions

25
cmd/root.go Normal file
View File

@@ -0,0 +1,25 @@
package cmd
import (
"os"
"rph/state"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: state.Name,
Short: "Manage your FRC robot code the UNIX way.",
Long: `rph (Robot Pits Helper) is a command line utility with the goal of
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.`,
}
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}

115
cmd/template.go Normal file
View File

@@ -0,0 +1,115 @@
package cmd
import (
"fmt"
"log/slog"
"rph/cmd/template"
"rph/utils"
"slices"
"github.com/spf13/cobra"
)
var desktopSupportFlag utils.BoolFlag
// templateCmd represents the template command
var templateCmd = &cobra.Command{
Use: "template",
Short: "Generate a new WPILIB project from a template.",
Long: `Generates a new WPILib robot project from a template archive.
You can pass flags or leave them out to be prompted interactively.
If you wish to skip the interactive ui then you must pass all of your
options in using the following flags:
--lang, --type, --dir, --team, --desktopSupport
Example:
rph template --lang=java --type=commandbased --dir=MyRobot --team=5438 --desktopSupport=false`,
PreRun: func(cmd *cobra.Command, args []string) {
template.Fetch(false, "keep")
},
RunE: func(cmd *cobra.Command, args []string) error {
lang, err := cmd.Flags().GetString("lang")
if err != nil { return err }
projectType, err := cmd.Flags().GetString("type")
if err != nil { return err }
types, err := cmd.Flags().GetBool("types")
if err != nil { return err }
dir, err := cmd.Flags().GetString("dir")
if err != nil { return err }
team, err := cmd.Flags().GetUint64("team")
if err != nil { return err }
// by default desktopSupport is nil to allow the interactive ui to show
var desktopSupport *bool
if desktopSupportFlag.IsSet {
desktopSupport = &desktopSupportFlag.Value
}
var langs []string
var projectTypes []string
if types || lang != "" || projectType != "" {
langs, err = template.GetLangs();
if err != nil {
slog.Error("Unable to get langs", "error", err)
return err
}
if lang != "" {
projectTypes, err = template.GetProjects(lang);
if err != nil {
slog.Error("Unable to get project types", "error", err)
return err
}
}
}
if types {
if lang != "" {
for _, e := range projectTypes {
fmt.Println(e)
}
} else {
for _, e := range langs {
fmt.Println(e)
}
}
}
// ensure that lang and projectType are valid
if lang != "" {
if !slices.Contains(langs, lang) {
slog.Error("Language is not valid", "language", lang)
return nil
}
}
if projectType != "" {
if !slices.Contains(projectTypes, projectType) {
slog.Error("Project type is not valid", "type", projectType)
return nil
}
}
template.GenerateProject(template.TemplateOptions{
Lang: lang,
ProjectType: projectType,
Dir: dir,
Team: team,
DesktopSupport: desktopSupport,
})
return err
},
}
func init() {
rootCmd.AddCommand(templateCmd)
templateCmd.Flags().StringP("lang", "l", "", "The language of the project")
templateCmd.Flags().StringP("type", "t", "", "The type of the project")
templateCmd.Flags().Bool("types", false, "List the languages available or if lang is specified the types of projects for that lang")
templateCmd.Flags().StringP("dir", "d", "", "The directory which will contain the contents of your new project")
templateCmd.Flags().Uint64P("team", "n", 0, "Your team number")
templateCmd.Flags().VarP(&desktopSupportFlag, "desktopSupport", "s", "Enable desktop simulation support")
}

159
cmd/template/configUi.go Normal file
View File

@@ -0,0 +1,159 @@
package template
import (
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"github.com/charmbracelet/huh"
)
var _dummyGroup = huh.NewGroup(huh.NewNote().Title("Dummy"))
type _fieldWrapper struct {
Visible func() (bool)
Field huh.Field
}
func _buildGroups(groups ...*huh.Group) []*huh.Group {
var visibleGroups []*huh.Group
for _, g := range groups {
if g != _dummyGroup {
visibleGroups = append(visibleGroups, g)
}
}
return visibleGroups
}
func _buildGroup(fields ..._fieldWrapper) *huh.Group {
var visibleFields []huh.Field
for _, f := range fields {
if f.Visible() {
visibleFields = append(visibleFields, f.Field)
}
}
if len(visibleFields) > 0 {
return huh.NewGroup(visibleFields...)
}
return _dummyGroup
}
func openConfigUi(opts TemplateOptions) (TemplateOptions, error) {
var lang string = opts.Lang
var projectType string = opts.ProjectType
var dir string = opts.Dir
var team string
tmp := strconv.FormatUint(opts.Team, 10)
if tmp != "0" {
team = tmp
}
var desktopSupport bool
if opts.DesktopSupport != nil {
desktopSupport = *opts.DesktopSupport
}
// Display form with selected theme.
err := huh.NewForm(
_buildGroups(
_buildGroup(
_fieldWrapper{
Visible: func() bool { return lang == "" },
Field: huh.NewSelect[string]().
OptionsFunc(func() []huh.Option[string] {
langs, err := GetLangs()
if err != nil {
slog.Error("Unable to get languages", "error", err)
os.Exit(1)
}
opts := make([]huh.Option[string], len(langs))
for i, e := range langs {
opts[i] = huh.Option[string]{Value: e, Key: e}
}
return opts
}, nil).
Description("Choose your desired language").
Value(&lang).
Height(4),
},
_fieldWrapper{
Visible: func() bool { return projectType == "" },
Field: huh.NewSelect[string]().
OptionsFunc(func() []huh.Option[string] {
if lang == "" {
return []huh.Option[string]{}
}
projects, err := GetProjects(lang)
if err != nil {
slog.Error("Unable to get project types", "error", err)
os.Exit(1)
}
opts := make([]huh.Option[string], len(projects))
for i, e := range projects {
opts[i] = huh.Option[string]{Value: e, Key: e}
}
return opts
}, &lang).
Description("Choose your project type").
Value(&projectType),
},
).Title("Project Language & Type"),
_buildGroup(
_fieldWrapper{
Visible: func() bool { return dir == "" },
Field: huh.NewInput().
Title("Project Path").
Placeholder("This is the full path to the project").
Value(&dir),
},
_fieldWrapper{
Visible: func() bool { return team == "" },
Field: huh.NewInput().
Title("Team Number").
Placeholder("Your teams number").
Value(&team).
Validate(func(s string) error {
_, err := strconv.Atoi(s)
if err != nil {
return errors.New("must be a number")
}
return nil
}),
},
_fieldWrapper{
Visible: func() bool { return opts.DesktopSupport == nil },
Field: huh.NewConfirm().
Title("Enable Desktop Support?").
Value(&desktopSupport),
},
).Title("Project Setup Information"),
)...,
).Run()
if err != nil {
if err == huh.ErrUserAborted {
os.Exit(130)
}
fmt.Println(err)
os.Exit(1)
}
teamnr, _ := strconv.ParseUint(team, 10, 64)
return TemplateOptions{
Lang: lang,
ProjectType: projectType,
Dir: dir,
Team: teamnr,
DesktopSupport: &desktopSupport,
}, nil
}

View File

@@ -0,0 +1,76 @@
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"
}

152
cmd/template/remote.go Normal file
View File

@@ -0,0 +1,152 @@
package template
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"rph/state"
)
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"`
}
type release struct {
TagName string `json:"tag_name"`
Assets []asset `json:"assets"`
}
func getTemplateArchive(filename string, force bool, version string) {
const url = "https://api.github.com/repos/wpilibsuite/vscode-wpilib/releases/"
path := filepath.Join(state.CachePath, filename)
currentVersion, err := LoadArchiveVersion()
// default the version to the latest version if no version is currently
// installed and the user wants to keep the current version
if err != nil && version == "keep" {
version = "latest"
} else if currentVersion != "" && version == "keep" {
version = currentVersion
}
// use tags to select the version when we're not just getting the latest one
if version != "latest" {
version = "tags/" + version
}
resp, err := http.Get(url + version)
if err != nil {
slog.Error("Error fetching release:", "error", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
slog.Error("GitHub API error", "status", resp.Status, "body", string(body))
os.Exit(1)
}
var release release
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
slog.Error("Error decoding JSON", "error", err)
os.Exit(1)
}
_, ferr := os.Stat(path)
currentVersion, err = LoadArchiveVersion()
if !force && err == nil && currentVersion == release.TagName && ferr == nil {
slog.Info("Template archive is already installed", "version", currentVersion)
slog.Info("If you would like to install a different version try: rph template fetch -h")
return
}
var downloadURL string
for _, asset := range release.Assets {
if asset.Name == "templates.zip" {
downloadURL = asset.BrowserDownloadURL
break
}
}
if downloadURL == "" {
slog.Warn("templates.zip not found in release version.", "version", version)
return
}
err = downloadFile(downloadURL)
if err != nil {
slog.Error("Error downloading archive file", "error", err)
os.Exit(1)
}
err = saveArchiveVersion(release.TagName)
if err != nil {
slog.Warn("Failed to save version information", "error", err)
} else {
slog.Info("Downloaded new template file", "version", release.TagName)
}
}
func ListTemplateArchiveVersions(results uint8) []string {
const url = "https://api.github.com/repos/wpilibsuite/vscode-wpilib/releases?per_page="
resp, err := http.Get(url + strconv.Itoa(int(results)))
if err != nil {
slog.Error("Error fetching release:", "error", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
slog.Error("GitHub API error", "status", resp.Status, "body", string(body))
os.Exit(1)
}
var releases []release
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
slog.Error("Error decoding JSON", "error", err)
os.Exit(1)
}
var versions []string
for _, r := range releases {
for _, a := range r.Assets {
if a.Name == "templates.zip" {
versions = append(versions, r.TagName)
}
}
}
return versions
}

145
cmd/template/store.go Normal file
View File

@@ -0,0 +1,145 @@
package template
import (
"context"
"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"
)
const dataFile = "templates.bin"
const zipFile = "templates.zip"
func saveArchiveVersion(version string) error {
return os.WriteFile(
filepath.Join(state.CachePath, dataFile),
[]byte(version),
0644,
)
}
func LoadArchiveVersion() (string, error) {
data, err := os.ReadFile(filepath.Join(state.CachePath, dataFile))
if err != nil {
return "", err
}
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 {
return nil, err
}
return fsys, nil
}
func GetLangs() ([]string, error) {
var langs []string
fsys, err := OpenArchive(context.Background())
if err != nil {
slog.Error("Failed to open archive", "err", err)
return nil, err
}
entries, err := fs.ReadDir(fsys, ".")
if err != nil {
slog.Error("No files found in fsys", "err", err)
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
langs = append(langs, entry.Name())
}
}
return langs, nil
}
func GetProjects(lang string) ([]string, error) {
var projects []string
if lang == "" {
return nil, errors.New("lang must be set")
}
fsys, err := OpenArchive(context.Background())
if err != nil {
slog.Error("Failed to open archive", "err", err)
return nil, err
}
entries, err := fs.ReadDir(fsys, filepath.Join(".", lang))
if err != nil {
slog.Error("No files found in fsys", "err", err)
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
projects = append(projects, entry.Name())
}
}
return projects, nil
}

143
cmd/template/template.go Normal file
View File

@@ -0,0 +1,143 @@
package template
import (
"context"
"encoding/json"
"io/fs"
"log/slog"
"os"
"path/filepath"
"regexp"
"rph/state"
)
type TemplateOptions struct {
Lang string
ProjectType string
Dir string
Team uint64
DesktopSupport *bool
}
type wpilibPreferences struct {
CppIntellisense bool `json:"enableCppIntellisense"`
Lang string `json:"currentLanguage"`
Year string `json:"projectYear"`
Team int `json:"teamNumber"`
}
// Fetch fetch the latest template zip that's distributed by vscode-wpilib
func Fetch(force bool, version string) {
os.MkdirAll(filepath.Join(state.CachePath), 0755)
getTemplateArchive(zipFile, force, version);
}
func GenerateProject(opts TemplateOptions) {
opts, err := openConfigUi(opts)
if err != nil {
slog.Error("Failed to run interactive ui", "error", err)
return
}
err = os.Mkdir(opts.Dir, 0755)
if err != nil {
slog.Error("Failed to create directory", "path", opts.Dir, "error", err)
// TODO: how should we handle if the directory already exists?
}
fsys, err := OpenArchive(context.Background())
if err != nil {
slog.Error("Failed to open archive", "err", err)
return
}
subFS, err := fs.Sub(fsys, filepath.Join(opts.Lang, opts.ProjectType))
if err != nil {
slog.Error("Unable to find project template", "template", opts.ProjectType, "error", err)
}
err = os.CopyFS(opts.Dir, subFS)
if err != nil {
slog.Error("Failed to copy template to destination", "template",
opts.ProjectType, "destination", opts.Dir, "error", err)
os.Exit(1)
}
// Configure the project
err = os.Chmod(filepath.Join(opts.Dir, "gradlew"), 0755)
if err != nil {
slog.Error("Unable to make gradlew executable", "error", err)
}
{
jsonFile := filepath.Join(opts.Dir, ".wpilib", "wpilib_preferences.json")
file, err := os.Open(jsonFile)
if err != nil {
slog.Error(
"Failed to open wpilib preferences file\n\nYou need to put your team number into " +
opts.Dir + "/.wpilib/wpilib_preferences.json",
"error",
err,
)
goto PostJson
}
defer file.Close()
{ // scope it so I can jump over it
var p wpilibPreferences
decoder := json.NewDecoder(file)
err = decoder.Decode(&p)
if err != nil {
slog.Error("Failed to decode json", "error", err)
goto PostJson
}
p.Team = int(opts.Team)
jsonData, err := json.MarshalIndent(p, "", " ")
if err != nil {
slog.Error("Error marshaling JSON:", "error", err)
goto PostJson
}
err = os.WriteFile(jsonFile, jsonData, 0644)
if err != nil {
slog.Error("Error writing to file:", "error", err)
goto PostJson
}
}
PostJson:
}
// This is a big one
{
buildGradleFile := filepath.Join(opts.Dir, "build.gradle")
in, err := os.ReadFile(buildGradleFile)
if err != nil {
slog.Error("Failed to open gradle build file", "error", err)
goto PostGradle
}
{ // scope it so I can jump over it
value := "false"
if *opts.DesktopSupport {
value = "true"
}
// this regex was translated from:
// https://github.com/wpilibsuite/vscode-wpilib/blob/df7fc8bb9db453cbc9ccc32d3c5f81ef53f5e93a/vscode-wpilib/src/shared/generator.ts#L390
re := regexp.MustCompile(`(?m)^(\s*def\s+includeDesktopSupport\s*=\s*)(true|false)\b`)
out := re.ReplaceAllString(string(in), "${1}" + value)
err = os.WriteFile(buildGradleFile, []byte(out), 0644)
if err != nil {
slog.Error("failed to write to gradle build file", "error", err)
goto PostGradle
}
}
PostGradle:
}
}

60
cmd/templatefetch.go Normal file
View File

@@ -0,0 +1,60 @@
package cmd
import (
"fmt"
"rph/cmd/template"
"github.com/spf13/cobra"
)
// templatefetchCmd represents the template fetch command
var templatefetchCmd = &cobra.Command{
Use: "fetch",
Short: "Fetch the template archive which is used for generating templates.",
Long: `Fetch the template archive which is used for generating templates.
Examples:
rph template fetch -v latest # Install the latest version
rph template fetch -v v2025.1.1 # Switch to a specific version`,
RunE: func(cmd *cobra.Command, args []string) error {
force, err := cmd.Flags().GetBool("force")
if err != nil { return err }
version, err := cmd.Flags().GetString("version")
if err != nil { return err }
list, err := cmd.Flags().GetUint8("list")
if err != nil { return err }
getversion, err := cmd.Flags().GetBool("get-version")
if err != nil { return err }
if getversion {
v, err := template.LoadArchiveVersion()
if err != nil {
return err
}
fmt.Println(v)
return nil
}
if list > 0 {
versions := template.ListTemplateArchiveVersions(list)
for _, v := range versions {
fmt.Println(v)
}
return nil
}
template.Fetch(force, version)
return nil
},
}
func init() {
templateCmd.AddCommand(templatefetchCmd)
templatefetchCmd.Flags().Uint8P("list", "l", 0, "List all available template versions.")
templatefetchCmd.Flags().Lookup("list").NoOptDefVal = "10"
templatefetchCmd.Flags().BoolP("force", "f", false, "Force refetch the template archive.")
templatefetchCmd.Flags().StringP("version", "v", "keep", "Change the version of the template archive.")
templatefetchCmd.Flags().BoolP("get-version", "g", false, "Get the currently installed version of the template archive.")
}