From 89e12d22f38fb0b646d6705e812e44908a358308 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 16 May 2026 20:27:40 -0600 Subject: [PATCH] feat(render): add installer script renderer for webid --- internal/render/render.go | 264 +++++++++++++++++++++++++++++++++ internal/render/render_test.go | 90 +++++++++++ 2 files changed, 354 insertions(+) create mode 100644 internal/render/render.go create mode 100644 internal/render/render_test.go diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..7a1701b --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,264 @@ +// 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. + // The marker sits inside __init_installer() at 8-space indent. + // Production pads every line of install.sh to match, and replaces + // the entire line (including leading whitespace). + padded := padScript(string(installSh), " ") + text = replaceMarkerLine(text, "{{ installer }}", padded) + + return text, nil +} + +// PowerShell renders a complete PowerShell installer script by injecting +// params into the template and splicing in the package's install.ps1. +func PowerShell(tplPath, installersDir, pkgName string, p Params) (string, error) { + tpl, err := os.ReadFile(tplPath) + if err != nil { + return "", fmt.Errorf("render: read template: %w", err) + } + + installPath := filepath.Join(installersDir, pkgName, "install.ps1") + installPs1, err := os.ReadFile(installPath) + if err != nil { + return "", fmt.Errorf("render: read %s/install.ps1: %w", pkgName, err) + } + + text := string(tpl) + + vars := []struct { + name string + value string + }{ + {"WEBI_PKG", p.PkgName + "@" + p.Tag}, + {"WEBI_HOST", p.Host}, + {"WEBI_VERSION", p.Version}, + {"WEBI_GIT_TAG", p.GitTag}, + {"WEBI_PKG_URL", p.PkgURL}, + {"WEBI_PKG_FILE", p.PkgFile}, + {"WEBI_PKG_PATHNAME", p.PkgFile}, + {"PKG_NAME", p.PkgName}, + } + + for _, v := range vars { + text = InjectPSVar(text, v.name, v.value) + } + + // PS1 marker is at column 0, no padding needed. + text = replaceMarkerLine(text, "{{ installer }}", string(installPs1)) + + return text, nil +} + +// InjectPSVar replaces a PowerShell template variable line with its value. +// Matches lines like: +// +// #$Env:WEBI_VERSION = v12.16.2 +// $Env:WEBI_HOST = 'https://webinstall.dev' +func InjectPSVar(text, name, value string) string { + p := getPSVarPattern(name) + return p.ReplaceAllString(text, "${1}$$Env:"+name+" = '"+sanitizePSValue(value)+"'") +} + +var psVarPatterns = map[string]*regexp.Regexp{} + +func getPSVarPattern(name string) *regexp.Regexp { + if p, ok := psVarPatterns[name]; ok { + return p + } + // Match: optional leading whitespace, optional #, $Env:NAME, =, rest of line + p := regexp.MustCompile(`(?m)^([ \t]*)#?\$Env:` + regexp.QuoteMeta(name) + `\s*=.*$`) + psVarPatterns[name] = p + return p +} + +// sanitizePSValue escapes single quotes for PowerShell single-quoted strings. +// In PowerShell, single quotes inside single-quoted strings are doubled: '' +func sanitizePSValue(s string) string { + return strings.ReplaceAll(s, "'", "''") +} + +// 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. +// It matches lines like: +// +// #WEBI_VERSION= +// #export WEBI_PKG_URL= +// export WEBI_HOST= +// +// and replaces them with the value in single quotes. +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, "'", `'\''`) +} + +// padScript prepends each line of a script with the given indent string. +// This matches production behavior where install.sh content is indented +// to align with the surrounding template code. +func padScript(script, indent string) string { + lines := strings.Split(script, "\n") + for i, line := range lines { + if line != "" { + lines[i] = indent + line + } + } + return strings.Join(lines, "\n") +} + +// replaceMarkerLine replaces an entire line containing the marker +// (including any leading whitespace) with the replacement text. +// This matches production's regex: /\s*#?\s*{{ installer }}/ +func replaceMarkerLine(text, marker, replacement string) string { + re := regexp.MustCompile(`(?m)^[ \t]*#?[ \t]*` + regexp.QuoteMeta(marker) + `[^\n]*`) + return re.ReplaceAllLiteralString(text, replacement) +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..0c058b8 --- /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") + } +}