diff --git a/cmd/webid/main.go b/cmd/webid/main.go index 606abe9..4f3b278 100644 --- a/cmd/webid/main.go +++ b/cmd/webid/main.go @@ -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 +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..ea65a9b --- /dev/null +++ b/internal/render/render.go @@ -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, "'", `'\''`) +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..29f51df --- /dev/null +++ b/internal/render/render_test.go @@ -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") + } +}