feat(render): add installer script renderer and bootstrap route

Renders package-install.tpl.sh with WEBI_* variable injection and
install.sh splicing. Bootstrap route at /{package}@{version} detects
UA, resolves best release, and returns rendered installer script.
This commit is contained in:
AJ ONeal
2026-03-11 02:03:58 -06:00
parent 9d3d28704e
commit a24d361289
3 changed files with 426 additions and 0 deletions

View File

@@ -26,7 +26,9 @@ import (
"time"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/render"
"github.com/webinstall/webi-installers/internal/resolve"
"github.com/webinstall/webi-installers/internal/resolver"
"github.com/webinstall/webi-installers/internal/storage"
"github.com/webinstall/webi-installers/internal/storage/fsstore"
"github.com/webinstall/webi-installers/internal/uadetect"
@@ -66,6 +68,10 @@ func main() {
fmt.Fprintln(w, "ok")
})
// Bootstrap route: /{package} and /{package}@{version}
// Detects UA and returns rendered installer script.
mux.HandleFunc("GET /{pkgSpec}", srv.handleBootstrap)
httpSrv := &http.Server{
Addr: *addr,
Handler: mux,
@@ -509,3 +515,168 @@ func (s *server) handleDebug(w http.ResponseWriter, r *http.Request) {
"libc": string(result.Libc),
})
}
// handleBootstrap serves /{package} and /{package}@{version}.
// Detects the client's OS/arch from User-Agent, resolves the best
// release, and renders the installer script.
func (s *server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
pkgSpec := r.PathValue("pkgSpec")
// Parse package@version.
pkg, tag := pkgSpec, ""
if idx := strings.IndexByte(pkgSpec, '@'); idx >= 0 {
pkg = pkgSpec[:idx]
tag = pkgSpec[idx+1:]
}
if pkg == "" {
http.Error(w, "package name required", http.StatusBadRequest)
return
}
// Detect platform from User-Agent.
ua := uadetect.FromRequest(r)
if ua.OS == "" {
http.Error(w, "could not detect OS from User-Agent", http.StatusBadRequest)
return
}
// Check for selfhosted package first.
isSelfHosted := s.isSelfHosted(pkg)
pc := s.getPackage(pkg)
if pc == nil && !isSelfHosted {
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
return
}
// Build render params.
baseURL := "https://" + r.Host
if r.TLS == nil && !strings.Contains(r.Host, "webinstall") {
baseURL = "http://" + r.Host
}
p := render.Params{
Host: baseURL,
PkgName: pkg,
Tag: tag,
OS: string(ua.OS),
Arch: string(ua.Arch),
Libc: string(ua.Libc),
}
// Resolve the best release (if not selfhosted).
if pc != nil {
req := resolver.Request{
OS: string(ua.OS),
Arch: string(ua.Arch),
Libc: string(ua.Libc),
}
// Handle channel selectors in tag.
switch strings.ToLower(tag) {
case "stable", "latest", "":
// Default.
case "lts":
req.LTS = true
case "beta", "pre", "preview":
req.Channel = "beta"
case "rc":
req.Channel = "rc"
case "alpha", "dev":
req.Channel = "alpha"
default:
req.Version = tag
}
res, err := resolver.Resolve(pc.assets, req)
if err != nil {
// Build error CSV like Node.js does.
p.Version = "0.0.0"
p.Channel = "error"
p.Ext = "err"
p.PkgURL = "https://example.com/doesntexist.ext"
p.PkgFile = "doesntexist.ext"
p.CSV = buildCSV(p)
} else {
v := strings.TrimPrefix(res.Version, "v")
parts := splitVersion(v)
p.Version = v
p.Major = parts[0]
p.Minor = parts[1]
p.Patch = parts[2]
p.Build = parts[3]
p.GitTag = "v" + v
p.GitBranch = "v" + v
p.LTS = fmt.Sprintf("%v", res.Asset.LTS)
p.Channel = res.Asset.Channel
if p.Channel == "" {
p.Channel = "stable"
}
p.Ext = strings.TrimPrefix(res.Asset.Format, ".")
p.PkgURL = res.Asset.Download
p.PkgFile = res.Asset.Filename
p.CSV = buildCSV(p)
}
// Add catalog info.
p.PkgStable = pc.catalog.Stable
p.PkgLatest = pc.catalog.Latest
p.PkgOSes = strings.Join(pc.catalog.OSes, " ")
p.PkgArches = strings.Join(pc.catalog.Arches, " ")
p.PkgLibcs = strings.Join(pc.catalog.Libcs, " ")
p.PkgFormats = strings.Join(pc.catalog.Formats, " ")
}
// Build releases URL.
p.ReleasesURL = fmt.Sprintf("%s/api/releases/%s@%s.tab?os=%s&arch=%s&libc=%s&formats=tar&pretty=true",
baseURL, pkg, tag, p.OS, p.Arch, p.Libc)
// Render the installer script.
tplPath := filepath.Join(s.installersDir, "_webi", "package-install.tpl.sh")
script, err := render.Bash(tplPath, s.installersDir, pkg, p)
if err != nil {
log.Printf("render %s: %v", pkg, err)
http.Error(w, fmt.Sprintf("failed to render installer for %q: %v", pkg, err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, script)
}
// buildCSV creates the WEBI_CSV line in the Node.js format.
func buildCSV(p render.Params) string {
return strings.Join([]string{
p.Version,
p.LTS,
p.Channel,
"", // date
p.OS,
p.Arch,
p.Ext,
"-",
p.PkgURL,
p.PkgFile,
"",
}, ",")
}
// splitVersion splits a version string into [major, minor, patch, build].
func splitVersion(v string) [4]string {
// Strip pre-release suffix for splitting.
base := v
build := ""
if idx := strings.IndexByte(v, '-'); idx >= 0 {
base = v[:idx]
build = v[idx+1:]
}
parts := strings.SplitN(base, ".", 4)
var result [4]string
for i := 0; i < len(parts) && i < 3; i++ {
result[i] = parts[i]
}
result[3] = build
return result
}

165
internal/render/render.go Normal file
View File

@@ -0,0 +1,165 @@
// Package render generates installer scripts by injecting release
// metadata into the package-install template.
//
// The template uses shell-style variable markers:
//
// #WEBI_VERSION= → WEBI_VERSION='1.2.3'
// #export WEBI_PKG_URL= → export WEBI_PKG_URL='https://...'
//
// The package's install.sh is injected at the {{ installer }} marker.
package render
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// Params holds all the values to inject into the installer template.
type Params struct {
// Host is the base URL of the webi server (e.g. "https://webinstall.dev").
Host string
// Checksum is the webi.sh bootstrap script checksum (first 8 hex chars of SHA-1).
Checksum string
// Package name (e.g. "bat", "node").
PkgName string
// Tag is the version selector from the URL (e.g. "20", "stable", "").
Tag string
// OS, Arch, Libc are the detected platform strings.
OS string
Arch string
Libc string
// Resolved release info.
Version string
Major string
Minor string
Patch string
Build string
GitTag string
GitBranch string
LTS string // "true" or "false"
Channel string
Ext string // archive extension (e.g. "tar.gz", "zip")
Formats string // comma-separated format list
// Download info.
PkgURL string // download URL
PkgFile string // filename
// Releases API URL for this request.
ReleasesURL string
// CSV line for WEBI_CSV.
CSV string
// Package catalog info.
PkgStable string
PkgLatest string
PkgOSes string // space-separated
PkgArches string // space-separated
PkgLibcs string // space-separated
PkgFormats string // space-separated
}
// Bash renders a complete bash installer script by injecting params
// into the template and splicing in the package's install.sh.
func Bash(tplPath, installersDir, pkgName string, p Params) (string, error) {
tpl, err := os.ReadFile(tplPath)
if err != nil {
return "", fmt.Errorf("render: read template: %w", err)
}
// Read the package's install.sh.
installPath := filepath.Join(installersDir, pkgName, "install.sh")
installSh, err := os.ReadFile(installPath)
if err != nil {
return "", fmt.Errorf("render: read %s/install.sh: %w", pkgName, err)
}
text := string(tpl)
// Inject environment variables.
vars := []struct {
name string
value string
}{
{"WEBI_CHECKSUM", p.Checksum},
{"WEBI_PKG", p.PkgName + "@" + p.Tag},
{"WEBI_HOST", p.Host},
{"WEBI_OS", p.OS},
{"WEBI_ARCH", p.Arch},
{"WEBI_LIBC", p.Libc},
{"WEBI_TAG", p.Tag},
{"WEBI_RELEASES", p.ReleasesURL},
{"WEBI_CSV", p.CSV},
{"WEBI_VERSION", p.Version},
{"WEBI_MAJOR", p.Major},
{"WEBI_MINOR", p.Minor},
{"WEBI_PATCH", p.Patch},
{"WEBI_BUILD", p.Build},
{"WEBI_GIT_BRANCH", p.GitBranch},
{"WEBI_GIT_TAG", p.GitTag},
{"WEBI_LTS", p.LTS},
{"WEBI_CHANNEL", p.Channel},
{"WEBI_EXT", p.Ext},
{"WEBI_FORMATS", p.Formats},
{"WEBI_PKG_URL", p.PkgURL},
{"WEBI_PKG_PATHNAME", p.PkgFile},
{"WEBI_PKG_FILE", p.PkgFile},
{"PKG_NAME", p.PkgName},
{"PKG_STABLE", p.PkgStable},
{"PKG_LATEST", p.PkgLatest},
{"PKG_OSES", p.PkgOSes},
{"PKG_ARCHES", p.PkgArches},
{"PKG_LIBCS", p.PkgLibcs},
{"PKG_FORMATS", p.PkgFormats},
}
for _, v := range vars {
text = injectVar(text, v.name, v.value)
}
// Inject the installer script at the {{ installer }} marker.
text = strings.Replace(text, "# {{ installer }}", string(installSh), 1)
text = strings.Replace(text, "{{ installer }}", string(installSh), 1)
return text, nil
}
// varPattern matches shell variable declarations in the template.
// Matches lines like:
//
// #WEBI_VERSION=
// #export WEBI_PKG_URL=
// #WEBI_OS=
var varPatterns = map[string]*regexp.Regexp{}
func getVarPattern(name string) *regexp.Regexp {
if p, ok := varPatterns[name]; ok {
return p
}
// Match: optional leading whitespace, optional #, optional export, the var name, =, rest of line
p := regexp.MustCompile(`(?m)^([ \t]*)#?([ \t]*)(export[ \t]+)?[ \t]*(` + regexp.QuoteMeta(name) + `)=.*$`)
varPatterns[name] = p
return p
}
// injectVar replaces a template variable line with its value.
func injectVar(text, name, value string) string {
p := getVarPattern(name)
return p.ReplaceAllString(text, "${1}${3}"+name+"='"+sanitizeShellValue(value)+"'")
}
// sanitizeShellValue ensures a value is safe to embed in single quotes.
// Single quotes in shell can't be escaped inside single quotes, so we
// close-quote, add escaped quote, re-open quote: 'foo'\''bar'
func sanitizeShellValue(s string) string {
return strings.ReplaceAll(s, "'", `'\''`)
}

View File

@@ -0,0 +1,90 @@
package render
import (
"strings"
"testing"
)
func TestInjectVar(t *testing.T) {
tests := []struct {
name string
input string
key string
value string
want string
}{
{
name: "commented var",
input: " #WEBI_VERSION=",
key: "WEBI_VERSION",
value: "1.2.3",
want: " WEBI_VERSION='1.2.3'",
},
{
name: "commented export var",
input: " #export WEBI_PKG_URL=",
key: "WEBI_PKG_URL",
value: "https://example.com/foo.tar.gz",
want: " export WEBI_PKG_URL='https://example.com/foo.tar.gz'",
},
{
name: "existing value replaced",
input: " export WEBI_HOST=",
key: "WEBI_HOST",
value: "https://webinstall.dev",
want: " export WEBI_HOST='https://webinstall.dev'",
},
{
name: "value with single quotes",
input: " #PKG_NAME=",
key: "PKG_NAME",
value: "it's-a-test",
want: " PKG_NAME='it'\\''s-a-test'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := injectVar(tt.input, tt.key, tt.value)
if strings.TrimSpace(got) != strings.TrimSpace(tt.want) {
t.Errorf("got %q\nwant %q", got, tt.want)
}
})
}
}
func TestInjectVarInTemplate(t *testing.T) {
tpl := `#!/bin/sh
__bootstrap_webi() {
#PKG_NAME=
#WEBI_OS=
#WEBI_ARCH=
#WEBI_VERSION=
export WEBI_HOST=
WEBI_PKG_DOWNLOAD=""
`
result := tpl
result = injectVar(result, "PKG_NAME", "bat")
result = injectVar(result, "WEBI_OS", "linux")
result = injectVar(result, "WEBI_ARCH", "x86_64")
result = injectVar(result, "WEBI_VERSION", "0.26.1")
result = injectVar(result, "WEBI_HOST", "https://webinstall.dev")
if !strings.Contains(result, "PKG_NAME='bat'") {
t.Error("PKG_NAME not injected")
}
if !strings.Contains(result, "WEBI_OS='linux'") {
t.Error("WEBI_OS not injected")
}
if !strings.Contains(result, "WEBI_VERSION='0.26.1'") {
t.Error("WEBI_VERSION not injected")
}
if !strings.Contains(result, "export WEBI_HOST='https://webinstall.dev'") {
t.Error("WEBI_HOST not injected")
}
// Should not have #PKG_NAME= anymore.
if strings.Contains(result, "#PKG_NAME=") {
t.Error("#PKG_NAME= should have been replaced")
}
}