Files
Claude 658460fe4c port: rewrite bootstrap tool in Go
Replace the Python bootstrap script with a Go implementation that
cross-compiles to native binaries for Linux and macOS on x86_64 and
aarch64. The Go port preserves all sections of the original (system
packages, optional Flatpak GUI apps, custom downloads, and the macOS
firecracker VM bridge) and adds first-class pacman support so the tool
works on Arch-family distros alongside Debian, RHEL, and macOS.

A Makefile produces a host binary via `make build` and the full
four-target matrix under dist/ via `make build-all`.
2026-05-22 21:48:57 +00:00

182 lines
4.4 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// Default per-call cap for runCmd and runShell. Generous enough for heavy
// installs (apt, brew, large downloads) but bounded so a stuck command can't
// hang the bootstrap forever. Override per-call for genuinely longer
// operations (e.g. pyenv compiles).
const defaultSubprocessTimeout = 30 * time.Minute
// CmdOpts captures the optional knobs on runCmd / runShell.
type CmdOpts struct {
AsSudo bool
Check bool // exit on failure (kept for parity but treated as advisory — we return the error instead)
Input []byte
Capture bool
Cwd string
Timeout time.Duration // zero = defaultSubprocessTimeout
}
// CmdResult holds the outcome of a subprocess invocation.
type CmdResult struct {
ExitCode int
Stdout []byte
Stderr []byte
Err error
}
func (r CmdResult) OK() bool { return r.Err == nil && r.ExitCode == 0 }
// runCmd executes argv with the supplied options.
func runCmd(argv []string, opts CmdOpts) CmdResult {
if opts.Timeout == 0 {
opts.Timeout = defaultSubprocessTimeout
}
if opts.AsSudo && os.Geteuid() != 0 {
argv = append([]string{"sudo"}, argv...)
}
fmt.Printf(" $ %s\n", strings.Join(argv, " "))
ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
defer cancel()
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
if opts.Input != nil {
cmd.Stdin = bytes.NewReader(opts.Input)
}
var stdout, stderr bytes.Buffer
if opts.Capture {
cmd.Stdout = &stdout
cmd.Stderr = &stderr
} else {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
err := cmd.Run()
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
if ctx.Err() == context.DeadlineExceeded {
warn(fmt.Sprintf("%q timed out after %s", argv[0], opts.Timeout))
res.ExitCode = 124
res.Err = ctx.Err()
return res
}
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
res.ExitCode = exitErr.ExitCode()
res.Err = err
return res
}
warn(fmt.Sprintf("error launching %q: %v", argv[0], err))
res.ExitCode = 1
res.Err = err
}
return res
}
// runShell executes a single shell string via /bin/sh -c (matching the Python
// version's subprocess.run(..., shell=True)).
func runShell(cmd string, opts CmdOpts) CmdResult {
if opts.Timeout == 0 {
opts.Timeout = defaultSubprocessTimeout
}
fmt.Printf(" $ %s\n", cmd)
ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
defer cancel()
c := exec.CommandContext(ctx, "/bin/sh", "-c", cmd)
if opts.Cwd != "" {
c.Dir = opts.Cwd
}
if opts.Input != nil {
c.Stdin = bytes.NewReader(opts.Input)
}
var stdout, stderr bytes.Buffer
if opts.Capture {
c.Stdout = &stdout
c.Stderr = &stderr
} else {
c.Stdout = os.Stdout
c.Stderr = os.Stderr
}
err := c.Run()
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
if ctx.Err() == context.DeadlineExceeded {
warn(fmt.Sprintf("shell command timed out after %s", opts.Timeout))
res.ExitCode = 124
res.Err = ctx.Err()
return res
}
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
res.ExitCode = exitErr.ExitCode()
res.Err = err
return res
}
warn(fmt.Sprintf("OSError in shell command: %v", err))
res.ExitCode = 1
res.Err = err
}
return res
}
// hasCmd is shutil.which() — returns true if name resolves on PATH.
func hasCmd(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// probe is a short, read-only command invocation used for "is this installed"
// checks. Returns (result, true) on completion (including non-zero exit) and
// (zero, false) on timeout/launch failure.
func probe(argv []string, timeout time.Duration) (CmdResult, bool) {
if timeout == 0 {
timeout = 30 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
res := CmdResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
if ctx.Err() == context.DeadlineExceeded {
warn(fmt.Sprintf("%q probe timed out", argv[0]))
return res, false
}
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
res.ExitCode = exitErr.ExitCode()
return res, true
}
warn(fmt.Sprintf("%q probe failed: %v", argv[0], err))
return res, false
}
return res, true
}