1+ // Package main provides the catalog CLI tool for building registry files
2+ // from server.json entries. It discovers registries under a root directory
3+ // and produces output for each one.
4+ package main
5+
6+ import (
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+
12+ "github.com/spf13/cobra"
13+
14+ internalregistry "github.com/stacklok/toolhive-registry/internal/registry"
15+ )
16+
17+ const (
18+ formatToolhive = "toolhive"
19+ formatUpstream = "upstream"
20+ formatAll = "all"
21+
22+ defaultRegistries = "registries"
23+ defaultOutputDir = "build"
24+
25+ // Each registry is expected to have a "servers" subdirectory containing
26+ // individual server directories with server.json files.
27+ serversSubdir = "servers"
28+ )
29+
30+ var (
31+ version = "dev"
32+ commit = "unknown"
33+ date = "unknown"
34+ )
35+
36+ var (
37+ registriesDir string
38+ outputDir string
39+ format string
40+ verbose bool
41+ )
42+
43+ var rootCmd = & cobra.Command {
44+ Use : "catalog" ,
45+ Short : "Build the ToolHive catalog from server.json files" ,
46+ Long : `catalog discovers registries under a root directory and builds
47+ registry files from individual server.json entries for each one.
48+
49+ Given a registries directory (default: registries/), it looks for
50+ subdirectories containing a "servers/" folder with server.json files:
51+
52+ registries/
53+ toolhive/
54+ servers/
55+ github/server.json
56+ ...
57+
58+ For each registry found, it produces output in the build directory:
59+
60+ build/
61+ toolhive/
62+ registry.json
63+ official-registry.json` ,
64+ }
65+
66+ var buildCmd = & cobra.Command {
67+ Use : "build" ,
68+ Short : "Build registry files for all discovered registries" ,
69+ RunE : runBuild ,
70+ }
71+
72+ var validateCmd = & cobra.Command {
73+ Use : "validate" ,
74+ Short : "Validate all server.json entries across all registries" ,
75+ RunE : runValidate ,
76+ }
77+
78+ var versionCmd = & cobra.Command {
79+ Use : "version" ,
80+ Short : "Print version information" ,
81+ Run : func (* cobra.Command , []string ) {
82+ fmt .Printf ("catalog %s\n " , version )
83+ fmt .Printf (" commit: %s\n " , commit )
84+ fmt .Printf (" built: %s\n " , date )
85+ },
86+ }
87+
88+ func init () {
89+ rootCmd .PersistentFlags ().StringVarP (& registriesDir , "registries" , "r" , defaultRegistries , "Path to the registries root directory" )
90+ rootCmd .PersistentFlags ().BoolVarP (& verbose , "verbose" , "v" , false , "Enable verbose output" )
91+
92+ buildCmd .Flags ().StringVarP (& outputDir , "output-dir" , "o" , defaultOutputDir , "Output directory" )
93+ buildCmd .Flags ().StringVarP (& format , "format" , "f" , formatAll ,
94+ fmt .Sprintf ("Output format (%s, %s, %s)" , formatToolhive , formatUpstream , formatAll ))
95+
96+ rootCmd .AddCommand (buildCmd )
97+ rootCmd .AddCommand (validateCmd )
98+ rootCmd .AddCommand (versionCmd )
99+ }
100+
101+ func main () {
102+ if err := rootCmd .Execute (); err != nil {
103+ fmt .Fprintf (os .Stderr , "Error: %v\n " , err )
104+ os .Exit (1 )
105+ }
106+ }
107+
108+ // registryInfo holds the name and loader for a discovered registry.
109+ type registryInfo struct {
110+ name string
111+ loader * internalregistry.Loader
112+ }
113+
114+ // discoverRegistries walks the registries root directory, finds subdirectories
115+ // that contain a "servers/" folder, and returns a loader for each.
116+ func discoverRegistries () ([]registryInfo , error ) {
117+ entries , err := os .ReadDir (registriesDir )
118+ if err != nil {
119+ return nil , fmt .Errorf ("failed to read registries directory %s: %w" , registriesDir , err )
120+ }
121+
122+ var registries []registryInfo
123+ for _ , entry := range entries {
124+ if ! entry .IsDir () || strings .HasPrefix (entry .Name (), "." ) {
125+ continue
126+ }
127+
128+ serversPath := filepath .Join (registriesDir , entry .Name (), serversSubdir )
129+ info , err := os .Stat (serversPath )
130+ if err != nil || ! info .IsDir () {
131+ if verbose {
132+ fmt .Printf ("Skipping %s (no %s/ directory)\n " , entry .Name (), serversSubdir )
133+ }
134+ continue
135+ }
136+
137+ loader := internalregistry .NewLoader (serversPath )
138+ if err := loader .LoadAll (); err != nil {
139+ return nil , fmt .Errorf ("failed to load registry %q: %w" , entry .Name (), err )
140+ }
141+
142+ if verbose {
143+ fmt .Printf ("Discovered registry %q with %d entries\n " , entry .Name (), len (loader .GetEntries ()))
144+ }
145+
146+ registries = append (registries , registryInfo {
147+ name : entry .Name (),
148+ loader : loader ,
149+ })
150+ }
151+
152+ if len (registries ) == 0 {
153+ return nil , fmt .Errorf ("no registries found under %s" , registriesDir )
154+ }
155+
156+ return registries , nil
157+ }
158+
159+ func runBuild (_ * cobra.Command , _ []string ) error {
160+ registries , err := discoverRegistries ()
161+ if err != nil {
162+ return err
163+ }
164+
165+ formats := determineFormats (format )
166+
167+ for _ , reg := range registries {
168+ regOutputDir := filepath .Join (outputDir , reg .name )
169+ if err := os .MkdirAll (regOutputDir , 0750 ); err != nil {
170+ return fmt .Errorf ("failed to create output directory %s: %w" , regOutputDir , err )
171+ }
172+
173+ for _ , f := range formats {
174+ if err := buildFormat (reg .loader , f , regOutputDir ); err != nil {
175+ return fmt .Errorf ("failed to build %s format for registry %q: %w" , f , reg .name , err )
176+ }
177+ }
178+
179+ fmt .Printf ("Built registry %q: %d entries [%s] -> %s\n " ,
180+ reg .name , len (reg .loader .GetEntries ()), strings .Join (formats , ", " ), regOutputDir )
181+ }
182+
183+ return nil
184+ }
185+
186+ func runValidate (_ * cobra.Command , _ []string ) error {
187+ registries , err := discoverRegistries ()
188+ if err != nil {
189+ return err
190+ }
191+
192+ for _ , reg := range registries {
193+ upstreamBuilder := internalregistry .NewBuilder (reg .loader )
194+ if err := upstreamBuilder .ValidateAgainstSchema (); err != nil {
195+ return fmt .Errorf ("registry %q: upstream validation failed: %w" , reg .name , err )
196+ }
197+ if verbose {
198+ fmt .Printf (" %s upstream format: valid\n " , reg .name )
199+ }
200+
201+ legacyBuilder := internalregistry .NewLegacyBuilder (reg .loader )
202+ if err := legacyBuilder .ValidateAgainstSchema (); err != nil {
203+ return fmt .Errorf ("registry %q: toolhive validation failed: %w" , reg .name , err )
204+ }
205+ if verbose {
206+ fmt .Printf (" %s toolhive format: valid\n " , reg .name )
207+ }
208+
209+ fmt .Printf ("Registry %q: all %d entries valid (both formats)\n " , reg .name , len (reg .loader .GetEntries ()))
210+ }
211+
212+ return nil
213+ }
214+
215+ func determineFormats (f string ) []string {
216+ switch strings .ToLower (f ) {
217+ case formatAll :
218+ return []string {formatToolhive , formatUpstream }
219+ case formatUpstream :
220+ return []string {formatUpstream }
221+ case formatToolhive :
222+ return []string {formatToolhive }
223+ default :
224+ return []string {formatAll }
225+ }
226+ }
227+
228+ func buildFormat (loader * internalregistry.Loader , f string , outDir string ) error {
229+ switch f {
230+ case formatToolhive :
231+ return buildToolhive (loader , outDir )
232+ case formatUpstream :
233+ return buildUpstream (loader , outDir )
234+ default :
235+ return fmt .Errorf ("unknown format: %s" , f )
236+ }
237+ }
238+
239+ func buildToolhive (loader * internalregistry.Loader , outDir string ) error {
240+ builder := internalregistry .NewLegacyBuilder (loader )
241+ outPath := filepath .Join (outDir , "registry.json" )
242+
243+ if err := builder .WriteJSON (outPath ); err != nil {
244+ return fmt .Errorf ("failed to write toolhive registry: %w" , err )
245+ }
246+
247+ if verbose {
248+ fmt .Printf (" wrote %s\n " , outPath )
249+ }
250+ return nil
251+ }
252+
253+ func buildUpstream (loader * internalregistry.Loader , outDir string ) error {
254+ builder := internalregistry .NewBuilder (loader )
255+ outPath := filepath .Join (outDir , "official-registry.json" )
256+
257+ if err := builder .WriteJSON (outPath ); err != nil {
258+ return fmt .Errorf ("failed to write upstream registry: %w" , err )
259+ }
260+
261+ if verbose {
262+ fmt .Printf (" wrote %s\n " , outPath )
263+ }
264+ return nil
265+ }
0 commit comments