mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-05-29 20:13:02 +00:00
feat(render): add installer script renderer for webid
This commit is contained in:
264
internal/render/render.go
Normal file
264
internal/render/render.go
Normal file
@@ -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)
|
||||
}
|
||||
90
internal/render/render_test.go
Normal file
90
internal/render/render_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user