diff --git a/build.ps1 b/build.ps1 index 4b598cb..b72634d 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1 +1 @@ -crystal build src/mol.cr -o mol.exe --release \ No newline at end of file +go build -o mol.exe mol.go \ No newline at end of file diff --git a/build.sh b/build.sh index b2abf57..e4abaf5 100644 --- a/build.sh +++ b/build.sh @@ -1,2 +1,2 @@ #!/bin/sh -crystal build src/mol.cr -o mol --release \ No newline at end of file +go build -o mol.exe mol.go \ No newline at end of file diff --git a/mol.exe b/mol.exe index 20332d0..558b995 100644 Binary files a/mol.exe and b/mol.exe differ diff --git a/mol.go b/mol.go new file mode 100644 index 0000000..68a494b --- /dev/null +++ b/mol.go @@ -0,0 +1,659 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const version = "0.4.0-boredos" + +var ( + home = os.Getenv("HOME") + bin = filepath.Join(home, ".local", "bin") + src = ".mol/src" + db = ".mol/repo" + stage = ".mol/stage" + manifestDir = ".mol/manifests" + reposFile = ".mol/repos" + depCache = ".mol/depcache.json" + defaultRepo = envOr("MOL_REPO", "https://git.sr.ht/~chersbobers/mol-pkgrepo/blob/master/PACKAGES?raw=true") + goos = runtime.GOOS +) + +func init() { + if goos == "windows" { + bin = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local", "mol", "bin") + } +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +const ( + reset = "\033[0m" + bold = "\033[1m" + dim = "\033[2m" + green = "\033[32m" + yellow = "\033[33m" + cyan = "\033[36m" + red = "\033[31m" +) + +func uiInfo(msg string) { fmt.Printf("%s →%s %s\n", cyan, reset, msg) } +func uiOk(msg string) { fmt.Printf("%s ✓%s %s%s%s\n", green, reset, bold, msg, reset) } +func uiWarn(msg string) { fmt.Printf("%s ⚠%s %s\n", yellow, reset, msg) } +func uiErr(msg string) { fmt.Fprintf(os.Stderr, "%s ✗%s %s%s%s\n", red, reset, bold, msg, reset) } +func uiDim(msg string) { fmt.Printf("%s %s%s\n", dim, msg, reset) } +func uiSection(msg string) { fmt.Printf("\n%s%s▸ %s%s\n", bold, cyan, msg, reset) } + +func uiHeader() { + fmt.Printf("%s%smol%s %sv%s · GPLv3%s\n", bold, cyan, reset, dim, version, reset) + fmt.Printf("%s%s%s\n", dim, strings.Repeat("─", 42), reset) +} + +func uiProgress(label string, total int, fn func(int)) { + for i := 0; i < total; i++ { + pct := ((i + 1) * 100) / total + done := (30 * pct) / 100 + bar := strings.Repeat("█", done) + strings.Repeat("░", 30-done) + fmt.Printf("\r %s%s%s %d%% %s%s%s ", cyan, bar, reset, pct, dim, label, reset) + fn(i) + } + fmt.Println() +} + +func run(cmd string, args []string, dir string) bool { + c := exec.Command(cmd, args...) + c.Dir = dir + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() == nil +} + +func stageBegin(name string) string { + path := filepath.Join(stage, name) + os.RemoveAll(path) + os.MkdirAll(path, 0755) + return path +} + +func stageCommit(name, stagedPath string) { + manifestPath := filepath.Join(manifestDir, name+".list") + os.MkdirAll(manifestDir, 0755) + var written []string + + filepath.Walk(stagedPath, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + rel, _ := filepath.Rel(stagedPath, path) + dest := filepath.Join("/", rel) + os.MkdirAll(filepath.Dir(dest), 0755) + copyFile(path, dest) + written = append(written, dest) + return nil + }) + + os.WriteFile(manifestPath, []byte(strings.Join(written, "\n")), 0644) + os.RemoveAll(stagedPath) + uiOk(fmt.Sprintf("committed %d file(s) for %s", len(written), name)) +} + +func stageRollback(name string) { + manifestPath := filepath.Join(manifestDir, name+".list") + if _, err := os.Stat(manifestPath); os.IsNotExist(err) { + return + } + uiWarn("rolling back " + name + "…") + f, _ := os.Open(manifestPath) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + path := strings.TrimSpace(scanner.Text()) + if path == "" { + continue + } + os.Remove(path) + uiDim("removed " + path) + } + f.Close() + os.Remove(manifestPath) + uiOk("rollback complete for " + name) +} + +func hasManifest(name string) bool { + _, err := os.Stat(filepath.Join(manifestDir, name+".list")) + return err == nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +type graph map[string][]string + +func loadGraph() graph { + g := make(graph) + data, err := os.ReadFile(depCache) + if err != nil { + return g + } + json.Unmarshal(data, &g) + return g +} + +func saveGraph(g graph) { + os.MkdirAll(filepath.Dir(depCache), 0755) + data, _ := json.Marshal(g) + os.WriteFile(depCache, data, 0644) +} + +func resolveGraph(root string, g graph) []string { + var order []string + visited := make(map[string]bool) + var visit func(string) + visit = func(name string) { + if visited[name] { + return + } + visited[name] = true + for _, dep := range g[name] { + visit(dep) + } + order = append(order, name) + } + visit(root) + return order +} + +func loadRepos() []string { + repos := []string{defaultRepo} + f, err := os.Open(reposFile) + if err != nil { + return repos + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + l := strings.TrimSpace(scanner.Text()) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + found := false + for _, r := range repos { + if r == l { + found = true + break + } + } + if !found { + repos = append(repos, l) + } + } + return repos +} + +func addRepo(url string) { + os.MkdirAll(filepath.Dir(reposFile), 0755) + f, _ := os.OpenFile(reposFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + defer f.Close() + fmt.Fprintln(f, url) + uiOk("added repo: " + url) +} + +func lookup(t string) (string, string) { + if strings.Contains(t, "://") { + return t, "" + } + f, err := os.Open(db) + if err != nil { + fmt.Fprintf(os.Stderr, "%s not found — run: mol sync\n", t) + os.Exit(1) + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + l := strings.TrimSpace(scanner.Text()) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + parts := strings.Fields(l) + if len(parts) < 2 || parts[0] != t { + continue + } + binURL := "" + for _, p := range parts { + if strings.HasPrefix(p, "B=") { + binURL = p[2:] + } + } + return parts[1], binURL + } + fmt.Fprintf(os.Stderr, "%s not found — run: mol sync\n", t) + os.Exit(1) + return "", "" +} + +func installed(name string) bool { + if hasManifest(name) { + return true + } + if _, err := os.Stat(filepath.Join(src, name)); err == nil { + return true + } + if _, err := os.Stat(filepath.Join(bin, name)); err == nil { + return true + } + return false +} + +func linkBins(path string) { + os.MkdirAll(bin, 0755) + candidates := []string{ + filepath.Join(path, "bin"), + filepath.Join(path, "target", "release"), + path, + } + for _, d := range candidates { + if _, err := os.Stat(d); os.IsNotExist(err) { + continue + } + entries, _ := os.ReadDir(d) + for _, e := range entries { + if e.IsDir() || strings.Contains(e.Name(), ".") { + continue + } + full := filepath.Join(d, e.Name()) + info, _ := e.Info() + if info.Mode()&0111 == 0 { + continue + } + dest := filepath.Join(bin, e.Name()) + copyFile(full, dest) + os.Chmod(dest, 0755) + uiInfo("linked → " + dest) + } + } +} + +func parseMolpkg(path string) ([]string, []string) { + var deps, build []string + file := filepath.Join(path, "mol.pkg") + f, err := os.Open(file) + if err != nil { + return deps, build + } + defer f.Close() + + inBuild, inOs, inInstall := false, false, false + scanner := bufio.NewScanner(f) + for scanner.Scan() { + l := strings.TrimSpace(scanner.Text()) + switch { + case strings.Contains(l, "(deps"): + for _, m := range extractQuoted(l) { + deps = append(deps, m) + } + case strings.Contains(l, "(build"): + inBuild = true + case strings.Contains(l, "(install"): + inBuild, inInstall = false, true + case inBuild && strings.Contains(l, "("+goos): + inOs = true + case inBuild && inOs && strings.HasPrefix(l, "(run"): + parts := extractQuoted(l) + if len(parts) > 0 { + build = append(build, strings.Join(parts, " ")) + } + case l == ")" && inOs: + inOs = false + case l == ")" && inBuild: + inBuild = false + case inInstall && strings.Contains(l, "(bin"): + quoted := extractQuoted(l) + if len(quoted) == 0 { + continue + } + name := quoted[0] + srcPath := filepath.Join(path, name) + if goos == "windows" { + if _, err := os.Stat(srcPath); os.IsNotExist(err) { + srcPath = srcPath + ".exe" + } + } + if _, err := os.Stat(srcPath); err == nil { + dest := filepath.Join(bin, filepath.Base(srcPath)) + copyFile(srcPath, dest) + os.Chmod(dest, 0755) + uiInfo("linked → " + dest) + } + } + } + return deps, build +} + +func extractQuoted(s string) []string { + var results []string + for { + start := strings.Index(s, `"`) + if start == -1 { + break + } + end := strings.Index(s[start+1:], `"`) + if end == -1 { + break + } + results = append(results, s[start+1:start+1+end]) + s = s[start+1+end+1:] + } + return results +} + +func install(name string, fromSrc, fast bool) { + if installed(name) { + return + } + uiSection("installing " + name) + + url, binURL := lookup(name) + + if (fast || !fromSrc) && binURL != "" { + uiInfo("fetching prebuilt binary…") + resp, err := http.Get(binURL) + if err != nil || resp.StatusCode != 200 { + fmt.Fprintln(os.Stderr, "download failed") + os.Exit(1) + } + defer resp.Body.Close() + os.MkdirAll(bin, 0755) + dest := filepath.Join(bin, filepath.Base(binURL)) + data, _ := io.ReadAll(resp.Body) + os.WriteFile(dest, data, 0755) + uiOk(name + " installed (binary)") + return + } + + n := strings.TrimSuffix(strings.TrimSuffix(filepath.Base(url), ".git"), ".hg") + clonePath := filepath.Join(src, n) + vcs := "git" + if strings.Contains(url, "hg") { + vcs = "hg" + } + uiInfo("cloning " + url + "…") + if !run(vcs, []string{"clone", url, clonePath}, "") { + fmt.Fprintln(os.Stderr, "clone failed") + os.Exit(1) + } + + deps, buildCmds := parseMolpkg(clonePath) + + g := loadGraph() + g[name] = deps + saveGraph(g) + + order := resolveGraph(name, g) + for _, dep := range order { + if dep != name && !installed(dep) { + uiInfo("dependency: " + dep) + install(dep, fromSrc, fast) + } + } + + if len(buildCmds) == 0 { + fmt.Fprintf(os.Stderr, "no build steps for %s in %s\n", goos, n) + os.Exit(1) + } + + staged := stageBegin(name) + uiSection("building " + name) + failed := false + uiProgress("compiling", len(buildCmds), func(i int) { + parts := strings.Fields(buildCmds[i]) + if !run(parts[0], parts[1:], clonePath) { + failed = true + } + }) + if failed { + stageRollback(name) + fmt.Fprintln(os.Stderr, "build failed") + os.Exit(1) + } + linkBins(clonePath) + stageCommit(name, staged) + uiOk(name + " installed from source") +} + +func removePkg(name string) { + uiSection("removing " + name) + if hasManifest(name) { + stageRollback(name) + } + os.RemoveAll(filepath.Join(src, name)) + os.Remove(filepath.Join(bin, name)) + os.Remove(filepath.Join(bin, name+".exe")) + uiOk(name + " removed") +} + +func updatePkg(name string, fast bool) { + clonePath := filepath.Join(src, name) + if fast { + uiInfo("fast update: reinstalling binary for " + name + "…") + removePkg(name) + install(name, false, true) + return + } + uiSection("updating " + name) + if _, err := os.Stat(clonePath); os.IsNotExist(err) { + fmt.Fprintln(os.Stderr, name+" not found, install first") + os.Exit(1) + } + vcs := "git" + if _, err := os.Stat(filepath.Join(clonePath, ".hg")); err == nil { + vcs = "hg" + } + args := []string{"pull"} + if vcs == "hg" { + args = []string{"pull", "-u"} + } + if !run(vcs, args, clonePath) { + fmt.Fprintln(os.Stderr, "pull failed") + os.Exit(1) + } + _, buildCmds := parseMolpkg(clonePath) + staged := stageBegin(name) + for _, cmd := range buildCmds { + parts := strings.Fields(cmd) + if !run(parts[0], parts[1:], clonePath) { + stageRollback(name) + fmt.Fprintln(os.Stderr, "build failed: "+cmd) + os.Exit(1) + } + } + linkBins(clonePath) + stageCommit(name, staged) + uiOk(name + " updated") +} + +func syncAll() { + uiSection("syncing repos") + repos := loadRepos() + os.MkdirAll(filepath.Dir(db), 0755) + var combined strings.Builder + for i, repo := range repos { + uiInfo(fmt.Sprintf("(%d/%d) %s", i+1, len(repos), repo)) + resp, err := http.Get(repo) + if err != nil || resp.StatusCode != 200 { + uiWarn(fmt.Sprintf("skipped: %s", repo)) + continue + } + data, _ := io.ReadAll(resp.Body) + resp.Body.Close() + combined.Write(data) + combined.WriteString("\n") + } + os.WriteFile(db, []byte(combined.String()), 0644) + uiOk(fmt.Sprintf("synced %d repo(s)", len(repos))) +} + +func bootstrap(recipePath string) { + uiSection("bootstrapping BoredOS from " + recipePath) + if _, err := os.Stat(recipePath); os.IsNotExist(err) { + fmt.Fprintln(os.Stderr, "recipe not found: "+recipePath) + os.Exit(1) + } + deps, buildCmds := parseMolpkg(filepath.Dir(recipePath)) + uiInfo("installing bootstrap deps…") + for _, d := range deps { + install(d, false, false) + } + uiInfo("running bootstrap build steps…") + for _, cmd := range buildCmds { + parts := strings.Fields(cmd) + uiDim("$ " + cmd) + if !run(parts[0], parts[1:], "") { + fmt.Fprintln(os.Stderr, "bootstrap step failed: "+cmd) + os.Exit(1) + } + } + uiOk("bootstrap complete") +} + +func contains(args []string, flag string) bool { + for _, a := range args { + if a == flag { + return true + } + } + return false +} + +func main() { + os.MkdirAll(src, 0755) + os.MkdirAll(stage, 0755) + os.MkdirAll(manifestDir, 0755) + os.MkdirAll(filepath.Dir(db), 0755) + + args := os.Args[1:] + cmd := "" + if len(args) > 0 { + cmd = args[0] + } + + switch cmd { + case "install": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: mol install [--source] [-f]") + os.Exit(1) + } + install(args[1], contains(args, "--source"), contains(args, "-f")) + + case "remove": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: mol remove ") + os.Exit(1) + } + removePkg(args[1]) + + case "update": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: mol update [-f]") + os.Exit(1) + } + updatePkg(args[1], contains(args, "-f")) + + case "sync": + syncAll() + + case "repo": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: mol repo ") + os.Exit(1) + } + switch args[1] { + case "add": + if len(args) < 3 { + fmt.Fprintln(os.Stderr, "usage: mol repo add ") + os.Exit(1) + } + addRepo(args[2]) + case "list": + for i, r := range loadRepos() { + fmt.Printf(" %d. %s\n", i+1, r) + } + } + + case "bootstrap": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: mol bootstrap ") + os.Exit(1) + } + bootstrap(args[1]) + + case "-l": + if _, err := os.Stat(db); os.IsNotExist(err) { + fmt.Fprintln(os.Stderr, "no repo — run: mol sync") + os.Exit(1) + } + f, _ := os.Open(db) + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + l := strings.TrimSpace(scanner.Text()) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + fmt.Println(strings.Fields(l)[0]) + } + + case "--version", "-v": + uiHeader() + fmt.Printf(" OS : %s\n", goos) + fmt.Printf(" arch : %s\n", runtime.GOARCH) + + default: + uiHeader() + fmt.Printf(` + %sUsage:%s mol [options] + + %sPackage commands:%s + install [--source] [-f] install a package + remove remove a package + update [-f] pull, rebuild, re-link + sync fetch all configured repos + + %sRepo commands:%s + repo add register an external repo + repo list show configured repos + + %sBoredOS tools:%s + bootstrap build BoredOS from a host recipe + + %sInfo:%s + -l list available packages + --version show version and platform info + +`, bold, reset, cyan, reset, cyan, reset, cyan, reset, cyan, reset) + } +} \ No newline at end of file