feat(scripts): add generator for template builder module catalog (#26193)

> [!NOTE]
> This PR was authored by Coder Agents on behalf of @jeremyruppel.

Adds `scripts/templatebuildermodulegen/`, a Go tool that fetches module metadata from the Coder registry HTTP API and generates the `module.json` manifests and `.tf.tmpl` files used by the template builder catalog.

The generator calls `GET /api/modules/{id}` for per-module metadata (display name, description, icon, tags, variables) and the Terraform protocol versions endpoint for semver resolution. No git clone or HCL parsing required.

Split into four files:
- `main.go`: orchestration, module config map, CLI flags
- `types.go`: output types (`ModuleManifest`, `ModuleVariable`) and API response types
- `fetch.go`: HTTP fetching, version resolution, variable conversion, icon normalization
- `write.go`: JSON writer, `.tf.tmpl` Go template and writer
This commit is contained in:
Jeremy Ruppel
2026-06-15 09:25:56 -04:00
committed by GitHub
parent 28e83471b3
commit 809bd613e3
4 changed files with 456 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"golang.org/x/mod/semver"
"golang.org/x/xerrors"
)
const registryBaseURL = "https://registry.coder.com"
// simpleTypes are the Terraform types the builder UI can represent.
var simpleTypes = map[string]bool{
"string": true,
"number": true,
"bool": true,
}
// skipVarNames are variables always excluded from the catalog. These are
// UI-ordering or internal plumbing concerns, not admin-facing config.
var skipVarNames = map[string]bool{
"order": true,
"coder_app_order": true,
"coder_parameter_order": true,
"group": true,
"slug": true,
"display_name": true,
"log_path": true,
"install_prefix": true,
"share": true,
"subdomain": true,
}
// fetchModule retrieves a single module from the registry per-module endpoint.
// The id should be "namespace/slug" (e.g. "coder/code-server"); it will be
// URL-encoded for the request path.
func fetchModule(ctx context.Context, baseURL, id string) (registryModule, error) {
reqURL := baseURL + "/api/modules/" + url.PathEscape(id)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return registryModule{}, xerrors.Errorf("creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return registryModule{}, xerrors.Errorf("GET %s: %w", reqURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return registryModule{}, xerrors.Errorf("GET %s: status %d", reqURL, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return registryModule{}, xerrors.Errorf("reading response: %w", err)
}
var mod registryModule
if err := json.Unmarshal(body, &mod); err != nil {
return registryModule{}, xerrors.Errorf("decoding response: %w", err)
}
return mod, nil
}
// fetchLatestVersion resolves the latest semver for a module using the
// Terraform protocol versions endpoint.
func fetchLatestVersion(ctx context.Context, baseURL, namespace, slug string) (string, error) {
reqURL := fmt.Sprintf("%s/terraform_protocol/%s/%s/coder/versions", baseURL, namespace, slug)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return "", xerrors.Errorf("creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", xerrors.Errorf("GET %s: %w", reqURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", xerrors.Errorf("GET %s: status %d", reqURL, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", xerrors.Errorf("reading response: %w", err)
}
var versionsResp terraformVersionsResponse
if err := json.Unmarshal(body, &versionsResp); err != nil {
return "", xerrors.Errorf("decoding response: %w", err)
}
if len(versionsResp.Modules) == 0 || len(versionsResp.Modules[0].Versions) == 0 {
return "", xerrors.Errorf("no versions found for %s/%s", namespace, slug)
}
return latestVersion(versionsResp.Modules[0].Versions)
}
// latestVersion finds the highest semver from a list of version entries.
// The API returns versions without a "v" prefix, so we canonicalize them
// for comparison and strip the prefix before returning.
func latestVersion(entries []struct {
Version string `json:"version"`
},
) (string, error) {
var best string
for _, e := range entries {
v := e.Version
// The semver package requires a "v" prefix, but the registry
// API returns bare versions like "1.5.0".
if !strings.HasPrefix(v, "v") {
v = "v" + v
}
if !semver.IsValid(v) {
continue
}
if best == "" || semver.Compare(v, best) > 0 {
best = v
}
}
if best == "" {
return "", xerrors.New("no valid semver tags found")
}
return strings.TrimPrefix(best, "v"), nil
}
// convertVariables filters and converts registry API variables to the
// catalog schema. It skips internal variables, non-simple types, and
// marks agent_id as computed.
func convertVariables(vars []registryVariable, extraSkip []string) []ModuleVariable {
skipSet := make(map[string]bool, len(skipVarNames)+len(extraSkip))
for k := range skipVarNames {
skipSet[k] = true
}
for _, s := range extraSkip {
skipSet[s] = true
}
var result []ModuleVariable
for _, v := range vars {
if skipSet[v.Name] {
continue
}
if !simpleTypes[v.Type] {
continue
}
computed := v.Name == "agent_id"
required := v.Required && !computed
mv := ModuleVariable{
Name: v.Name,
Type: v.Type,
Description: v.Description,
Required: required,
Sensitive: v.Sensitive,
Computed: computed,
}
if v.Default != nil {
raw, err := json.Marshal(v.Default)
if err == nil {
mv.Default = raw
}
}
result = append(result, mv)
}
return result
}
// normalizeIcon converts registry icon paths to web-servable paths.
// The API returns paths like "/module/code.svg"; we serve them as
// "/icon/code.svg" in the Coder dashboard.
func normalizeIcon(icon string) string {
if strings.HasPrefix(icon, "/module/") {
return "/icon/" + strings.TrimPrefix(icon, "/module/")
}
return icon
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"context"
"flag"
"log"
"os"
"path/filepath"
"sort"
)
// ModuleConfig defines the builder catalog metadata that cannot be
// inferred from the registry (category, OS compatibility, conflicts).
type ModuleConfig struct {
Category string `json:"category"`
CompatibleOS []string `json:"compatible_os"`
ConflictsWith []string `json:"conflicts_with"`
SkipVars []string `json:"skip_vars,omitempty"`
}
// moduleConfigs defines the builder-specific metadata for each module.
var moduleConfigs = map[string]ModuleConfig{
"code-server": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{"vscode-web"}},
"jetbrains": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"vscode-desktop": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"vscode-web": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{"code-server"}},
"cursor": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"windsurf": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"zed": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"kiro": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"claude-code": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"aider": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"goose": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"amazon-q": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"git-clone": {Category: "Source Control", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"git-config": {Category: "Source Control", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"git-commit-signing": {Category: "Source Control", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"dotfiles": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"personalize": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"filebrowser": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"jupyterlab": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
}
func main() {
outputPath := flag.String("output", "", "Output directory for generated module files (required)")
baseURL := flag.String("registry-url", registryBaseURL, "Base URL of the Coder registry API")
flag.Parse()
if *outputPath == "" {
flag.Usage()
os.Exit(1)
}
ctx := context.Background()
moduleIDs := sortedKeys(moduleConfigs)
var failures int
for _, id := range moduleIDs {
cfg := moduleConfigs[id]
registryID := "coder/" + id
log.Printf("Generating %s...", id)
regMod, err := fetchModule(ctx, *baseURL, registryID)
if err != nil {
log.Printf(" ERROR fetching module: %v", err)
failures++
continue
}
version, err := fetchLatestVersion(ctx, *baseURL, "coder", id)
if err != nil {
log.Printf(" WARNING: could not determine version: %v", err)
version = "0.0.0"
}
vars := convertVariables(regMod.Variables, cfg.SkipVars)
manifest := ModuleManifest{
ID: id,
DisplayName: regMod.DisplayName,
Description: regMod.Description,
Icon: normalizeIcon(regMod.IconURL),
Category: cfg.Category,
Tags: regMod.Tags,
CompatibleOS: cfg.CompatibleOS,
ConflictsWith: cfg.ConflictsWith,
PinnedVersion: version,
Variables: vars,
}
outDir := filepath.Join(*outputPath, id)
if err := os.MkdirAll(outDir, 0o755); err != nil {
log.Printf(" ERROR creating directory: %v", err)
failures++
continue
}
if err := writeModuleJSON(filepath.Join(outDir, "module.json"), manifest); err != nil {
log.Printf(" ERROR writing module.json: %v", err)
failures++
continue
}
if err := writeTFTmpl(filepath.Join(outDir, id+".tf.tmpl"), manifest); err != nil {
log.Printf(" ERROR writing .tf.tmpl: %v", err)
failures++
continue
}
log.Printf(" OK: %d variables, version %s", len(vars), version)
}
if failures > 0 {
log.Fatalf("Failed to generate %d module(s)", failures)
}
}
func sortedKeys(m map[string]ModuleConfig) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+61
View File
@@ -0,0 +1,61 @@
package main
import "encoding/json"
// ModuleManifest is the on-disk module.json schema.
type ModuleManifest struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Icon string `json:"icon"`
Category string `json:"category"`
Tags []string `json:"tags"`
CompatibleOS []string `json:"compatible_os"`
ConflictsWith []string `json:"conflicts_with"`
PinnedVersion string `json:"pinned_version"`
Variables []ModuleVariable `json:"variables"`
}
// ModuleVariable is a variable declaration within a module manifest.
type ModuleVariable struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Default json.RawMessage `json:"default,omitempty"`
Required bool `json:"required"`
Sensitive bool `json:"sensitive"`
Computed bool `json:"computed"`
}
// registryModule is the JSON shape returned by GET /api/modules/{id}.
type registryModule struct {
ID string `json:"id"`
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
IconURL string `json:"iconUrl"`
Tags []string `json:"tags"`
Variables []registryVariable `json:"variables"`
Namespace string `json:"contributorNamespace"`
}
// registryVariable is a variable as returned by the registry API.
// The Default field is a raw JSON value because the API returns typed
// defaults (string, bool, number, null, array, object).
type registryVariable struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Default interface{} `json:"default"`
Required bool `json:"required"`
Sensitive bool `json:"sensitive"`
}
// terraformVersionsResponse wraps the Terraform protocol versions endpoint.
type terraformVersionsResponse struct {
Modules []struct {
Versions []struct {
Version string `json:"version"`
} `json:"versions"`
} `json:"modules"`
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"encoding/json"
"os"
"text/template"
)
func writeModuleJSON(path string, m ModuleManifest) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0o600)
}
var tfTmplTemplate = template.Must(template.New("tf.tmpl").Funcs(template.FuncMap{
"hclValue": func(v ModuleVariable) string {
if v.Sensitive {
return "var." + v.Name
}
return "{{ .Variables." + v.Name + " }}"
},
}).Parse(`{{- range .SensitiveVars }}
variable "{{ .Name }}" {
description = "{{ .Description }}"
type = {{ .Type }}
sensitive = true
}
{{ end -}}
module "{{ .ID }}" {
count = data.coder_workspace.me.start_count
source = "{{"{{"}} .RegistryBase {{"}}"}}/coder/{{ .ID }}/coder"
version = "{{"{{"}} .PinnedVersion {{"}}"}}"
agent_id = coder_agent.{{"{{"}} .AgentResourceName {{"}}"}}.id
{{- range .NonComputedVars }}
{{- if .Sensitive }}
{{ .Name }} = var.{{ .Name }}
{{- else }}
{{ .Name }} = {{"{{"}} .Variables.{{ .Name }} {{"}}"}}
{{- end }}
{{- end }}
}
`))
type tfTmplData struct {
ID string
SensitiveVars []ModuleVariable
NonComputedVars []ModuleVariable
}
func writeTFTmpl(path string, m ModuleManifest) error {
var sensitiveVars []ModuleVariable
var nonComputedVars []ModuleVariable
for _, v := range m.Variables {
if v.Computed {
continue
}
nonComputedVars = append(nonComputedVars, v)
if v.Sensitive {
sensitiveVars = append(sensitiveVars, v)
}
}
data := tfTmplData{
ID: m.ID,
SensitiveVars: sensitiveVars,
NonComputedVars: nonComputedVars,
}
f, err := os.Create(path)
if err != nil {
return err
}
err = tfTmplTemplate.Execute(f, data)
if closeErr := f.Close(); err == nil {
err = closeErr
}
return err
}