Skip to content

Commit 0ab73ef

Browse files
chuongld20claude
andcommitted
Merge ISS-52-community-templates-registry into main
Resolve import conflict in main.go — keep both plugin and registry imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2 parents a329700 + bb1f6d9 commit 0ab73ef

12 files changed

Lines changed: 580 additions & 3 deletions

File tree

cmd/devbox/main.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/junixlabs/devbox/internal/metrics"
2020
"github.com/junixlabs/devbox/internal/plugin"
2121
"github.com/junixlabs/devbox/internal/plugin/docker"
22+
"github.com/junixlabs/devbox/internal/registry"
2223
"github.com/junixlabs/devbox/internal/server"
2324
"github.com/junixlabs/devbox/internal/snapshot"
2425
devboxssh "github.com/junixlabs/devbox/internal/ssh"
@@ -976,6 +977,9 @@ func templateCmd() *cobra.Command {
976977
}
977978
cmd.AddCommand(templateListCmd())
978979
cmd.AddCommand(templateCreateCmd())
980+
cmd.AddCommand(templateSearchCmd())
981+
cmd.AddCommand(templatePullCmd())
982+
cmd.AddCommand(templatePushCmd())
979983
return cmd
980984
}
981985

@@ -1056,6 +1060,98 @@ func templateCreateCmd() *cobra.Command {
10561060
return cmd
10571061
}
10581062

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

internal/registry/registry.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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+
if query == "" {
87+
return nil, nil
88+
}
89+
90+
q := strings.ToLower(query)
91+
var matches []IndexEntry
92+
for _, e := range entries {
93+
if strings.Contains(strings.ToLower(e.Name), q) ||
94+
strings.Contains(strings.ToLower(e.Description), q) {
95+
matches = append(matches, e)
96+
}
97+
}
98+
return matches, nil
99+
}
100+
101+
// Pull downloads a template by name from the registry and saves it to the
102+
// local registry.
103+
func (r *RemoteRegistry) Pull(name string, localReg *template.LocalRegistry) (*template.Template, error) {
104+
entries, err := r.FetchIndex()
105+
if err != nil {
106+
return nil, err
107+
}
108+
109+
var entry *IndexEntry
110+
for i := range entries {
111+
if entries[i].Name == name {
112+
entry = &entries[i]
113+
break
114+
}
115+
}
116+
if entry == nil {
117+
return nil, fmt.Errorf("template %q not found in registry", name)
118+
}
119+
120+
// Only allow safe relative URLs to prevent SSRF via malicious index entries.
121+
templateURL := entry.URL
122+
if strings.HasPrefix(templateURL, "http://") || strings.HasPrefix(templateURL, "https://") ||
123+
strings.HasPrefix(templateURL, "//") || strings.Contains(templateURL, "..") ||
124+
strings.Contains(templateURL, ":") {
125+
return nil, fmt.Errorf("template %q has unsafe URL %q", name, templateURL)
126+
}
127+
templateURL = r.baseURL + "/" + strings.TrimLeft(templateURL, "/")
128+
129+
resp, err := r.client.Get(templateURL)
130+
if err != nil {
131+
return nil, fmt.Errorf("failed to download template %q: %w", name, err)
132+
}
133+
defer resp.Body.Close()
134+
135+
if resp.StatusCode != http.StatusOK {
136+
return nil, fmt.Errorf("failed to download template %q: HTTP %d", name, resp.StatusCode)
137+
}
138+
139+
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to read template %q: %w", name, err)
142+
}
143+
144+
var t template.Template
145+
if err := yaml.Unmarshal(data, &t); err != nil {
146+
return nil, fmt.Errorf("failed to parse template %q: %w", name, err)
147+
}
148+
149+
if t.Name == "" {
150+
t.Name = name
151+
}
152+
153+
if err := localReg.Save(&t); err != nil {
154+
return nil, fmt.Errorf("failed to save template %q: %w", name, err)
155+
}
156+
157+
return &t, nil
158+
}
159+
160+
// Push reads a template from the local registry and outputs its YAML content
161+
// for manual submission to the community registry.
162+
func (r *RemoteRegistry) Push(name string, localReg *template.LocalRegistry) (string, error) {
163+
t, err := localReg.Get(name)
164+
if err != nil {
165+
return "", fmt.Errorf("failed to read local template %q: %w", name, err)
166+
}
167+
168+
data, err := yaml.Marshal(t)
169+
if err != nil {
170+
return "", fmt.Errorf("failed to marshal template %q: %w", name, err)
171+
}
172+
173+
return string(data), nil
174+
}

0 commit comments

Comments
 (0)