install.go raw

   1  package main
   2  
   3  import (
   4  	"fmt"
   5  	"os"
   6  	"os/exec"
   7  	"path/filepath"
   8  	"strings"
   9  	"time"
  10  
  11  	"moxie/builder"
  12  	"moxie/cache"
  13  	"moxie/compileopts"
  14  	"moxie/goenv"
  15  )
  16  
  17  // Install fetches (if remote), compiles, and caches a package binary + .mxh.
  18  // pkgName is either a local directory path or a remote import path (e.g.
  19  // "git.smesh.lol/iskradb"). The URL is passed as-is to git; protocol
  20  // detection is left to git itself.
  21  func Install(pkgName string, options *compileopts.Options) error {
  22  	config, err := builder.NewConfig(options)
  23  	if err != nil {
  24  		return err
  25  	}
  26  
  27  	importPath := pkgName
  28  	localDir := pkgName
  29  
  30  	// Remote package: import path contains a dot before the first slash.
  31  	firstSeg := strings.SplitN(pkgName, "/", 2)[0]
  32  	isRemote := strings.Contains(firstSeg, ".") && !isDir(pkgName)
  33  
  34  	if isRemote {
  35  		importPath = pkgName
  36  		localDir = cache.SourcePath(importPath)
  37  		if err := fetchOrUpdate(pkgName, localDir); err != nil {
  38  			return err
  39  		}
  40  	} else {
  41  		// Local directory: derive import path from directory name for cache key.
  42  		abs, err := filepath.Abs(localDir)
  43  		if err != nil {
  44  			return err
  45  		}
  46  		importPath = filepath.Base(abs)
  47  		localDir = abs
  48  	}
  49  
  50  	triple := config.Triple()
  51  
  52  	// Check cache hit: reuse if source hash + compiler version + triple match.
  53  	srcHash, err := cache.SourceHash(localDir)
  54  	if err != nil {
  55  		return fmt.Errorf("install: hashing source: %w", err)
  56  	}
  57  	compilerVer := goenv.Version()
  58  
  59  	if meta, err := cache.ReadMeta(triple, importPath); err == nil {
  60  		if meta.SourceHash == srcHash && meta.CompilerVersion == compilerVer && meta.Triple == triple {
  61  			fmt.Printf("install: %s is up to date\n", importPath)
  62  			return nil
  63  		}
  64  	}
  65  
  66  	// Build the binary.
  67  	binName := filepath.Base(importPath)
  68  	binPath := cache.BinPath(triple, importPath, binName)
  69  	if err := os.MkdirAll(filepath.Dir(binPath), 0755); err != nil {
  70  		return err
  71  	}
  72  	tmpdir, err := os.MkdirTemp("", "moxie-install-*")
  73  	if err != nil {
  74  		return err
  75  	}
  76  	defer os.RemoveAll(tmpdir)
  77  
  78  	_, err = builder.Build(localDir, binPath, tmpdir, config)
  79  	if err != nil {
  80  		return fmt.Errorf("install: build: %w", err)
  81  	}
  82  
  83  	// Extract .mxh (target-independent).
  84  	mxhDir := filepath.Dir(cache.MXHPath(importPath))
  85  	if err := os.MkdirAll(mxhDir, 0755); err != nil {
  86  		return err
  87  	}
  88  	if err := Header(localDir, mxhDir, "", options); err != nil {
  89  		return fmt.Errorf("install: extracting header: %w", err)
  90  	}
  91  	// Header writes <pkgname>.mxh but we want it at <importpath>.mxh.
  92  	// Move if the names differ.
  93  	pkgBase := filepath.Base(localDir)
  94  	emitted := filepath.Join(mxhDir, pkgBase+".mxh")
  95  	wanted := cache.MXHPath(importPath)
  96  	if emitted != wanted {
  97  		if err := os.Rename(emitted, wanted); err != nil {
  98  			return fmt.Errorf("install: moving .mxh: %w", err)
  99  		}
 100  	}
 101  
 102  	// Write metadata.
 103  	return cache.WriteMeta(triple, importPath, &cache.Meta{
 104  		SourceHash:      srcHash,
 105  		CompilerVersion: compilerVer,
 106  		Triple:          triple,
 107  		InstalledAt:     time.Now(),
 108  	})
 109  }
 110  
 111  // fetchOrUpdate clones or pulls a remote package into localDir.
 112  // The URL is passed as-is to git, allowing git to detect the protocol
 113  // (SSH, HTTPS, etc.) from its own configuration.
 114  func fetchOrUpdate(importPath, localDir string) error {
 115  	if isDir(filepath.Join(localDir, ".git")) {
 116  		// Already cloned: pull latest.
 117  		cmd := exec.Command("git", "-C", localDir, "pull", "--ff-only")
 118  		cmd.Stdout = os.Stdout
 119  		cmd.Stderr = os.Stderr
 120  		if err := cmd.Run(); err != nil {
 121  			return fmt.Errorf("git pull %s: %w", importPath, err)
 122  		}
 123  		return nil
 124  	}
 125  
 126  	if err := os.MkdirAll(filepath.Dir(localDir), 0755); err != nil {
 127  		return err
 128  	}
 129  	cmd := exec.Command("git", "clone", "--depth=1", importPath, localDir)
 130  	cmd.Stdout = os.Stdout
 131  	cmd.Stderr = os.Stderr
 132  	if err := cmd.Run(); err != nil {
 133  		return fmt.Errorf("git clone %s: %w", importPath, err)
 134  	}
 135  	return nil
 136  }
 137  
 138  // Fetch reads moxie.mod in the current directory, resolves all declared
 139  // dependencies, and runs Install for each. This is the network-fetch step;
 140  // moxie build never touches the network after this.
 141  func Fetch(options *compileopts.Options) error {
 142  	wd, err := os.Getwd()
 143  	if err != nil {
 144  		return err
 145  	}
 146  	modFile := findModFileIn(wd)
 147  	if modFile == "" {
 148  		return fmt.Errorf("fetch: no moxie.mod or go.mod found in %s", wd)
 149  	}
 150  	deps, err := readFetchDeps(modFile)
 151  	if err != nil {
 152  		return err
 153  	}
 154  	if len(deps) == 0 {
 155  		fmt.Println("fetch: no external dependencies found")
 156  		return nil
 157  	}
 158  	for _, dep := range deps {
 159  		fmt.Printf("fetch: installing %s\n", dep)
 160  		if err := Install(dep, options); err != nil {
 161  			return fmt.Errorf("fetch %s: %w", dep, err)
 162  		}
 163  	}
 164  	return nil
 165  }
 166  
 167  // findModFileIn returns the moxie.mod or go.mod path in dir.
 168  func findModFileIn(dir string) string {
 169  	for _, name := range []string{"moxie.mod", "go.mod"} {
 170  		p := filepath.Join(dir, name)
 171  		if _, err := os.Stat(p); err == nil {
 172  			return p
 173  		}
 174  	}
 175  	return ""
 176  }
 177  
 178  // readFetchDeps extracts require lines from a mod file that look like
 179  // remote import paths (contain a '.' before the first '/').
 180  func readFetchDeps(modFile string) ([]string, error) {
 181  	data, err := os.ReadFile(modFile)
 182  	if err != nil {
 183  		return nil, err
 184  	}
 185  	var deps []string
 186  	inRequire := false
 187  	for _, line := range strings.Split(string(data), "\n") {
 188  		trimmed := strings.TrimSpace(line)
 189  		if trimmed == "require (" {
 190  			inRequire = true
 191  			continue
 192  		}
 193  		if inRequire && trimmed == ")" {
 194  			inRequire = false
 195  			continue
 196  		}
 197  		if strings.HasPrefix(trimmed, "require ") {
 198  			trimmed = strings.TrimPrefix(trimmed, "require ")
 199  		} else if !inRequire {
 200  			continue
 201  		}
 202  		// "module/path v1.2.3" or "module/path vX.Y.Z // indirect"
 203  		parts := strings.Fields(trimmed)
 204  		if len(parts) < 2 {
 205  			continue
 206  		}
 207  		importPath := parts[0]
 208  		firstSeg := strings.SplitN(importPath, "/", 2)[0]
 209  		if strings.Contains(firstSeg, ".") {
 210  			deps = append(deps, importPath)
 211  		}
 212  	}
 213  	return deps, nil
 214  }
 215  
 216  func isDir(path string) bool {
 217  	info, err := os.Stat(path)
 218  	return err == nil && info.IsDir()
 219  }
 220