This commit is contained in:
cellarspoon 2022-02-01 00:08:55 +01:00 committed by decentral1se
commit 1190775e94
No known key found for this signature in database
GPG Key ID: 03789458B3D0C410
6 changed files with 748 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
test

41
README.md Normal file
View File

@ -0,0 +1,41 @@
# distribusi-go
> This is still very experimental, please take a backup of your archives if you
> are running it on files you care about. It hasn't been tested on large
> archives. It may still thrash files. Please [report issues] as you find them
> :100:
A [Go] implementation of [distribusi].
This is a compiled `distribusi` which is simpler to install on your computer,
just download the binary, `chmod +x` and run it.
The command-line interface is quite different from the Python version. There
are less optional flags and more defaults. I shuffled a number of things around
according to my preferences. For example, I always like my images to be
thumbnail'd. There's a handy web server built-in now, just run with `-s`
:metal:
There is no need to install [Pillow] for handling images, that is now built-in.
The only external dependency is [exiftool] for image captions from embedded
metadata. If you don't have it `exiftool` installed, then it gracefully skips
that feature. So, you don't need to install anything else to run `distribusi`
now :pray:
## Install
```
curl https://git.vvvvvvaria.org/decentral1se/distribusi-go/raw/branch/main/distribusi -o distribusi
chmod +x distribusi
./distribusi
```
## Hacking
You'll need [Go] >= 1.13 installed. Run `go build .` to build a new `./distribusi` executable.
[Go]: https://go.dev
[distribusi]: https://git.vvvvvvaria.org/varia/distribusi
[Pillow]: https://pillow.readthedocs.io/en/stable/installation.html#external-libraries
[exiftool]: https://exiftool.org/
[report issues]: https://git.vvvvvvaria.org/decentral1se/distribusi-go/issues

BIN
distribusi Executable file

Binary file not shown.

640
distribusi.go Normal file
View File

@ -0,0 +1,640 @@
// Package main is the command-line entrypoint for the distribusi command.
package main
import (
"bytes"
"encoding/base64"
"fmt"
"image/png"
"io/fs"
"io/ioutil"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"sort"
"strings"
logrusStack "github.com/Gurpartap/logrus-stack"
"github.com/barasher/go-exiftool"
"github.com/disintegration/imaging"
"github.com/gabriel-vasile/mimetype"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
// exiftooInstalled tells us if the exiftool binary is installed or not.
var exiftoolInstalled = true
// generatedInDistribusi is an internal marker to help recognise when
// distribusi-go has generated files.
var generatedInDistribusi = "<meta name='generator' content='distribusi-go' />"
// htmlBody is the template for the index.html files that are generated by distribusi-go.
var htmlBody = `
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Generated with distribusi-go https://git.vvvvvvaria.org/decentral1se/distribusi-go -->
%s
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<style>
.image {
max-width: 100%%;
}
.pdf {
width: 640px;
height: 640px;
}
.os::before {
content: "📁 ";
font-size: 18px;
}
.filename {
display: block;
font-family: mono;
}
.gif {
width: 450px;
max-height: 450px;
}
div {
display: inline-block;
vertical-align: top;
margin: 1em;
padding: 1em;
}
video {
width: 450px;
max-height: 450px;
}
%s
</style>
</head>
<body>
%s
</body>
</html
`
// main is the command-line entrypoint.
func main() {
app := &cli.App{
Name: "distribusi-go",
Version: "0.1.0-alpha",
Usage: "low-tech content management system for the web",
Description: `
Distribusi is a content management system for the web that produces static
index pages based on folders in the files system. It is inspired by the
automatic index functions featured in several popular web servers. Distribusi
works by traversing the file system and directory hierarchy to automatically
list all the files in the directory, detect the file types and providing them
with relevant html classes and tags for easy styling.
Example:
distribusi -p myarchive -c styles.css -i .git -s
`,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "css",
Aliases: []string{"c"},
Usage: "css file for custom styles",
Value: "",
},
&cli.BoolFlag{
Name: "debug",
Aliases: []string{"d"},
Usage: "debug logging",
Value: false,
},
&cli.BoolFlag{
Name: "hidden",
Aliases: []string{"n"},
Usage: "include hidden directories (e.g. \".git\")",
Value: false,
},
&cli.StringFlag{
Name: "ignore",
Aliases: []string{"i"},
Usage: "ignore specific paths (e.g. \"mydir, myfile.txt\" )",
Value: "",
},
&cli.StringFlag{
Name: "path",
Aliases: []string{"p"},
Usage: "path to distribusify",
Required: true,
},
&cli.BoolFlag{
Name: "serve",
Aliases: []string{"s"},
Usage: "serve distribusi for the web",
Value: false,
},
&cli.BoolFlag{
Name: "wipe",
Aliases: []string{"w"},
Usage: "remove all generated files",
Value: false,
},
},
Authors: []*cli.Author{
{
Name: "decentral1se",
Email: "cellarspoon@riseup.net",
},
},
Before: func(c *cli.Context) error {
if c.Bool("debug") {
logrus.SetLevel(logrus.DebugLevel)
logrus.SetFormatter(&logrus.TextFormatter{})
logrus.SetOutput(os.Stderr)
logrus.AddHook(logrusStack.StandardHook())
}
return nil
},
Action: func(c *cli.Context) error {
root, err := filepath.Abs(c.String("path"))
if err != nil {
logrus.Fatal(err)
}
var ignore []string
if c.String("ignore") != "" {
ignore = strings.Split(c.String("ignore"), ",")
for i := range ignore {
ignore[i] = strings.TrimSpace(ignore[i])
}
logrus.Debugf("parsed %s as ignore patterns", strings.Join(ignore, " "))
}
if c.Bool("wipe") {
if err := wipeGeneratedFiles(root); err != nil {
logrus.Fatal(err)
}
logrus.Infof("wiped all generated files in %s", root)
return nil
}
_, err = exec.LookPath("exiftool")
if err != nil {
logrus.Warn("exiftool is not installed, skipping image captions")
exiftoolInstalled = false
}
logrus.Debugf("selecting %s as distribusi root", root)
if err := distribusify(c, root, ignore); err != nil {
logrus.Fatal(err)
}
if c.Bool("serve") {
if err := serveHTTP(root); err != nil {
logrus.Fatal(err)
}
}
return nil
},
}
sort.Sort(cli.FlagsByName(app.Flags))
sort.Sort(cli.CommandsByName(app.Commands))
if err := app.Run(os.Args); err != nil {
logrus.Fatal(err)
}
}
// distribusify runs the main distribusi generation logic.
func distribusify(c *cli.Context, root string, ignore []string) error {
var allDirs []string
if err := filepath.Walk(root, func(fpath string, finfo os.FileInfo, err error) error {
if skip := shouldSkip(c, fpath, ignore); skip {
return filepath.SkipDir
}
var html []string
if finfo.IsDir() {
var dirs []string
var files []string
absPath, err := filepath.Abs(fpath)
if err != nil {
return err
}
contents, err := ioutil.ReadDir(absPath)
if err != nil {
if strings.Contains(err.Error(), "permission denied") {
return filepath.SkipDir
} else {
return err
}
}
for _, content := range contents {
if content.IsDir() {
dirs = append(dirs, path.Join(absPath, content.Name()))
} else {
if content.Name() == "index.html" {
file, err := os.ReadFile(path.Join(absPath, content.Name()))
if err != nil {
return err
}
if strings.Contains(string(file), generatedInDistribusi) {
continue
}
}
files = append(files, path.Join(absPath, content.Name()))
}
}
if len(dirs) == 0 && len(files) == 0 {
return nil
}
if root != fpath {
href := "<a href='../'>../</a>"
div, err := mkDiv(c, "os/directory", href, "menu", false)
if err != nil {
return err
}
html = append(html, div)
}
for _, dir := range dirs {
fname := filepath.Base(dir)
if skip := shouldSkip(c, dir, ignore); skip {
continue
}
mtype := "os/directory"
href := fmt.Sprintf("<a href='%s'>%s/</a>", fname, fname)
div, err := mkDiv(c, mtype, href, fname, false)
if err != nil {
return err
}
html = append(html, div)
}
for _, file := range files {
fname := filepath.Base(file)
if skip := shouldSkip(c, file, ignore); skip {
continue
}
mtype, err := getMtype(file)
if err != nil {
return err
}
unknown, href, err := getHref(c, file, mtype)
if err != nil {
return err
}
div, err := mkDiv(c, mtype, href, fname, unknown)
if err != nil {
return err
}
html = append(html, div)
}
if err := writeIndex(absPath, html, c.String("css")); err != nil {
return err
}
allDirs = append(allDirs, strings.Join(dirs, " "))
}
return nil
}); err != nil {
return err
}
logrus.Debugf("generated files in following paths: %s", strings.Join(allDirs, " "))
return nil
}
// removeIndex safely removes an index.html, making sure to check that
// distribusi generated it.
func removeIndex(fpath string) error {
file, err := os.ReadFile(fpath)
if err != nil {
return err
}
if strings.Contains(string(file), generatedInDistribusi) {
if err := os.Remove(fpath); err != nil {
return err
}
return nil
}
return nil
}
// getMtype determines file mimetype which helps to arrange HTML tags
// appropriate for the file type.
func getMtype(fpath string) (string, error) {
mtype, err := mimetype.DetectFile(fpath)
if err != nil {
return "", err
}
return mtype.String(), nil
}
// genThumb generates an in-memory image thumbnail and encodes it into a base64
// representation which is suitable for embedding in HTML pages.
func genThumb(c *cli.Context, fpath, caption string) (string, error) {
knownFailureExts := []string{".ico", ".svg", ".xcf"}
if sliceContains(knownFailureExts, filepath.Ext(fpath)) {
return "", nil
}
imgSrc, err := imaging.Open(fpath, imaging.AutoOrientation(true))
if err != nil {
logrus.Debugf("failed to generate thumbnail for %s", fpath)
return "", err
}
img := imaging.Thumbnail(imgSrc, 450, 450, imaging.Lanczos)
buf := new(bytes.Buffer)
png.Encode(buf, img)
imgBase64Str := base64.StdEncoding.EncodeToString(buf.Bytes())
return imgBase64Str, nil
}
// getCaption retrieves an embedded image caption via exif-tool. If not
// exiftool is installed, we gracefully bail out. The caller is responsible for
// handling the alternative.
func getCaption(c *cli.Context, fpath string) (string, error) {
var caption string
if !exiftoolInstalled {
return "", nil
}
exif, err := exiftool.NewExiftool()
if err != nil {
return caption, fmt.Errorf("failed to initialise exiftool, saw %v", err)
}
defer exif.Close()
for _, finfo := range exif.ExtractMetadata(fpath) {
if finfo.Err != nil {
continue
}
for k, v := range finfo.Fields {
if k == "Comment" {
caption = fmt.Sprintf("%v", v)
}
}
}
return caption, nil
}
// parseMtype parses a mimetype string to simplify programmatic type lookups.
func parseMtype(mtype string) (string, string) {
stripCharset := strings.Split(mtype, ";")
splitTypes := strings.Split(stripCharset[0], "/")
ftype, stype := splitTypes[0], splitTypes[1]
return ftype, stype
}
// sliceContains checks if an element is present in a list.
func sliceContains(items []string, target string) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
// trimFinalNewline trims newlines from the end of bytes just read from files.
func trimFinalNewline(contents []byte) string {
return strings.TrimSuffix(string(contents), "\n")
}
// getHref figures out which href tag corresponds to which file by navigating
// the mimetype. If a type of file is unknown, this is signalled via the bool
// return value.
func getHref(c *cli.Context, fpath string, mtype string) (bool, string, error) {
var href string
var caption string
var unknown bool
fname := filepath.Base(fpath)
ftype, stype := parseMtype(mtype)
if ftype == "text" {
fcontents, err := os.ReadFile(fpath)
if err != nil {
return unknown, href, err
}
if stype == "html" {
href = fmt.Sprintf("<section id=\"%s\">%s</section>", fname, trimFinalNewline(fcontents))
} else {
href = fmt.Sprintf("<pre>%s</pre>", trimFinalNewline(fcontents))
}
} else if ftype == "image" {
caption = ""
exifCaption, err := getCaption(c, fpath)
if err != nil {
return unknown, href, nil
}
if exifCaption != "" {
caption = exifCaption
}
if stype == "gif" {
href = fmt.Sprintf("<img class='gif' src=\"%s\" />", fname)
} else {
thumb, err := genThumb(c, fpath, caption)
if err != nil {
unknown = true
href = fmt.Sprintf("<a class='%s' href=\"%s\">%s</a>", stype, fname, fname)
} else {
href = fmt.Sprintf(
"<figure><a href=\"%s\"><img class='thumbnail' src='data:image/jpg;base64,%s'></a><figcaption>%s</figcaption></figure>",
fname, thumb, caption,
)
}
}
} else if ftype == "application" {
if stype == "pdf" {
href = fmt.Sprintf("<object data=\"%s\" class='pdf' type='application/pdf'><embed src=\"%s\" type='application/pdf'/></object>", fname, fname)
} else {
unknown = true
href = fmt.Sprintf("<a class='%s' href=\"%s\">%s</a>", stype, fname, fname)
}
} else if ftype == "audio" {
href = fmt.Sprintf("<audio controls> <source src=\"%s\" type='audio/%s'> Your browser does not support the audio element. </audio>", fname, stype)
} else if ftype == "video" {
href = fmt.Sprintf("<video controls> <source src=\"%s\" type='video/%s'> </video>", fname, stype)
} else {
unknown = true
href = fmt.Sprintf("<a class='%s' href=\"%s\">%s</a>", stype, fname, fname)
}
return unknown, href, nil
}
// mkDiv cosntructs a HTML div for inclusion in the generated index.html.
func mkDiv(c *cli.Context, mtype string, href, fname string, unknown bool) (string, error) {
var div string
filename := fmt.Sprintf("<span class='filename'>%s</span>", fname)
ftype, _ := parseMtype(mtype)
if ftype == "text" {
div = fmt.Sprintf("<div id=\"%s\" class='%s'>%s%s</div>", fname, ftype, href, filename)
} else if ftype == "os" {
div = fmt.Sprintf("<div id=\"%s\" class='%s'>%s</div>", fname, ftype, href)
} else {
if unknown {
// we really don't know what this is, so the filename is the href and we avoid adding it again
div = fmt.Sprintf("<div id=\"%s\" class='%s'>%s</div>", fname, ftype, href)
} else {
// images, videos, etc. still get a filename
div = fmt.Sprintf("<div id=\"%s\" class='%s'>%s%s</div>", fname, ftype, href, filename)
}
}
return div, nil
}
// writeIndex writes a new index.html.
func writeIndex(fpath string, html []string, styles string) error {
body := fmt.Sprintf(htmlBody, generatedInDistribusi, "", strings.Join(html, "\n"))
HTMLPath := path.Join(fpath, "index.html")
contents := []byte(body)
if styles != "" {
absPath, err := filepath.Abs(styles)
if err != nil {
return err
}
if _, err := os.Stat(absPath); !os.IsNotExist(err) {
contents, err := os.ReadFile(absPath)
if err != nil {
return err
}
body = fmt.Sprintf(htmlBody, generatedInDistribusi, contents, strings.Join(html, "\n"))
}
}
if _, err := os.Stat(HTMLPath); err != nil {
if os.IsNotExist(err) {
if err := ioutil.WriteFile(HTMLPath, contents, 0644); err != nil {
return err
}
} else {
return err
}
} else {
file, err := os.ReadFile(HTMLPath)
if err != nil {
return err
}
if strings.Contains(string(file), generatedInDistribusi) {
if err := ioutil.WriteFile(HTMLPath, contents, 0644); err != nil {
return err
}
}
}
return nil
}
// serveHTTP serves a web server for browsing distribusi.
func serveHTTP(fpath string) error {
fs := http.FileServer(http.Dir(fpath))
http.Handle("/", fs)
logrus.Info("distribusi live @ http://localhost:3000")
if err := http.ListenAndServe(":3000", nil); err != nil {
return err
}
return nil
}
// wipeGeneratedFiles removes all distribusi generated files.
func wipeGeneratedFiles(dir string) error {
if err := filepath.WalkDir(dir, func(fpath string, dirEntry fs.DirEntry, err error) error {
fname := filepath.Base(fpath)
if fname == "index.html" {
if err := removeIndex(fpath); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
return nil
}
// shouldSkip checks if paths should be skipped over.
func shouldSkip(c *cli.Context, fpath string, ignore []string) bool {
if sliceContains(ignore, filepath.Base(fpath)) {
return true
}
fpaths := strings.Split(fpath, "/")
for _, part := range fpaths {
if strings.HasPrefix(part, ".") {
if !c.Bool("hidden") {
return true
}
}
}
return false
}

22
go.mod Normal file
View File

@ -0,0 +1,22 @@
module varia.zone/distribusi
go 1.17
require (
github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4
github.com/barasher/go-exiftool v1.7.0
github.com/disintegration/imaging v1.6.2
github.com/gabriel-vasile/mimetype v1.4.0
github.com/sirupsen/logrus v1.8.1
github.com/urfave/cli/v2 v2.3.0
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125 // indirect
golang.org/x/sys v0.0.0-20210423082822-04245dca01da // indirect
)

44
go.sum Normal file
View File

@ -0,0 +1,44 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4 h1:vdT7QwBhJJEVNFMBNhRSFDRCB6O16T28VhvqRgqFyn8=
github.com/Gurpartap/logrus-stack v0.0.0-20170710170904-89c00d8a28f4/go.mod h1:SvXOG8ElV28oAiG9zv91SDe5+9PfIr7PPccpr8YyXNs=
github.com/barasher/go-exiftool v1.7.0 h1:EOGb5D6TpWXmqsnEjJ0ai6+tIW2gZFwIoS9O/33Nixs=
github.com/barasher/go-exiftool v1.7.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125 h1:Ugb8sMTWuWRC3+sz5WeN/4kejDx9BvIwnPUiJBjJE+8=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=