777 lines
22 KiB
Go
777 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// resolveURL returns the formatted download URL or "" if no template is set.
|
|
func (p *CustomPackage) resolveURL() string {
|
|
if p.URLTemplate == "" {
|
|
return ""
|
|
}
|
|
return formatURL(p.URLTemplate, p.Version)
|
|
}
|
|
|
|
func (p *CustomPackage) resolveSHA256URL() string {
|
|
if p.SHA256URLTemplate == "" {
|
|
return ""
|
|
}
|
|
return formatURL(p.SHA256URLTemplate, p.Version)
|
|
}
|
|
|
|
func (p *CustomPackage) resolvedSHA256() string {
|
|
if p.SHA256 != "" {
|
|
return strings.ToLower(p.SHA256)
|
|
}
|
|
if p.SHA256Map != nil {
|
|
key := osName + "-" + archName
|
|
if v, ok := p.SHA256Map[key]; ok {
|
|
return strings.ToLower(v)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (p *CustomPackage) displayName() string {
|
|
if p.Version != "" {
|
|
return p.Name + "-" + p.Version
|
|
}
|
|
return p.Name
|
|
}
|
|
|
|
var defaultInstallPaths = map[string]string{
|
|
"go": "/usr/local/go",
|
|
"firecracker": "/usr/local/bin/firecracker",
|
|
"zig": "/usr/local/bin/zig",
|
|
"nvm": "~/.nvm",
|
|
"pyenv": "~/.pyenv",
|
|
"neovim": "/usr/local/bin/nvim",
|
|
"oh-my-zsh": "~/.oh-my-zsh",
|
|
"agy": "~/.local/bin/agy",
|
|
"gh-repo-bootstrap": "~/.local/share/gh/extensions/gh-repo-bootstrap",
|
|
"yq": "/usr/local/bin/yq",
|
|
"rustup": "~/.cargo/bin/rustup",
|
|
"dagger": "/usr/local/bin/dagger",
|
|
"trivy": "/usr/local/bin/trivy",
|
|
"cosign": "/usr/local/bin/cosign",
|
|
"gitleaks": "/usr/local/bin/gitleaks",
|
|
}
|
|
|
|
func expandHome(p string) string {
|
|
if strings.HasPrefix(p, "~") {
|
|
home, err := os.UserHomeDir()
|
|
if err == nil {
|
|
return filepath.Join(home, strings.TrimPrefix(p, "~"))
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
func defaultInstallPath(pkg *CustomPackage) string {
|
|
if p, ok := defaultInstallPaths[strings.ToLower(pkg.Name)]; ok {
|
|
return p
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func pipInstalled() bool {
|
|
if !hasCmd("python3") {
|
|
return false
|
|
}
|
|
r, ok := probe([]string{"python3", "-m", "pip", "--version"}, 0)
|
|
return ok && r.ExitCode == 0
|
|
}
|
|
|
|
// isCustomPkgInstalled returns (installed, checkPath). pip ships inside the
|
|
// Python distribution rather than at a fixed path, so it's detected with
|
|
// `python3 -m pip --version`.
|
|
func npmInstalled(cmdName string) (bool, string) {
|
|
if hasCmd(cmdName) {
|
|
if p, err := exec.LookPath(cmdName); err == nil {
|
|
return true, p
|
|
}
|
|
return true, ""
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return false, ""
|
|
}
|
|
matches, _ := filepath.Glob(filepath.Join(home, ".local/share/pnpm/bin", cmdName))
|
|
if len(matches) > 0 {
|
|
return true, matches[0]
|
|
}
|
|
matches, _ = filepath.Glob(filepath.Join(home, ".nvm/versions/node/*/bin", cmdName))
|
|
if len(matches) > 0 {
|
|
return true, matches[0]
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
func isCustomPkgInstalled(pkg *CustomPackage) (bool, string) {
|
|
name := strings.ToLower(pkg.Name)
|
|
if name == "pip" {
|
|
return pipInstalled(), ""
|
|
}
|
|
if name == "claude" {
|
|
return npmInstalled("claude")
|
|
}
|
|
if name == "codex" {
|
|
return npmInstalled("codex")
|
|
}
|
|
if name == "copilot" {
|
|
return npmInstalled("copilot")
|
|
}
|
|
if name == "playwright" {
|
|
return npmInstalled("playwright")
|
|
}
|
|
if name == "mdts" {
|
|
return npmInstalled("mdts")
|
|
}
|
|
raw := pkg.InstallPath
|
|
if raw == "" {
|
|
raw = defaultInstallPath(pkg)
|
|
}
|
|
if raw == "" {
|
|
return false, ""
|
|
}
|
|
check := expandHome(raw)
|
|
if _, err := osStat(check); err == nil {
|
|
return true, check
|
|
}
|
|
return false, check
|
|
}
|
|
|
|
// verifyArchive validates a downloaded archive against either a pinned
|
|
// sha256 or a .minisig signature. Returns true when verified or nothing to
|
|
// verify (latter case logs a warning).
|
|
func verifyArchive(archive string, pkg *CustomPackage) bool {
|
|
if expected := pkg.resolvedSHA256(); expected != "" {
|
|
actual, err := sha256Of(archive)
|
|
if err != nil {
|
|
errLog(fmt.Sprintf("hash failed for %s: %v", pkg.Name, err))
|
|
return false
|
|
}
|
|
if actual != expected {
|
|
errLog(fmt.Sprintf("SHA256 mismatch for %s: expected %s, got %s", pkg.Name, expected, actual))
|
|
return false
|
|
}
|
|
taskPrintln(" SHA256 OK")
|
|
return true
|
|
}
|
|
if sigURL := pkg.resolveSHA256URL(); sigURL != "" {
|
|
sigPath := filepath.Join(filepath.Dir(archive), filepath.Base(sigURL))
|
|
if !download(sigURL, sigPath) {
|
|
return false
|
|
}
|
|
if !hasCmd("minisign") {
|
|
warn(fmt.Sprintf("minisign not installed — skipping signature verification for %s", pkg.Name))
|
|
return true
|
|
}
|
|
cmd := []string{"minisign", "-Vm", archive, "-x", sigPath}
|
|
if pkg.MinisignKey != "" {
|
|
cmd = append(cmd, "-P", pkg.MinisignKey)
|
|
}
|
|
if !runCmd(cmd, CmdOpts{Out: taskOut()}).OK() {
|
|
errLog(fmt.Sprintf("minisign verification failed for %s", pkg.Name))
|
|
return false
|
|
}
|
|
taskPrintln(" minisign OK")
|
|
}
|
|
return true
|
|
}
|
|
|
|
func urlArchOK(pkg *CustomPackage) bool {
|
|
url := pkg.resolveURL()
|
|
if url == "" {
|
|
return true
|
|
}
|
|
if archMatches(url, archName) {
|
|
return true
|
|
}
|
|
if hasOtherArchToken(url) {
|
|
warn(fmt.Sprintf("%s: URL targets %s but host is %s. Update packages.go with a matching URL/SHA256.",
|
|
pkg.Name, otherArch(), archName))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ── per-package install handlers ────────────────────────────────────────
|
|
|
|
func installGo(archive string) {
|
|
out := taskOut()
|
|
goRoot := "/usr/local/go"
|
|
if _, err := osStat(goRoot); err == nil {
|
|
taskPrintf(" Removing existing Go at %s ...\n", goRoot)
|
|
runCmd([]string{"rm", "-rf", goRoot}, CmdOpts{AsSudo: true, Out: out})
|
|
}
|
|
runCmd([]string{"tar", "-C", "/usr/local", "-xzf", archive}, CmdOpts{AsSudo: true, Out: out})
|
|
appendProfileLine("local_go", "export PATH=$PATH:/usr/local/go/bin")
|
|
taskPrintf(" Go installed to %s\n", goRoot)
|
|
}
|
|
|
|
func installFirecracker(archive, tmp string) {
|
|
out := taskOut()
|
|
if !runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{Out: out}).OK() {
|
|
errLog("firecracker tar extraction failed")
|
|
return
|
|
}
|
|
var binary string
|
|
filepath.Walk(tmp, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
name := info.Name()
|
|
if strings.HasSuffix(name, ".tgz") || strings.HasSuffix(name, ".tar.gz") {
|
|
return nil
|
|
}
|
|
if !strings.HasPrefix(name, "firecracker-v") || strings.Contains(name, "debug") {
|
|
return nil
|
|
}
|
|
if binary == "" {
|
|
binary = path
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if binary == "" {
|
|
errLog("firecracker binary not found in archive")
|
|
return
|
|
}
|
|
dest := "/usr/local/bin/firecracker"
|
|
runCmd([]string{"cp", binary, dest}, CmdOpts{AsSudo: true, Out: out})
|
|
runCmd([]string{"chmod", "755", dest}, CmdOpts{AsSudo: true, Out: out})
|
|
taskPrintf(" firecracker installed to %s\n", dest)
|
|
}
|
|
|
|
func installZig(pkg *CustomPackage, archive string) {
|
|
out := taskOut()
|
|
parent := "/usr/local"
|
|
zigDir := filepath.Join(parent, "zig-"+pkg.Version)
|
|
if _, err := osStat(zigDir); err == nil {
|
|
runCmd([]string{"rm", "-rf", zigDir}, CmdOpts{AsSudo: true, Out: out})
|
|
}
|
|
runCmd([]string{"tar", "-C", parent, "-xJf", archive}, CmdOpts{AsSudo: true, Out: out})
|
|
|
|
pattern := filepath.Join(parent, fmt.Sprintf("zig-%s-%s*", archName, osZig[osName]))
|
|
matches, _ := filepath.Glob(pattern)
|
|
for _, m := range matches {
|
|
if m != zigDir {
|
|
runCmd([]string{"mv", m, zigDir}, CmdOpts{AsSudo: true, Out: out})
|
|
break
|
|
}
|
|
}
|
|
symlink := "/usr/local/bin/zig"
|
|
runCmd([]string{"ln", "-sf", filepath.Join(zigDir, "zig"), symlink}, CmdOpts{AsSudo: true, Out: out})
|
|
taskPrintf(" Zig installed to %s, symlinked at %s\n", zigDir, symlink)
|
|
}
|
|
|
|
func installNeovim(_ *CustomPackage, tmp string) {
|
|
out := taskOut()
|
|
var rel ghRelease
|
|
if !fetchJSON("https://api.github.com/repos/neovim/neovim/releases/latest", &rel) {
|
|
return
|
|
}
|
|
archTok := archNvim[archName]
|
|
osTok := osNvim[osName]
|
|
assetName := fmt.Sprintf("nvim-%s-%s.tar.gz", osTok, archTok)
|
|
|
|
var asset *ghAsset
|
|
for i := range rel.Assets {
|
|
if rel.Assets[i].Name == assetName {
|
|
asset = &rel.Assets[i]
|
|
break
|
|
}
|
|
}
|
|
if asset == nil {
|
|
errLog(fmt.Sprintf("Neovim asset %s not found", assetName))
|
|
return
|
|
}
|
|
if !strings.HasPrefix(asset.Digest, "sha256:") {
|
|
errLog("Neovim asset digest missing or invalid")
|
|
return
|
|
}
|
|
expected := strings.TrimPrefix(asset.Digest, "sha256:")
|
|
dest := filepath.Join(tmp, assetName)
|
|
if !download(asset.BrowserDownloadURL, dest) {
|
|
return
|
|
}
|
|
actual, err := sha256Of(dest)
|
|
if err != nil {
|
|
errLog(fmt.Sprintf("Neovim hash failed: %v", err))
|
|
return
|
|
}
|
|
if actual != expected {
|
|
errLog(fmt.Sprintf("Neovim SHA256 mismatch: expected %s, got %s", expected, actual))
|
|
return
|
|
}
|
|
taskPrintln(" SHA256 OK")
|
|
|
|
installDir := fmt.Sprintf("/opt/nvim-%s-%s", osTok, archTok)
|
|
taskPrintln(" Extracting Neovim to /opt ...")
|
|
runCmd([]string{"mkdir", "-p", "/opt"}, CmdOpts{AsSudo: true, Out: out})
|
|
runCmd([]string{"rm", "-rf", installDir}, CmdOpts{AsSudo: true, Out: out})
|
|
runCmd([]string{"tar", "-C", "/opt", "-xzf", dest}, CmdOpts{AsSudo: true, Out: out})
|
|
|
|
runCmd([]string{"mkdir", "-p", "/usr/local/bin"}, CmdOpts{AsSudo: true, Out: out})
|
|
symlink := "/usr/local/bin/nvim"
|
|
runCmd([]string{"ln", "-sf", filepath.Join(installDir, "bin", "nvim"), symlink}, CmdOpts{AsSudo: true, Out: out})
|
|
taskPrintf(" Neovim installed to %s, symlinked at %s\n", installDir, symlink)
|
|
}
|
|
|
|
func installRustup(archive string) {
|
|
out := taskOut()
|
|
runCmd([]string{"chmod", "+x", archive}, CmdOpts{Out: out})
|
|
runCmd([]string{archive, "-y", "--no-modify-path"}, CmdOpts{Out: out})
|
|
taskPrintln(" rustup installed.")
|
|
}
|
|
|
|
func installYq(archive string) {
|
|
out := taskOut()
|
|
runCmd([]string{"cp", archive, "/usr/local/bin/yq"}, CmdOpts{AsSudo: true, Out: out})
|
|
runCmd([]string{"chmod", "+x", "/usr/local/bin/yq"}, CmdOpts{AsSudo: true, Out: out})
|
|
taskPrintln(" yq installed.")
|
|
}
|
|
|
|
func installDagger(archive, tmp string) {
|
|
out := taskOut()
|
|
runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{Out: out})
|
|
runCmd([]string{"mv", filepath.Join(tmp, "dagger"), "/usr/local/bin/dagger"}, CmdOpts{AsSudo: true, Out: out})
|
|
taskPrintln(" dagger installed.")
|
|
}
|
|
|
|
func installTrivy(archive, tmp string) {
|
|
out := taskOut()
|
|
runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{Out: out})
|
|
runCmd([]string{"mv", filepath.Join(tmp, "trivy"), "/usr/local/bin/trivy"}, CmdOpts{AsSudo: true, Out: out})
|
|
taskPrintln(" trivy installed.")
|
|
}
|
|
|
|
func installCosign(archive string) {
|
|
out := taskOut()
|
|
runCmd([]string{"cp", archive, "/usr/local/bin/cosign"}, CmdOpts{AsSudo: true, Out: out})
|
|
runCmd([]string{"chmod", "+x", "/usr/local/bin/cosign"}, CmdOpts{AsSudo: true, Out: out})
|
|
taskPrintln(" cosign installed.")
|
|
}
|
|
|
|
func installGitleaks(archive, tmp string) {
|
|
out := taskOut()
|
|
runCmd([]string{"tar", "-C", tmp, "-xzf", archive}, CmdOpts{Out: out})
|
|
runCmd([]string{"mv", filepath.Join(tmp, "gitleaks"), "/usr/local/bin/gitleaks"}, CmdOpts{AsSudo: true, Out: out})
|
|
taskPrintln(" gitleaks installed.")
|
|
}
|
|
|
|
// ── latest-version resolvers ────────────────────────────────────────────
|
|
|
|
func resolveLatestGo(_ *CustomPackage) (string, string, bool) {
|
|
var raw json.RawMessage
|
|
if !fetchJSON("https://go.dev/dl/?mode=json", &raw) {
|
|
return "", "", false
|
|
}
|
|
// API returns an array; first element is the latest stable release.
|
|
type goFile struct {
|
|
Filename string `json:"filename"`
|
|
Kind string `json:"kind"`
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
type goRelease struct {
|
|
Version string `json:"version"`
|
|
Files []goFile `json:"files"`
|
|
}
|
|
var releases []goRelease
|
|
if err := json.Unmarshal(raw, &releases); err != nil || len(releases) == 0 {
|
|
var single goRelease
|
|
if err := json.Unmarshal(raw, &single); err != nil {
|
|
return "", "", false
|
|
}
|
|
releases = []goRelease{single}
|
|
}
|
|
latest := releases[0]
|
|
version := strings.TrimPrefix(latest.Version, "go")
|
|
if version == "" {
|
|
return "", "", false
|
|
}
|
|
archiveName := fmt.Sprintf("go%s.%s-%s.tar.gz", version, osGo[osName], archGo[archName])
|
|
for _, f := range latest.Files {
|
|
if f.Filename == archiveName && f.Kind == "archive" && f.SHA256 != "" {
|
|
return version, f.SHA256, true
|
|
}
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
func resolveLatestFirecracker(_ *CustomPackage) (string, string, bool) {
|
|
if isMacOS {
|
|
return "", "", false
|
|
}
|
|
var rel ghRelease
|
|
if !fetchJSON("https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest", &rel) {
|
|
return "", "", false
|
|
}
|
|
version := strings.TrimPrefix(rel.TagName, "v")
|
|
if version == "" {
|
|
return "", "", false
|
|
}
|
|
archiveName := fmt.Sprintf("firecracker-v%s-%s.tgz", version, archName)
|
|
shaAssetName := archiveName + ".sha256.txt"
|
|
for _, a := range rel.Assets {
|
|
if a.Name == shaAssetName {
|
|
sha := fetchText(a.BrowserDownloadURL)
|
|
if sha == "" {
|
|
return "", "", false
|
|
}
|
|
return version, strings.Fields(sha)[0], true
|
|
}
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
var zigVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
|
|
|
func resolveLatestZig(_ *CustomPackage) (string, string, bool) {
|
|
var data map[string]map[string]any
|
|
if !fetchJSON("https://ziglang.org/download/index.json", &data) {
|
|
return "", "", false
|
|
}
|
|
var stable []string
|
|
for k := range data {
|
|
if k != "master" && zigVersionRe.MatchString(k) {
|
|
stable = append(stable, k)
|
|
}
|
|
}
|
|
if len(stable) == 0 {
|
|
return "", "", false
|
|
}
|
|
sort.Slice(stable, func(i, j int) bool {
|
|
return cmpSemver(stable[i], stable[j]) < 0
|
|
})
|
|
version := stable[len(stable)-1]
|
|
key := archName + "-" + osZig[osName]
|
|
entry, ok := data[version][key].(map[string]any)
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
sha, _ := entry["shasum"].(string)
|
|
if sha == "" {
|
|
return "", "", false
|
|
}
|
|
return version, sha, true
|
|
}
|
|
|
|
func cmpSemver(a, b string) int {
|
|
pa := strings.Split(a, ".")
|
|
pb := strings.Split(b, ".")
|
|
for i := 0; i < len(pa) && i < len(pb); i++ {
|
|
ai, _ := strconv.Atoi(pa[i])
|
|
bi, _ := strconv.Atoi(pb[i])
|
|
if ai != bi {
|
|
if ai < bi {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
}
|
|
return len(pa) - len(pb)
|
|
}
|
|
|
|
func resolveLatestYq(_ *CustomPackage) (string, string, bool) {
|
|
var rel ghRelease
|
|
if !fetchJSON("https://api.github.com/repos/mikefarah/yq/releases/latest", &rel) {
|
|
return "", "", false
|
|
}
|
|
version := strings.TrimPrefix(rel.TagName, "v")
|
|
if version == "" {
|
|
return "", "", false
|
|
}
|
|
return version, "", true
|
|
}
|
|
|
|
var latestResolvers = map[string]func(*CustomPackage) (string, string, bool){
|
|
"go": resolveLatestGo,
|
|
"firecracker": resolveLatestFirecracker,
|
|
"zig": resolveLatestZig,
|
|
"yq": resolveLatestYq,
|
|
}
|
|
|
|
// resolveLatest best-effort upgrades pkg.Version/SHA256 to the latest release.
|
|
// On any failure, warns and leaves the pinned values in place.
|
|
func resolveLatest(pkg *CustomPackage) {
|
|
resolver, ok := latestResolvers[pkg.FetchLatest]
|
|
if !ok {
|
|
return
|
|
}
|
|
taskPrintf(" Checking latest version for %s ...\n", pkg.Name)
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
warn(fmt.Sprintf("%s: latest-version lookup panicked %v; falling back to pinned version %s",
|
|
pkg.Name, r, pkg.Version))
|
|
}
|
|
}()
|
|
version, sha, found := resolver(pkg)
|
|
if !found {
|
|
warn(fmt.Sprintf("%s: could not resolve latest version; falling back to pinned version %s",
|
|
pkg.Name, pkg.Version))
|
|
return
|
|
}
|
|
if version == pkg.Version {
|
|
taskPrintf(" Pinned version %s is already the latest.\n", pkg.Version)
|
|
return
|
|
}
|
|
taskPrintf(" Latest is %s (pinned was %s); using latest.\n", version, pkg.Version)
|
|
pkg.Version = version
|
|
pkg.SHA256 = strings.ToLower(sha)
|
|
pkg.SHA256URLTemplate = "" // prefer the freshly resolved sha256
|
|
}
|
|
|
|
// resolveLatestAll fetches latest versions for all packages with a
|
|
// FetchLatest hint in parallel — three small HTTP calls today, but enough to
|
|
// matter on slower connections. Each lookup is independent and idempotent.
|
|
func resolveLatestAll(pkgs []*CustomPackage) {
|
|
var withLatest []*CustomPackage
|
|
for _, p := range pkgs {
|
|
if _, ok := latestResolvers[p.FetchLatest]; ok {
|
|
withLatest = append(withLatest, p)
|
|
}
|
|
}
|
|
if len(withLatest) == 0 {
|
|
return
|
|
}
|
|
parallelDo(withLatest, httpWorkers(), func(_ int, p *CustomPackage) {
|
|
resolveLatest(p)
|
|
})
|
|
}
|
|
|
|
// ── orchestration ───────────────────────────────────────────────────────
|
|
|
|
// Dependency map for custom packages:
|
|
//
|
|
// nvm → claude, codex, copilot, playwright (need node from nvm)
|
|
// (none) → go, firecracker, zig, neovim, pyenv, pip, oh-my-zsh, agy,
|
|
// gh-repo-bootstrap (independent)
|
|
//
|
|
// Within "independent", we further split:
|
|
//
|
|
// Wave A (parallel, idempotent on disk targets that don't overlap):
|
|
// go, firecracker, zig, neovim, pyenv, pip, oh-my-zsh, agy, nvm,
|
|
// gh-repo-bootstrap
|
|
//
|
|
// Wave B (after Wave A; needs nvm/node to exist):
|
|
// claude, codex, copilot, playwright — batched into one pnpm call
|
|
//
|
|
// We parallelize Wave A up to cpuWorkers(). Each install runs under its own
|
|
// taskOutput so output stays grouped per-package. Wave B runs after Wave A
|
|
// has produced ~/.nvm; it batches the npm tools into a single `pnpm add -g`
|
|
// call (single Node startup, single pnpm dep solve).
|
|
|
|
func nodeDependentPkgs() map[string]bool {
|
|
return map[string]bool{
|
|
"claude": true,
|
|
"codex": true,
|
|
"copilot": true,
|
|
"playwright": true,
|
|
"mdts": true,
|
|
}
|
|
}
|
|
|
|
// runOneCustomInstall executes a single custom package's install handler.
|
|
// The caller is responsible for setting up the goroutine-local task output
|
|
// when running in parallel. extracted from the old switch statement.
|
|
func runOneCustomInstall(pkg *CustomPackage) {
|
|
name := strings.ToLower(pkg.Name)
|
|
_, checkPath := isCustomPkgInstalled(pkg)
|
|
extra := ""
|
|
if checkPath != "" {
|
|
extra = fmt.Sprintf(" (install path: %s)", checkPath)
|
|
}
|
|
taskPrintf("\n Installing %s ...%s\n", pkg.displayName(), extra)
|
|
if checkPath == "" && name != "pip" {
|
|
warn(fmt.Sprintf("%s: no known install path — script will not detect future installs", pkg.Name))
|
|
}
|
|
|
|
if name == "firecracker" && isMacOS {
|
|
warn(fmt.Sprintf("%s: Linux-only — skipping on macOS", pkg.Name))
|
|
return
|
|
}
|
|
|
|
switch name {
|
|
case "nvm":
|
|
installNVM()
|
|
return
|
|
case "pyenv":
|
|
installPyenv()
|
|
return
|
|
case "pip":
|
|
installPip()
|
|
return
|
|
case "oh-my-zsh":
|
|
installOhMyZsh()
|
|
return
|
|
case "neovim":
|
|
tmp, err := os.MkdirTemp("", "bootstrap-nvim-")
|
|
if err != nil {
|
|
errLog(fmt.Sprintf("neovim tmp dir failed: %v", err))
|
|
return
|
|
}
|
|
installNeovim(pkg, tmp)
|
|
osRemoveAll(tmp)
|
|
return
|
|
case "agy":
|
|
installAgy()
|
|
return
|
|
case "gh-repo-bootstrap":
|
|
installGHExtension("JMR-dev/gh-repo-bootstrap")
|
|
return
|
|
}
|
|
|
|
url := pkg.resolveURL()
|
|
if url == "" {
|
|
warn(fmt.Sprintf("No URL or install handler for '%s' — skipping", pkg.Name))
|
|
return
|
|
}
|
|
if !urlArchOK(pkg) {
|
|
return
|
|
}
|
|
|
|
tmp, err := os.MkdirTemp("", "bootstrap-custom-")
|
|
if err != nil {
|
|
errLog(fmt.Sprintf("tmp dir failed for %s: %v", pkg.Name, err))
|
|
return
|
|
}
|
|
defer osRemoveAll(tmp)
|
|
archive := filepath.Join(tmp, filepath.Base(url))
|
|
if !download(url, archive) {
|
|
return
|
|
}
|
|
if !verifyArchive(archive, pkg) {
|
|
return
|
|
}
|
|
switch name {
|
|
case "go":
|
|
installGo(archive)
|
|
case "firecracker":
|
|
installFirecracker(archive, tmp)
|
|
case "zig":
|
|
installZig(pkg, archive)
|
|
case "rustup":
|
|
installRustup(archive)
|
|
case "yq":
|
|
installYq(archive)
|
|
case "dagger":
|
|
installDagger(archive, tmp)
|
|
case "trivy":
|
|
installTrivy(archive, tmp)
|
|
case "cosign":
|
|
installCosign(archive)
|
|
case "gitleaks":
|
|
installGitleaks(archive, tmp)
|
|
default:
|
|
warn(fmt.Sprintf("No install handler for '%s' — skipping", pkg.Name))
|
|
}
|
|
}
|
|
|
|
// installNpmToolsBatch installs all npm-based CLI tools (claude, codex,
|
|
// copilot, playwright) in a single `pnpm add -g` invocation. This is
|
|
// significantly faster than per-tool installs because pnpm only resolves
|
|
// the dep graph and starts Node once. On batch failure we fall back to
|
|
// per-package installs so we can report exactly which tool broke.
|
|
//
|
|
// playwright is special: after the npm install we still need to provision
|
|
// browsers via `pnpx playwright install`. We do that after the batch.
|
|
func installNpmToolsBatch(pkgs []*CustomPackage) {
|
|
if len(pkgs) == 0 {
|
|
return
|
|
}
|
|
home, _ := os.UserHomeDir()
|
|
if _, err := osStat(filepath.Join(home, ".nvm")); err != nil {
|
|
errLog("NVM is not installed — cannot install npm-based tools")
|
|
return
|
|
}
|
|
ensureNodeLTS()
|
|
|
|
npmNames := map[string]string{
|
|
"claude": "@anthropic-ai/claude-code",
|
|
"codex": "@openai/codex",
|
|
"copilot": "@github/copilot",
|
|
"playwright": "playwright",
|
|
"mdts": "mdts",
|
|
}
|
|
|
|
var npmPkgs []string
|
|
var hasPlaywright bool
|
|
for _, p := range pkgs {
|
|
n := strings.ToLower(p.Name)
|
|
if pkg, ok := npmNames[n]; ok {
|
|
npmPkgs = append(npmPkgs, pkg)
|
|
if n == "playwright" {
|
|
hasPlaywright = true
|
|
}
|
|
}
|
|
}
|
|
if len(npmPkgs) == 0 {
|
|
return
|
|
}
|
|
|
|
fmt.Printf("\n Installing %d npm tool(s) via pnpm in one batch ...\n", len(npmPkgs))
|
|
addCmd := fmt.Sprintf(`bash -c '%ssource ~/.nvm/nvm.sh && pnpm add -g %s'`,
|
|
pnpmEnvPrefix(), strings.Join(npmPkgs, " "))
|
|
if !runShell(addCmd, CmdOpts{}).OK() {
|
|
warn("Batched pnpm add -g failed; retrying per-package to isolate failures ...")
|
|
for _, p := range pkgs {
|
|
n := strings.ToLower(p.Name)
|
|
if pkg, ok := npmNames[n]; ok {
|
|
installNpmPackage(pkg)
|
|
}
|
|
}
|
|
}
|
|
|
|
if hasPlaywright {
|
|
installPlaywrightBrowsers()
|
|
}
|
|
}
|
|
|
|
func installCustomPackages(toInstall []*CustomPackage) {
|
|
fmt.Println("\n=== Custom Packages ===")
|
|
if len(toInstall) == 0 {
|
|
return
|
|
}
|
|
|
|
// Fetch latest versions for all to-install packages in parallel up
|
|
// front — small HTTP calls but they add up serially on slow links.
|
|
resolveLatestAll(toInstall)
|
|
|
|
// Split into independent (Wave A) vs node-dependent (Wave B).
|
|
nodeDeps := nodeDependentPkgs()
|
|
var waveA, waveB []*CustomPackage
|
|
for _, p := range toInstall {
|
|
if nodeDeps[strings.ToLower(p.Name)] {
|
|
waveB = append(waveB, p)
|
|
} else {
|
|
waveA = append(waveA, p)
|
|
}
|
|
}
|
|
|
|
// Wave A: parallel up to cpuWorkers(). Each package's output is buffered
|
|
// to a per-task taskOutput and flushed on completion so that concurrent
|
|
// installs don't interleave on stdout.
|
|
parallelDo(waveA, cpuWorkers(), func(_ int, pkg *CustomPackage) {
|
|
tOut := newCapturedOutput(pkg.Name)
|
|
withTaskOutput(tOut, func() {
|
|
runOneCustomInstall(pkg)
|
|
})
|
|
tOut.Flush(os.Stdout)
|
|
})
|
|
|
|
// Wave B (npm tools): batched into a single pnpm call. Requires Wave A
|
|
// to have completed (specifically: nvm install + ensureNodeLTS), so we
|
|
// run it after the parallel block returns.
|
|
installNpmToolsBatch(waveB)
|
|
}
|