From dcb2e52e7bff5db73314c210804a11e409bd57cf Mon Sep 17 00:00:00 2001 From: Jordan Orelli Date: Sat, 1 Jan 2022 18:19:54 -0600 Subject: [PATCH] get next version plz --- go.mod | 1 + go.sum | 2 + main.go | 37 ++++++++-- next.go | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 next.go diff --git a/go.mod b/go.mod index bee369a..c534d80 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,6 @@ require golang.org/x/mod v0.5.1 require ( golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect + golang.org/x/net v0.0.0-20211216030914-fe4d6282115f // indirect golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 // indirect ) diff --git a/go.sum b/go.sum index f47271c..207a0df 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+Wr golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/net v0.0.0-20211216030914-fe4d6282115f h1:hEYJvxw1lSnWIl8X9ofsYMklzaDs90JI2az5YMd4fPM= +golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e h1:aZzprAO9/8oim3qStq3wc1Xuxx4QmAGriC4VU4ojemQ= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbOeHJjicWYPqR9bpxqxYG2pA= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index 641422f..5313513 100644 --- a/main.go +++ b/main.go @@ -5,13 +5,17 @@ import ( _ "embed" "flag" "fmt" + "io" "log" "os" "os/signal" ) -var log_error = log.New(os.Stderr, "", 0) -var log_info = log.New(os.Stdout, "", 0) +var ( + log_error = log.New(os.Stderr, "", 0) + log_info = log.New(os.Stdout, "", 0) + log_debug = log.New(io.Discard, "", 0) +) func bail(status int, t string, args ...interface{}) { if status != 0 { @@ -39,17 +43,40 @@ func sigCancel(ctx context.Context) context.Context { } func main() { + var ( + quiet bool + verbose bool + ) + sigCancel(context.Background()) root := flag.NewFlagSet("", flag.ExitOnError) + root.BoolVar(&quiet, "q", false, "suppress non-error output") + root.BoolVar(&verbose, "v", false, "show additional debug output") root.Parse(os.Args[1:]) + if quiet { + log_info = log.New(io.Discard, "", 0) + } + if !quiet && verbose { + log_debug = log.New(os.Stdout, "", 0) + } + + rest := root.Args()[1:] + switch root.Arg(0) { case "serve": - serve(root.Args()[1:]) + serve(rest) case "zip": - zipcmd(root.Args()[1:]) + zipcmd(rest) case "pwhash": - pwhashcmd(root.Args()[1:]) + pwhashcmd(rest) + case "next": + nextcmd(rest) + // mir next major + // mir next minor + // mir next patch + // mir next pre fartstorm + default: bail(0, usage) } diff --git a/next.go b/next.go new file mode 100644 index 0000000..f477eaf --- /dev/null +++ b/next.go @@ -0,0 +1,210 @@ +package main + +import ( + "bufio" + "errors" + "flag" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "path" + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/semver" + "golang.org/x/net/html" +) + +func nextcmd(args []string) { + flags := flag.NewFlagSet("next", flag.ExitOnError) + flags.Parse(args) + + switch flags.Arg(0) { + case "major", "minor", "patch": + default: + bail(1, "major|minor|patch only for now") + } + + log_debug.Printf("reading module file go.mod") + b, err := ioutil.ReadFile("go.mod") + if err != nil { + bail(1, "unable to read modfile: %v", err) + } + + log_debug.Printf("parsing module file go.mod") + f, err := modfile.Parse("go.mod", b, nil) + if err != nil { + bail(1, "unable to parse modfile: %v", err) + } + + modpath := f.Module.Mod.Path + log_debug.Printf("parsed module path: %s", modpath) + + u, err := url.Parse(f.Module.Mod.Path) + if err != nil { + bail(1, "module path %s is not a valid url: %v", err) + } + u.Scheme = "https" + + log_debug.Printf("GET %v", u) + res, err := http.Get(u.String()) + if err != nil { + bail(1, "unable to fetch module root page: %v", err) + } + defer res.Body.Close() + + m, err := parseModPage(res.Body) + if err != nil { + bail(1, "unable to parse module meta page: %v", err) + } + + lines, err := m.fetchVersionList() + if err != nil { + bail(1, "unable to fetch version list: %v", err) + } + log_debug.Printf("%s", lines) +} + +// parseModPage parses the page at a module path, looking for the appropriate +// meta tags defined by the Go module ecosystem and by mir itself +func parseModPage(r io.Reader) (*modmeta, error) { + root, err := html.Parse(r) + if err != nil { + return nil, fmt.Errorf("invalid html: %v", err) + } + + var meta modmeta + if err := meta.parseTree(root); err != nil { + return nil, fmt.Errorf("parse failed: %v", err) + } + return &meta, nil +} + +type modmeta struct { + path string // module path + backend string // git | hg | mod + dlRoot url.URL // download URL +} + +// parseTree parses an HTML tree, evaluating each node and then descending to +// its children +func (m *modmeta) parseTree(n *html.Node) error { + m.parseNode(n) + + for c := n.FirstChild; c != nil; c = c.NextSibling { + if err := m.parseTree(c); err != nil { + return err + } + } + return nil +} + +// attr finds the first value for a given attribute in an html attribute list +func attr(key string, attrs []html.Attribute) string { + for _, a := range attrs { + if a.Key == key { + return a.Val + } + } + return "" +} + +// parseNode parses a single node in an html tree, without looking at its +// descendants +func (m *modmeta) parseNode(n *html.Node) error { + if n.Type != html.ElementNode { + return nil + } + if n.Data != "meta" { + return nil + } + + if attr("name", n.Attr) == "go-import" { + content := attr("content", n.Attr) + if content == "" { + return fmt.Errorf("go-import meta tag is missing content") + } + + parts := strings.Fields(content) + if len(parts) != 3 { + return fmt.Errorf("go import meta tag has invalid content (not 3 parts): %q", content) + } + + m.path = parts[0] + m.backend = parts[1] + u, err := url.Parse(parts[2]) + if err != nil { + return fmt.Errorf("go import meta tag has invalid download url: %v", err) + } + m.dlRoot = *u + } + + return nil +} + +func (m *modmeta) listEndpoint() (*url.URL, error) { + var empty url.URL + if m.dlRoot == empty { + return nil, fmt.Errorf("dl root is empty") + } + + u := url.URL{ + Scheme: "https", + Host: m.dlRoot.Host, + Path: path.Join(m.dlRoot.Path, m.path, "@v", "list"), + } + return &u, nil +} + +func (m *modmeta) fetchVersionList() ([]string, error) { + u, err := m.listEndpoint() + if err != nil { + return nil, fmt.Errorf("unable to locate version list: %w", err) + } + + log_debug.Printf("GET %s", u) + res, err := http.Get(u.String()) + if err != nil { + return nil, fmt.Errorf("unable to fetch version list: %w", err) + } + defer res.Body.Close() + + lines, err := parseVersionLines(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to parse version list: %w", err) + } + semver.Sort(lines) + return lines, nil +} + +func parseVersionLines(r io.Reader) ([]string, error) { + lines := make([]string, 0, 8) + keep := func(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + + if !semver.IsValid(s) { + return fmt.Errorf("invalid version string: %s", s) + } + lines = append(lines, s) + return nil + } + + br := bufio.NewReader(r) + for { + line, err := br.ReadString('\n') + if err != nil { + if errors.Is(err, io.EOF) { + return lines, keep(line) + } + return nil, fmt.Errorf("error reading version list response: %w", err) + } + if err := keep(line); err != nil { + return nil, fmt.Errorf("bad version list: %w", err) + } + } +}