This commit is contained in:
chersbobers 2026-05-11 19:35:58 +12:00
parent 5f4ad63266
commit fb9f151979
4 changed files with 661 additions and 2 deletions

View file

@ -1 +1 @@
crystal build src/mol.cr -o mol.exe --release
go build -o mol.exe mol.go

View file

@ -1,2 +1,2 @@
#!/bin/sh
crystal build src/mol.cr -o mol --release
go build -o mol.exe mol.go

BIN
mol.exe

Binary file not shown.

659
mol.go Normal file
View file

@ -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 <name> [--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 <name>")
os.Exit(1)
}
removePkg(args[1])
case "update":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "usage: mol update <name> [-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 <add|list>")
os.Exit(1)
}
switch args[1] {
case "add":
if len(args) < 3 {
fmt.Fprintln(os.Stderr, "usage: mol repo add <url>")
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 <mol.pkg path>")
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 <command> [options]
%sPackage commands:%s
install <name> [--source] [-f] install a package
remove <name> remove a package
update <name> [-f] pull, rebuild, re-link
sync fetch all configured repos
%sRepo commands:%s
repo add <url> register an external repo
repo list show configured repos
%sBoredOS tools:%s
bootstrap <mol.pkg> 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)
}
}