Skip to content

Commit 4a3735e

Browse files
chuongld20claude
andcommitted
feat: add community template registry with search/pull/push commands
Add remote template registry backed by HTTP index (GitHub raw URLs). New internal/registry package with RemoteRegistry client supporting search, pull, and push operations. Three new CLI subcommands wired into `devbox template`. Version field added to Template struct. Two new built-in templates (django, rust) bringing total to 7. All existing templates updated with version "1.0.0". Closes ISS-52 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c0b0718 commit 4a3735e

12 files changed

Lines changed: 547 additions & 3 deletions

File tree

cmd/devbox/main.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
devboxerr "github.com/junixlabs/devbox/internal/errors"
1818
"github.com/junixlabs/devbox/internal/identity"
1919
"github.com/junixlabs/devbox/internal/metrics"
20+
"github.com/junixlabs/devbox/internal/registry"
2021
"github.com/junixlabs/devbox/internal/server"
2122
"github.com/junixlabs/devbox/internal/snapshot"
2223
devboxssh "github.com/junixlabs/devbox/internal/ssh"
@@ -973,6 +974,9 @@ func templateCmd() *cobra.Command {
973974
}
974975
cmd.AddCommand(templateListCmd())
975976
cmd.AddCommand(templateCreateCmd())
977+
cmd.AddCommand(templateSearchCmd())
978+
cmd.AddCommand(templatePullCmd())
979+
cmd.AddCommand(templatePushCmd())
976980
return cmd
977981
}
978982

@@ -1053,6 +1057,98 @@ func templateCreateCmd() *cobra.Command {
10531057
return cmd
10541058
}
10551059

1060+
func templateSearchCmd() *cobra.Command {
1061+
cmd := &cobra.Command{
1062+
Use: "search <query>",
1063+
Short: "Search templates in the community registry",
1064+
Args: cobra.ExactArgs(1),
1065+
RunE: func(cmd *cobra.Command, args []string) error {
1066+
registryURL, _ := cmd.Flags().GetString("registry")
1067+
reg := registry.NewRemoteRegistry(registryURL)
1068+
1069+
results, err := reg.Search(args[0])
1070+
if err != nil {
1071+
return fmt.Errorf("devbox template search: %w", err)
1072+
}
1073+
1074+
if len(results) == 0 {
1075+
fmt.Println("No templates found")
1076+
return nil
1077+
}
1078+
1079+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
1080+
fmt.Fprintln(w, "NAME\tVERSION\tDESCRIPTION")
1081+
for _, e := range results {
1082+
fmt.Fprintf(w, "%s\t%s\t%s\n", e.Name, e.Version, e.Description)
1083+
}
1084+
return w.Flush()
1085+
},
1086+
}
1087+
cmd.Flags().String("registry", "", "Custom registry URL")
1088+
return cmd
1089+
}
1090+
1091+
func templatePullCmd() *cobra.Command {
1092+
cmd := &cobra.Command{
1093+
Use: "pull <name>",
1094+
Short: "Download a template from the community registry",
1095+
Args: cobra.ExactArgs(1),
1096+
RunE: func(cmd *cobra.Command, args []string) error {
1097+
registryURL, _ := cmd.Flags().GetString("registry")
1098+
reg := registry.NewRemoteRegistry(registryURL)
1099+
1100+
localReg, err := tmpl.NewDefaultRegistry()
1101+
if err != nil {
1102+
return fmt.Errorf("devbox template pull: %w", err)
1103+
}
1104+
1105+
t, err := reg.Pull(args[0], localReg)
1106+
if err != nil {
1107+
return fmt.Errorf("devbox template pull: %w", err)
1108+
}
1109+
1110+
version := ""
1111+
if t.Version != "" {
1112+
version = fmt.Sprintf(" (v%s)", t.Version)
1113+
}
1114+
fmt.Printf("Template %q%s saved to local registry\n", t.Name, version)
1115+
return nil
1116+
},
1117+
}
1118+
cmd.Flags().String("registry", "", "Custom registry URL")
1119+
return cmd
1120+
}
1121+
1122+
func templatePushCmd() *cobra.Command {
1123+
cmd := &cobra.Command{
1124+
Use: "push <name>",
1125+
Short: "Publish a template to the community registry",
1126+
Args: cobra.ExactArgs(1),
1127+
RunE: func(cmd *cobra.Command, args []string) error {
1128+
registryURL, _ := cmd.Flags().GetString("registry")
1129+
reg := registry.NewRemoteRegistry(registryURL)
1130+
1131+
localReg, err := tmpl.NewDefaultRegistry()
1132+
if err != nil {
1133+
return fmt.Errorf("devbox template push: %w", err)
1134+
}
1135+
1136+
output, err := reg.Push(args[0], localReg)
1137+
if err != nil {
1138+
return fmt.Errorf("devbox template push: %w", err)
1139+
}
1140+
1141+
fmt.Println("# Template YAML for submission:")
1142+
fmt.Println(output)
1143+
fmt.Println("# To publish, submit a PR to the community registry repo:")
1144+
fmt.Printf("# %s\n", registry.DefaultRegistryURL)
1145+
return nil
1146+
},
1147+
}
1148+
cmd.Flags().String("registry", "", "Custom registry URL")
1149+
return cmd
1150+
}
1151+
10561152
func tuiCmd(wm workspace.Manager) *cobra.Command {
10571153
return &cobra.Command{
10581154
Use: "tui",

internal/registry/registry.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package registry
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"strings"
8+
"time"
9+
10+
"github.com/junixlabs/devbox/internal/template"
11+
"gopkg.in/yaml.v3"
12+
)
13+
14+
// DefaultRegistryURL is the base URL for the community template registry.
15+
const DefaultRegistryURL = "https://raw.githubusercontent.com/junixlabs/devbox-templates/main"
16+
17+
// maxResponseBytes limits HTTP response body size to prevent memory exhaustion.
18+
const maxResponseBytes = 1 << 20 // 1 MB
19+
20+
// IndexEntry describes a single template in the remote registry index.
21+
type IndexEntry struct {
22+
Name string `yaml:"name"`
23+
Version string `yaml:"version"`
24+
Description string `yaml:"description"`
25+
URL string `yaml:"url"`
26+
UpdatedAt time.Time `yaml:"updated_at"`
27+
}
28+
29+
// Index is the top-level structure of the registry index file.
30+
type Index struct {
31+
Templates []IndexEntry `yaml:"templates"`
32+
}
33+
34+
// RemoteRegistry fetches templates from an HTTP-based registry.
35+
type RemoteRegistry struct {
36+
baseURL string
37+
client *http.Client
38+
}
39+
40+
// NewRemoteRegistry creates a new remote registry client.
41+
// If baseURL is empty, DefaultRegistryURL is used.
42+
func NewRemoteRegistry(baseURL string) *RemoteRegistry {
43+
if baseURL == "" {
44+
baseURL = DefaultRegistryURL
45+
}
46+
return &RemoteRegistry{
47+
baseURL: strings.TrimRight(baseURL, "/"),
48+
client: &http.Client{Timeout: 30 * time.Second},
49+
}
50+
}
51+
52+
// FetchIndex downloads and parses the registry index.
53+
func (r *RemoteRegistry) FetchIndex() ([]IndexEntry, error) {
54+
url := r.baseURL + "/index.yaml"
55+
resp, err := r.client.Get(url)
56+
if err != nil {
57+
return nil, fmt.Errorf("failed to fetch registry index: %w", err)
58+
}
59+
defer resp.Body.Close()
60+
61+
if resp.StatusCode != http.StatusOK {
62+
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
63+
}
64+
65+
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
66+
if err != nil {
67+
return nil, fmt.Errorf("failed to read registry index: %w", err)
68+
}
69+
70+
var idx Index
71+
if err := yaml.Unmarshal(data, &idx); err != nil {
72+
return nil, fmt.Errorf("failed to parse registry index: %w", err)
73+
}
74+
75+
return idx.Templates, nil
76+
}
77+
78+
// Search returns index entries matching the query (case-insensitive substring
79+
// match on name or description).
80+
func (r *RemoteRegistry) Search(query string) ([]IndexEntry, error) {
81+
entries, err := r.FetchIndex()
82+
if err != nil {
83+
return nil, err
84+
}
85+
86+
q := strings.ToLower(query)
87+
var matches []IndexEntry
88+
for _, e := range entries {
89+
if strings.Contains(strings.ToLower(e.Name), q) ||
90+
strings.Contains(strings.ToLower(e.Description), q) {
91+
matches = append(matches, e)
92+
}
93+
}
94+
return matches, nil
95+
}
96+
97+
// Pull downloads a template by name from the registry and saves it to the
98+
// local registry.
99+
func (r *RemoteRegistry) Pull(name string, localReg *template.LocalRegistry) (*template.Template, error) {
100+
entries, err := r.FetchIndex()
101+
if err != nil {
102+
return nil, err
103+
}
104+
105+
var entry *IndexEntry
106+
for i := range entries {
107+
if entries[i].Name == name {
108+
entry = &entries[i]
109+
break
110+
}
111+
}
112+
if entry == nil {
113+
return nil, fmt.Errorf("template %q not found in registry", name)
114+
}
115+
116+
// Only allow relative URLs to prevent SSRF via malicious index entries.
117+
templateURL := entry.URL
118+
if strings.HasPrefix(templateURL, "http://") || strings.HasPrefix(templateURL, "https://") {
119+
return nil, fmt.Errorf("template %q has absolute URL which is not allowed", name)
120+
}
121+
templateURL = r.baseURL + "/" + strings.TrimLeft(templateURL, "/")
122+
123+
resp, err := r.client.Get(templateURL)
124+
if err != nil {
125+
return nil, fmt.Errorf("failed to download template %q: %w", name, err)
126+
}
127+
defer resp.Body.Close()
128+
129+
if resp.StatusCode != http.StatusOK {
130+
return nil, fmt.Errorf("failed to download template %q: HTTP %d", name, resp.StatusCode)
131+
}
132+
133+
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
134+
if err != nil {
135+
return nil, fmt.Errorf("failed to read template %q: %w", name, err)
136+
}
137+
138+
var t template.Template
139+
if err := yaml.Unmarshal(data, &t); err != nil {
140+
return nil, fmt.Errorf("failed to parse template %q: %w", name, err)
141+
}
142+
143+
if t.Name == "" {
144+
t.Name = name
145+
}
146+
147+
if err := localReg.Save(&t); err != nil {
148+
return nil, fmt.Errorf("failed to save template %q: %w", name, err)
149+
}
150+
151+
return &t, nil
152+
}
153+
154+
// Push reads a template from the local registry and outputs its YAML content
155+
// for manual submission to the community registry.
156+
func (r *RemoteRegistry) Push(name string, localReg *template.LocalRegistry) (string, error) {
157+
t, err := localReg.Get(name)
158+
if err != nil {
159+
return "", fmt.Errorf("failed to read local template %q: %w", name, err)
160+
}
161+
162+
data, err := yaml.Marshal(t)
163+
if err != nil {
164+
return "", fmt.Errorf("failed to marshal template %q: %w", name, err)
165+
}
166+
167+
return string(data), nil
168+
}

0 commit comments

Comments
 (0)