Files
vim-ale/cmd/webid/main.go
AJ ONeal 031a15b0ea feat(webid): wire golib middleware for request logging
Uses therootcompany/golib/http/middleware/v2 to add requestLogger to all
routes except /api/health (too noisy). Logs method, path, status, duration.
2026-03-11 14:31:37 -06:00

879 lines
22 KiB
Go

// Command webid is the webi HTTP API server. It reads cached release
// data from the filesystem and serves release metadata, installer
// scripts, and bootstrap dispatches.
//
// It never fetches from upstream APIs — that's webicached's job.
// This server is stateless and fast: load from cache, resolve, render.
//
// Usage:
//
// go run ./cmd/webid
// go run ./cmd/webid -addr :3001 -cache ./_cache
package main
import (
"context"
"crypto/sha1"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/lexver"
"github.com/webinstall/webi-installers/internal/render"
"github.com/webinstall/webi-installers/internal/resolve"
"github.com/webinstall/webi-installers/internal/resolver"
middleware "github.com/therootcompany/golib/http/middleware/v2"
"github.com/webinstall/webi-installers/internal/storage"
"github.com/webinstall/webi-installers/internal/storage/fsstore"
"github.com/webinstall/webi-installers/internal/storage/pgstore"
"github.com/webinstall/webi-installers/internal/uadetect"
)
func main() {
addr := flag.String("addr", ":3001", "listen address")
cacheDir := flag.String("cache", "./_cache", "cache directory root")
pgDSN := flag.String("pg", "", "PostgreSQL DSN (enables pgstore; mutually exclusive with -cache)")
installersDir := flag.String("installers", ".", "installers repo root (for install.sh/ps1)")
flag.Parse()
var store storage.Store
if *pgDSN != "" {
pg, err := pgstore.New(context.Background(), *pgDSN)
if err != nil {
log.Fatalf("pgstore: %v", err)
}
store = pg
} else {
fs, err := fsstore.New(*cacheDir)
if err != nil {
log.Fatalf("fsstore: %v", err)
}
store = fs
}
srv := &server{
store: store,
installersDir: *installersDir,
packages: make(map[string]*packageCache),
}
// Pre-load all cached packages.
srv.loadAll()
mux := http.NewServeMux()
mmux := middleware.WithMux(mux, requestLogger)
// Legacy API routes (Node.js compat).
mmux.HandleFunc("GET /api/releases/{rest...}", srv.handleReleasesAPI)
// New API routes (v1).
mmux.HandleFunc("GET /v1/releases/{rest...}", srv.handleV1Releases)
mmux.HandleFunc("GET /v1/resolve/{rest...}", srv.handleV1Resolve)
// Full installer script (package-install.tpl.sh + install.sh).
mmux.HandleFunc("GET /api/installers/{rest...}", srv.handleInstaller)
// Debug endpoint.
mmux.HandleFunc("GET /api/debug", srv.handleDebug)
// Health check (no logging — too noisy).
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
// Bootstrap route: /{package} and /{package}@{version}
// Detects UA and returns rendered installer script.
mmux.HandleFunc("GET /{pkgSpec}", srv.handleBootstrap)
httpSrv := &http.Server{
Addr: *addr,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() {
log.Printf("webid listening on %s", *addr)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
log.Println("shutting down...")
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
httpSrv.Shutdown(shutCtx)
}
// requestLogger is a middleware that logs each request with status and duration.
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &statusWriter{ResponseWriter: w, code: http.StatusOK}
next.ServeHTTP(rw, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rw.code, time.Since(start))
})
}
// statusWriter wraps ResponseWriter to capture the HTTP status code.
type statusWriter struct {
http.ResponseWriter
code int
}
func (sw *statusWriter) WriteHeader(code int) {
sw.code = code
sw.ResponseWriter.WriteHeader(code)
}
// server holds the shared state for all HTTP handlers.
type server struct {
store storage.Store
installersDir string
mu sync.RWMutex
packages map[string]*packageCache
webiCksum string // cached sha1[:8] of webi.sh
}
// packageCache holds a loaded package's assets and catalog.
type packageCache struct {
assets []storage.Asset
dists []resolve.Dist
catalog resolve.Catalog
}
// loadAll pre-loads all packages from the store.
func (s *server) loadAll() {
ctx := context.Background()
pkgs, err := s.store.ListPackages(ctx)
if err != nil {
log.Printf("warn: list packages: %v", err)
return
}
count := 0
for _, pkg := range pkgs {
pd, err := s.store.Load(ctx, pkg)
if err != nil {
log.Printf("warn: load %s: %v", pkg, err)
continue
}
if pd == nil || len(pd.Assets) == 0 {
continue
}
pc := &packageCache{
assets: pd.Assets,
dists: assetsToDists(pd.Assets),
}
pc.catalog = resolve.Survey(pc.dists)
s.mu.Lock()
s.packages[pkg] = pc
s.mu.Unlock()
count++
}
log.Printf("loaded %d packages from store", count)
}
// getPackage returns the cached package data, or nil if not found.
func (s *server) getPackage(pkg string) *packageCache {
s.mu.RLock()
defer s.mu.RUnlock()
return s.packages[pkg]
}
// assetsToDists converts storage.Asset slice to resolve.Dist slice.
func assetsToDists(assets []storage.Asset) []resolve.Dist {
dists := make([]resolve.Dist, len(assets))
for i, a := range assets {
dists[i] = resolve.Dist{
Filename: a.Filename,
Version: a.Version,
LTS: a.LTS,
Channel: a.Channel,
Date: a.Date,
OS: a.OS,
Arch: a.Arch,
Libc: a.Libc,
Format: a.Format,
Download: a.Download,
Extra: a.Extra,
Variants: a.Variants,
}
}
return dists
}
// handleReleasesAPI serves /api/releases/{package}@{version}.{format}
func (s *server) handleReleasesAPI(w http.ResponseWriter, r *http.Request) {
rest := r.PathValue("rest")
// Parse: {package}@{version}.{json|tab} or {package}.{json|tab}
pkg, version, format, err := parseReleasePath(rest)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pc := s.getPackage(pkg)
if pc == nil {
// Check if it's a selfhosted package.
if s.isSelfHosted(pkg) {
s.serveEmptyReleases(w, format)
return
}
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
return
}
// Parse query parameters.
q := r.URL.Query()
osStr := q.Get("os")
archStr := q.Get("arch")
libcStr := q.Get("libc")
ltsStr := q.Get("lts")
channelStr := q.Get("channel")
formatsStr := q.Get("formats")
limitStr := q.Get("limit")
// Normalize wildcard "-" to empty (means "any").
if osStr == "-" {
osStr = ""
}
if archStr == "-" {
archStr = ""
}
if libcStr == "-" {
libcStr = ""
}
// Map Node.js OS/arch names to our canonical names.
osStr = normalizeQueryOS(osStr)
archStr = normalizeQueryArch(archStr)
// Parse LTS.
lts := ltsStr == "true" || ltsStr == "1"
// Handle channel selectors in the version field: @stable, @lts, @beta, etc.
switch strings.ToLower(version) {
case "stable", "latest":
version = ""
if channelStr == "" {
channelStr = "stable"
}
case "lts":
version = ""
lts = true
case "beta", "pre", "preview":
version = ""
if channelStr == "" {
channelStr = "beta"
}
case "rc":
version = ""
if channelStr == "" {
channelStr = "rc"
}
case "alpha", "dev":
version = ""
if channelStr == "" {
channelStr = "alpha"
}
}
// Parse formats list.
var formats []string
if formatsStr != "" {
formats = strings.Split(formatsStr, ",")
}
// Parse limit.
limit := 1000
if limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit)
}
// Filter matching releases.
filtered := filterDists(pc.dists, osStr, archStr, libcStr, channelStr, version, formats, lts, limit)
// Sort newest-first (descending by version).
sortDistsDescending(filtered)
switch format {
case "json":
s.serveJSON(w, r, pc, filtered)
case "tab":
s.serveTab(w, r, filtered)
default:
http.Error(w, "unsupported format: "+format, http.StatusBadRequest)
}
}
// normalizeQueryOS maps Node.js OS names to our canonical names.
func normalizeQueryOS(s string) string {
switch strings.ToLower(s) {
case "macos", "mac":
return "darwin"
case "win":
return "windows"
default:
return s
}
}
// normalizeQueryArch maps Node.js arch names to our canonical names.
func normalizeQueryArch(s string) string {
switch strings.ToLower(s) {
case "amd64":
return string(buildmeta.ArchAMD64) // "x86_64"
case "arm64":
return string(buildmeta.ArchARM64) // "aarch64"
case "armv7l":
return string(buildmeta.ArchARMv7)
case "armv6l":
return string(buildmeta.ArchARMv6)
case "x86", "i386", "i686":
return string(buildmeta.ArchX86)
default:
return s
}
}
// parseReleasePath parses "{pkg}@{version}.{format}" or "{pkg}.{format}".
func parseReleasePath(rest string) (pkg, version, format string, err error) {
if strings.HasSuffix(rest, ".json") {
format = "json"
rest = strings.TrimSuffix(rest, ".json")
} else if strings.HasSuffix(rest, ".tab") {
format = "tab"
rest = strings.TrimSuffix(rest, ".tab")
} else {
return "", "", "", fmt.Errorf("unsupported format (use .json or .tab)")
}
if idx := strings.IndexByte(rest, '@'); idx >= 0 {
pkg = rest[:idx]
version = rest[idx+1:]
} else {
pkg = rest
}
if pkg == "" {
return "", "", "", fmt.Errorf("package name required")
}
return pkg, version, format, nil
}
// filterDists filters dists by query parameters, returning all matches
// up to limit. This is for the API listing, not single-best resolution.
func filterDists(dists []resolve.Dist, osStr, archStr, libcStr, channel, version string, formats []string, lts bool, limit int) []resolve.Dist {
var result []resolve.Dist
for _, d := range dists {
if osStr != "" && d.OS != osStr && d.OS != "*" && d.OS != "ANYOS" {
continue
}
if archStr != "" && d.Arch != archStr && d.Arch != "*" && d.Arch != "ANYARCH" {
continue
}
if libcStr != "" && d.Libc != "none" && d.Libc != "" && d.Libc != libcStr {
continue
}
if lts && !d.LTS {
continue
}
if channel != "" && d.Channel != channel {
continue
}
if version != "" {
// Match with or without "v" prefix:
// query "0.25" should match version "v0.25.0".
v := strings.TrimPrefix(d.Version, "v")
vq := strings.TrimPrefix(version, "v")
if !strings.HasPrefix(v, vq) {
continue
}
}
if len(formats) > 0 {
matched := false
for _, f := range formats {
if strings.Contains(d.Format, f) {
matched = true
break
}
}
if !matched {
continue
}
}
result = append(result, d)
if len(result) >= limit {
break
}
}
return result
}
// legacyRelease matches the Node.js JSON response format.
// Production returns a bare JSON array of these objects.
type legacyRelease struct {
Name string `json:"name"`
Version string `json:"version"`
LTS bool `json:"lts"`
Channel string `json:"channel"`
Date string `json:"date"`
OS string `json:"os"`
Arch string `json:"arch"`
Ext string `json:"ext"`
Download string `json:"download"`
Libc string `json:"libc"`
}
// legacyOS maps Go canonical OS names to Node.js legacy names.
func legacyOS(s string) string {
switch s {
case "darwin":
return "macos"
default:
return s
}
}
// legacyArch maps Go canonical arch names to Node.js legacy names.
func legacyArch(s string) string {
switch s {
case "x86_64":
return "amd64"
case "aarch64":
return "arm64"
default:
return s
}
}
// legacyExt strips the leading "." from format strings.
func legacyExt(s string) string {
return strings.TrimPrefix(s, ".")
}
// legacyVersion strips the leading "v" from version strings.
func legacyVersion(s string) string {
return strings.TrimPrefix(s, "v")
}
// legacyLibc returns "none" for empty libc values.
func legacyLibc(s string) string {
if s == "" {
return "none"
}
return s
}
func distsToLegacy(dists []resolve.Dist) []legacyRelease {
releases := make([]legacyRelease, len(dists))
for i, d := range dists {
releases[i] = legacyRelease{
Name: d.Filename,
Version: legacyVersion(d.Version),
LTS: d.LTS,
Channel: d.Channel,
Date: d.Date,
OS: legacyOS(d.OS),
Arch: legacyArch(d.Arch),
Ext: legacyExt(d.Format),
Download: d.Download,
Libc: legacyLibc(d.Libc),
}
}
return releases
}
func (s *server) serveJSON(w http.ResponseWriter, r *http.Request, pc *packageCache, filtered []resolve.Dist) {
// Production returns a bare JSON array, not wrapped in an object.
releases := distsToLegacy(filtered)
w.Header().Set("Content-Type", "application/json")
pretty := r.URL.Query().Get("pretty")
if pretty == "true" || pretty == "1" {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.Encode(releases)
} else {
json.NewEncoder(w).Encode(releases)
}
}
func (s *server) serveTab(w http.ResponseWriter, r *http.Request, filtered []resolve.Dist) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
// Production only shows header row with ?pretty=true.
pretty := r.URL.Query().Get("pretty")
if pretty != "" && pretty != "false" {
fmt.Fprintln(w, "VERSION\tLTS\tCHANNEL\tRELEASE_DATE\tOS\tARCH\tEXT\tHASH\tURL\t_\tLIBC")
}
// Tab format matches Node.js production:
// version \t lts \t channel \t date \t os \t arch \t ext \t hash \t download \t comment \t libc
for _, d := range filtered {
lts := "-"
if d.LTS {
lts = "lts"
}
channel := d.Channel
if channel == "" {
channel = "-"
}
date := d.Date
if date == "" {
date = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t-\t%s\t\t%s\n",
legacyVersion(d.Version),
lts,
channel,
date,
legacyOS(d.OS),
legacyArch(d.Arch),
legacyExt(d.Format),
d.Download,
legacyLibc(d.Libc),
)
}
}
// sortDistsDescending sorts dists newest-first by version.
func sortDistsDescending(dists []resolve.Dist) {
slices.SortStableFunc(dists, func(a, b resolve.Dist) int {
va := lexver.Parse(strings.TrimPrefix(a.Version, "v"))
vb := lexver.Parse(strings.TrimPrefix(b.Version, "v"))
// Descending: reverse the comparison.
return lexver.Compare(vb, va)
})
}
// serveEmptyReleases returns an empty release list for selfhosted packages.
func (s *server) serveEmptyReleases(w http.ResponseWriter, format string) {
switch format {
case "json":
w.Header().Set("Content-Type", "application/json")
// Production returns an empty array.
json.NewEncoder(w).Encode([]legacyRelease{})
case "tab":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
}
// isSelfHosted checks if a package has install.sh but no releases.conf.
func (s *server) isSelfHosted(pkg string) bool {
installPath := filepath.Join(s.installersDir, pkg, "install.sh")
if _, err := os.Stat(installPath); err != nil {
return false
}
confPath := filepath.Join(s.installersDir, pkg, "releases.conf")
if _, err := os.Stat(confPath); err == nil {
return false
}
return true
}
// handleDebug returns UA detection info for the requesting client.
func (s *server) handleDebug(w http.ResponseWriter, r *http.Request) {
result := uadetect.FromRequest(r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"user_agent": r.Header.Get("User-Agent"),
"os": string(result.OS),
"arch": string(result.Arch),
"libc": string(result.Libc),
})
}
// handleBootstrap serves /{package} and /{package}@{version}.
// This is the curl-pipe bootstrap: a minimal script that sets
// WEBI_PKG/WEBI_HOST/WEBI_CHECKSUM and downloads+runs webi.
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
}
// Verify package exists.
if s.getPackage(pkg) == nil && !s.isSelfHosted(pkg) {
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
return
}
baseURL := baseURLFromRequest(r)
webiPkg := pkg
if tag != "" {
webiPkg = pkg + "@" + tag
}
// Read and inject the curl-pipe bootstrap template.
tplPath := filepath.Join(s.installersDir, "_webi", "curl-pipe-bootstrap.tpl.sh")
tpl, err := os.ReadFile(tplPath)
if err != nil {
log.Printf("bootstrap: read template: %v", err)
http.Error(w, "bootstrap template not found", http.StatusInternalServerError)
return
}
script := string(tpl)
script = render.InjectVar(script, "WEBI_PKG", webiPkg)
script = render.InjectVar(script, "WEBI_HOST", baseURL)
script = render.InjectVar(script, "WEBI_CHECKSUM", s.webiChecksum())
// text/html so browsers see the meta redirect to cheat sheet.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, script)
}
// handleInstaller serves /api/installers/{pkg}@{version}.sh
// This is the full installer script with release resolution and
// embedded install.sh.
func (s *server) handleInstaller(w http.ResponseWriter, r *http.Request) {
rest := r.PathValue("rest")
// Parse: {pkg}@{version}.sh or {pkg}.sh
ext := ""
if strings.HasSuffix(rest, ".sh") {
ext = "sh"
rest = strings.TrimSuffix(rest, ".sh")
} else if strings.HasSuffix(rest, ".ps1") {
ext = "ps1"
rest = strings.TrimSuffix(rest, ".ps1")
} else {
http.Error(w, "unsupported format (use .sh or .ps1)", http.StatusBadRequest)
return
}
pkg, tag := rest, ""
if idx := strings.IndexByte(rest, '@'); idx >= 0 {
pkg = rest[:idx]
tag = rest[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
}
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
}
baseURL := baseURLFromRequest(r)
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),
}
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 {
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)
}
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, " ")
}
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)
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
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, script)
}
// baseURLFromRequest builds the base URL from the request.
func baseURLFromRequest(r *http.Request) string {
if r.TLS != nil || strings.Contains(r.Host, "webinstall") || strings.Contains(r.Host, "webi.") {
return "https://" + r.Host
}
return "http://" + r.Host
}
// webiChecksum returns the checksum of the webi.sh bootstrap script.
func (s *server) webiChecksum() string {
s.mu.RLock()
cksum := s.webiCksum
s.mu.RUnlock()
if cksum != "" {
return cksum
}
// Calculate checksum from webi.sh file.
webiPath := filepath.Join(s.installersDir, "webi", "webi.sh")
data, err := os.ReadFile(webiPath)
if err != nil {
return "00000000"
}
h := sha1.New()
h.Write(data)
cksum = fmt.Sprintf("%x", h.Sum(nil))[:8]
s.mu.Lock()
s.webiCksum = cksum
s.mu.Unlock()
return cksum
}
// 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
}