diff --git a/cmd/webid/bootstrap_test.go b/cmd/webid/bootstrap_test.go index 02cd789..4c5d6c3 100644 --- a/cmd/webid/bootstrap_test.go +++ b/cmd/webid/bootstrap_test.go @@ -75,6 +75,33 @@ func TestInstallerFull(t *testing.T) { t.Logf("installer size: %d bytes", len(body)) } +// TestInstallerPowerShell verifies /api/installers/{pkg}.ps1 returns a PowerShell installer. +func TestInstallerPowerShell(t *testing.T) { + srv, ts := newTestServer(t) + + pkg := "node" + if srv.getPackage(pkg) == nil { + t.Skipf("package %s not in cache", pkg) + } + + code, body := getWithUA(t, ts, "/api/installers/node@stable.ps1", "AMD64/unknown Windows/10.0.19045 msvc") + if code != 200 { + t.Fatalf("status %d: %s", code, body[:min(len(body), 500)]) + } + + if !strings.Contains(body, "$Env:WEBI_VERSION") { + t.Error("missing $Env:WEBI_VERSION in PS1 installer") + } + if !strings.Contains(body, "$Env:WEBI_PKG_URL") { + t.Error("missing $Env:WEBI_PKG_URL in PS1 installer") + } + if !strings.Contains(body, "$Env:PKG_NAME") { + t.Error("missing $Env:PKG_NAME in PS1 installer") + } + + t.Logf("PS1 installer size: %d bytes", len(body)) +} + // TestInstallerSelfHosted verifies selfhosted packages get a script without resolution. func TestInstallerSelfHosted(t *testing.T) { _, ts := newTestServer(t) diff --git a/cmd/webid/main.go b/cmd/webid/main.go index 943ddc1..ab0b293 100644 --- a/cmd/webid/main.go +++ b/cmd/webid/main.go @@ -668,12 +668,6 @@ func (s *server) handleInstaller(w http.ResponseWriter, r *http.Request) { http.Error(w, "package name required", http.StatusBadRequest) return } - if ext == "ps1" { - // TODO: PowerShell installer rendering. - http.Error(w, "PowerShell installer not yet implemented", http.StatusNotImplemented) - return - } - // Detect platform from User-Agent. ua := uadetect.FromRequest(r) if ua.OS == "" { @@ -763,11 +757,18 @@ func (s *server) handleInstaller(w http.ResponseWriter, r *http.Request) { 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) - 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) + var script string + var renderErr error + if ext == "ps1" { + tplPath := filepath.Join(s.installersDir, "_webi", "package-install.tpl.ps1") + script, renderErr = render.PowerShell(tplPath, s.installersDir, pkg, p) + } else { + tplPath := filepath.Join(s.installersDir, "_webi", "package-install.tpl.sh") + script, renderErr = render.Bash(tplPath, s.installersDir, pkg, p) + } + if renderErr != nil { + log.Printf("render %s: %v", pkg, renderErr) + http.Error(w, fmt.Sprintf("failed to render installer for %q: %v", pkg, renderErr), http.StatusInternalServerError) return } diff --git a/internal/render/render.go b/internal/render/render.go index 17e6937..e0dadf2 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -133,6 +133,74 @@ func Bash(tplPath, installersDir, pkgName string, p Params) (string, error) { 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) + } + + text = strings.Replace(text, "# {{ installer }}", string(installPs1), 1) + text = strings.Replace(text, "{{ installer }}", string(installPs1), 1) + + 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: //