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