~mariusor/motley

76211cd60c1aeaffb1d140221cb96a2adc8601c6 — Marius Orcsik 9 months ago
Moved TUI interface for FedBOX to its separate probject
A  => .gitignore +3 -0
@@ 1,3 @@
bin/
.idea/
go.sum

A  => Makefile +64 -0
@@ 1,64 @@
SHELL := bash
.ONESHELL:
.SHELLFLAGS := -eu -o pipefail -c
.DELETE_ON_ERROR:

ENV ?= dev
LDFLAGS ?= -X main.version=$(VERSION)
BUILDFLAGS ?= -trimpath -a -ldflags '$(LDFLAGS)'
TEST_FLAGS ?= -count=1
MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules

M4 = /usr/bin/m4
M4_FLAGS =

GO := go
APPSOURCES := $(wildcard *.go internal/*/*.go cmd/*.go)
PROJECT_NAME := $(shell basename $(PWD))
TAGS := $(ENV)

export CGO_ENABLED=0

ifneq ($(ENV), dev)
	LDFLAGS += -s -w -extldflags "-static"
endif

ifeq ($(VERSION), )
	ifeq ($(shell git describe --always > /dev/null 2>&1 ; echo $$?), 0)
		BRANCH=$(shell git rev-parse --abbrev-ref HEAD | tr '/' '-')
		HASH=$(shell git rev-parse --short HEAD)
		VERSION ?= $(shell printf "%s-%s" "$(BRANCH)" "$(HASH)")
	endif
	ifeq ($(shell git describe --tags > /dev/null 2>&1 ; echo $$?), 0)
		VERSION ?= $(shell git describe --tags | tr '/' '-')
	endif
endif

BUILD := $(GO) build $(BUILDFLAGS)
TEST := $(GO) test $(BUILDFLAGS)

.PHONY: all run clean test coverage install

all: motley

motley: bin/motley
bin/motley: go.mod cmd/main.go $(APPSOURCES)
	$(BUILD) -tags "$(TAGS)" -o $@ ./cmd/

run: motley
	@./bin/motely

clean:
	-$(RM) bin/*

test: TEST_TARGET := ./...
test:
	$(TEST) $(TEST_FLAGS) -tags "$(TAGS)" $(TEST_TARGET)

coverage: TEST_TARGET := .
coverage: TEST_FLAGS += -covermode=count -coverprofile $(PROJECT_NAME).coverprofile
coverage: test

install: bin/motley
	install bin/motley $(DESTDIR)$(INSTALL_PREFIX)/bin

A  => cmd/control.go +81 -0
@@ 1,81 @@
package main

import (
	tui "git.sr.ht/~marius/motley"
	"git.sr.ht/~marius/motley/internal/config"
	"git.sr.ht/~marius/motley/internal/env"
	pub "github.com/go-ap/activitypub"
	"github.com/go-ap/storage"
	"github.com/openshift/osin"
	"github.com/sirupsen/logrus"
	"gopkg.in/urfave/cli.v2"
	"os"
	"time"
)

var (
	ctl Control
	logger = logrus.New()
)

type Control struct {
	Conf        config.Options
	AuthStorage osin.Storage
	Storage     storage.Repository
}

func New(authDB osin.Storage, actorDb storage.Repository, conf config.Options) *Control {
	return &Control{
		Conf:        conf,
		AuthStorage: authDB,
		Storage:     actorDb,
	}
}

func Before(c *cli.Context) error {
	logger.Level = logrus.WarnLevel
	ct, err := setup(c, logger)
	if err != nil {
		// Ensure we don't print the default help message, which is not useful here
		c.App.CustomAppHelpTemplate = "Failed"
		logger.Errorf("Error: %s", err)
		return err
	}
	ctl = *ct
	// the level enums have same values
	logger.Level = logrus.TraceLevel

	return nil
}

func setup(c *cli.Context, l logrus.FieldLogger) (*Control, error) {
	dir := c.String("dir")
	if dir == "" {
		dir = "."
	}
	environ := env.Type(c.String("env"))
	if environ == "" {
		environ = env.DEV
	}
	conf, err := config.LoadFromEnv(environ, time.Second)
	if err != nil {
		l.Errorf("Unable to load config files for environment %s: %s", environ, err)
	}
	if dir == "." && conf.StoragePath != os.TempDir() {
		dir = conf.StoragePath
	}
	typ := c.String("type")
	if typ != "" {
		conf.Storage = config.StorageType(typ)
	}
	db, aDb, err := Storage(conf, l)
	if err != nil {
		return nil, err
	}
	return New(aDb, db, conf), nil
}

var TuiAction = func(*cli.Context) error {
	return tui.Launch(pub.IRI(ctl.Conf.BaseURL), ctl.Storage)
}


A  => cmd/main.go +49 -0
@@ 1,49 @@
package main

import (
	"fmt"
	"git.sr.ht/~marius/motley/internal/config"
	"git.sr.ht/~marius/motley/internal/env"
	"gopkg.in/urfave/cli.v2"
	"os"
)

var version = "HEAD"

func main() {
	app := cli.App{}
	app.Name = "motley"
	app.Usage = "helper utility to manage a FedBOX instance"
	app.Version = version
	app.Before = Before
	app.Flags = []cli.Flag{
		&cli.StringFlag{
			Name:  "url",
			Usage: "The url used by the application (REQUIRED)",
		},
		&cli.StringFlag{
			Name:  "env",
			Usage: fmt.Sprintf("The environment to use. Possible values: %q", []env.Type{env.DEV, env.QA, env.PROD}),
			Value: string(env.DEV),
		},
		&cli.StringFlag{
			Name:  "type",
			Usage: fmt.Sprintf("Type of the backend to use. Possible values: %q", []config.StorageType{config.StorageBoltDB, config.StorageBadger, config.StorageFS}),
		},
		&cli.StringFlag{
			Name:  "path",
			Value: ".",
			Usage: fmt.Sprintf("The path for the storage folder orsocket"),
		},
		&cli.StringFlag{
			Name:  "user",
			Value: "fedbox",
			Usage: "The postgres database user",
		},
	}
	app.Action = TuiAction

	if err := app.Run(os.Args); err != nil {
		os.Exit(1)
	}
}

A  => cmd/storage_all.go +133 -0
@@ 1,133 @@
package main

import (
	"git.sr.ht/~marius/motley/internal/config"
	authbadger "github.com/go-ap/auth/badger"
	authboltdb "github.com/go-ap/auth/boltdb"
	authfs "github.com/go-ap/auth/fs"
	authpgx "github.com/go-ap/auth/pgx"
	"github.com/go-ap/errors"
	"github.com/go-ap/fedbox/storage/badger"
	"github.com/go-ap/fedbox/storage/boltdb"
	"github.com/go-ap/fedbox/storage/fs"
	"github.com/go-ap/fedbox/storage/pgx"
	"github.com/go-ap/fedbox/storage/sqlite"
	st "github.com/go-ap/storage"
	"github.com/openshift/osin"
	"github.com/sirupsen/logrus"
)

var (
	emptyFieldsLogFn = func(logrus.Fields, string, ...interface{}) {}
	emptyLogFn       = func(string, ...interface{}) {}
	InfoLogFn        = func(l logrus.FieldLogger) func(logrus.Fields, string, ...interface{}) {
		if l == nil {
			return emptyFieldsLogFn
		}
		return func(f logrus.Fields, s string, p ...interface{}) { l.WithFields(f).Infof(s, p...) }
	}
	ErrLogFn = func(l logrus.FieldLogger) func(logrus.Fields, string, ...interface{}) {
		if l == nil {
			return emptyFieldsLogFn
		}
		return func(f logrus.Fields, s string, p ...interface{}) { l.WithFields(f).Errorf(s, p...) }
	}
)

func getBadgerStorage(c config.Options, l logrus.FieldLogger) (st.Repository, osin.Storage, error) {
	l.Debugf("Initializing badger storage at %s", c.Badger())
	db := badger.New(badger.Config{
		Path:  c.Badger(),
		LogFn: InfoLogFn(l),
		ErrFn: ErrLogFn(l),
	}, c.BaseURL)
	oauth := authbadger.New(authbadger.Config{
		Path:  c.BadgerOAuth2(),
		Host:  c.Host,
		LogFn: InfoLogFn(l),
		ErrFn: ErrLogFn(l),
	})
	return db, oauth, nil
}

func getBoltStorage(c config.Options, l logrus.FieldLogger) (st.Repository, osin.Storage, error) {
	l.Debugf("Initializing boltdb storage at %s", c.BoltDB())
	db := boltdb.New(boltdb.Config{
		Path:  c.BoltDB(),
		LogFn: InfoLogFn(l),
		ErrFn: ErrLogFn(l),
	}, c.BaseURL)

	oauth := authboltdb.New(authboltdb.Config{
		Path:       c.BoltDBOAuth2(),
		BucketName: c.Host,
		LogFn:      InfoLogFn(l),
		ErrFn:      ErrLogFn(l),
	})
	return db, oauth, nil
}

func getFsStorage(c config.Options, l logrus.FieldLogger) (st.Repository, osin.Storage, error) {
	l.Debugf("Initializing fs storage at %s", c.BaseStoragePath())
	oauth := authfs.New(authfs.Config{
		Path:  c.BaseStoragePath(),
		LogFn: InfoLogFn(l),
		ErrFn: ErrLogFn(l),
	})
	db, err := fs.New(fs.Config{
		StoragePath: c.StoragePath,
		Env:         string(c.Env),
		BaseURL:     c.BaseURL,
	})
	if err != nil {
		return nil, oauth, err
	}
	return db, oauth, nil
}

func getSqliteStorage(c config.Options, l logrus.FieldLogger) (st.Repository, osin.Storage, error) {
	l.Debugf("Initializing sqlite storage at %s", c.StoragePath)
	db, err := sqlite.New(sqlite.Config{})
	if err != nil {
		return nil, nil, err
	}
	return db, nil, errors.NotImplementedf("sqlite storage backend is not implemented yet")
}

func getPgxStorage(c config.Options, l logrus.FieldLogger) (st.Repository, osin.Storage, error) {
	// @todo(marius): we're no longer loading SQL db config env variables
	l.Debugf("Initializing pgx storage at %s", c.StoragePath)
	conf := pgx.Config{}
	db, err := pgx.New(conf, c.BaseURL, l)
	if err != nil {
		return nil, nil, err
	}

	oauth := authpgx.New(authpgx.Config{
		Enabled: true,
		Host:    conf.Host,
		Port:    int64(conf.Port),
		User:    conf.User,
		Pw:      conf.Password,
		Name:    conf.Database,
		LogFn:   InfoLogFn(l),
		ErrFn:   ErrLogFn(l),
	})
	return db, oauth, errors.NotImplementedf("sqlite storage backend is not implemented yet")
}

func Storage(c config.Options, l logrus.FieldLogger) (st.Repository, osin.Storage, error) {
	switch c.Storage {
	case config.StorageBoltDB:
		return getBoltStorage(c, l)
	case config.StorageBadger:
		return getBadgerStorage(c, l)
	case config.StoragePostgres:
		return getPgxStorage(c, l)
	case config.StorageSqlite:
		return getSqliteStorage(c, l)
	case config.StorageFS:
		return getFsStorage(c, l)
	}
	return nil, nil, errors.NotImplementedf("Invalid storage type %s", c.Storage)
}

A  => go.mod +24 -0
@@ 1,24 @@
module git.sr.ht/~marius/motley

go 1.14

require (
	github.com/charmbracelet/bubbles v0.7.6
	github.com/charmbracelet/bubbletea v0.12.2
	github.com/charmbracelet/glamour v0.2.0
	github.com/go-ap/activitypub v0.0.0-20210113095250-247f1fbf224c
	github.com/go-ap/auth v0.0.0-20210113101207-103038d69797
	github.com/go-ap/errors v0.0.0-20200702155720-f662512ba418
	github.com/go-ap/fedbox v0.0.0-20210116130525-fd9377430e3e
	github.com/go-ap/storage v0.0.0-20210113100905-747cc07ec1b1
	github.com/go-chi/chi v4.1.2+incompatible
	github.com/jackc/pgx v3.6.2+incompatible
	github.com/joho/godotenv v1.3.0
	github.com/mattn/go-runewidth v0.0.10
	github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68
	github.com/muesli/termenv v0.7.4
	github.com/openshift/osin v1.0.1
	github.com/sirupsen/logrus v1.7.0
	golang.org/x/crypto v0.0.0-20201217014255-9d1352758620
	gopkg.in/urfave/cli.v2 v2.0.0-20190806201727-b62605953717
)

A  => internal/config/config.go +192 -0
@@ 1,192 @@
package config

import (
	"fmt"
	"git.sr.ht/~marius/motley/internal/env"
	"github.com/go-ap/errors"
	"github.com/joho/godotenv"
	"github.com/sirupsen/logrus"
	"os"
	"path"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

var Prefix = "fedbox"

type BackendConfig struct {
	Enabled bool
	Host    string
	Port    int64
	User    string
	Pw      string
	Name    string
}

type Options struct {
	Env         env.Type
	LogLevel    logrus.Level
	TimeOut     time.Duration
	Secure      bool
	CertPath    string
	KeyPath     string
	Host        string
	Listen      string
	BaseURL     string
	Storage     StorageType
	StoragePath string
}

type StorageType string

const (
	KeyENV          = "ENV"
	KeyTimeOut      = "TIME_OUT"
	KeyLogLevel     = "LOG_LEVEL"
	KeyHostname     = "HOSTNAME"
	KeyHTTPS        = "HTTPS"
	KeyCertPath     = "CERT_PATH"
	KeyKeyPath      = "KEY_PATH"
	KeyListen       = "LISTEN"
	KeyDBHost       = "DB_HOST"
	KeyDBPort       = "DB_PORT"
	KeyDBName       = "DB_NAME"
	KeyDBUser       = "DB_USER"
	KeyDBPw         = "DB_PASSWORD"
	KeyStorage      = "STORAGE"
	KeyStoragePath  = "STORAGE_PATH"
	StorageBoltDB   = StorageType("boltdb")
	StorageFS       = StorageType("fs")
	StorageBadger   = StorageType("badger")
	StoragePostgres = StorageType("postgres")
	StorageSqlite   = StorageType("sqlite")
)

const defaultPerm = os.ModeDir | os.ModePerm | 0700

func (o Options) BaseStoragePath() string {
	if !filepath.IsAbs(o.StoragePath) {
		o.StoragePath, _ = filepath.Abs(o.StoragePath)
	}
	basePath := path.Clean(path.Join(o.StoragePath, string(o.Env), o.Host))
	fi, err := os.Stat(basePath)
	if err != nil && os.IsNotExist(err) {
		err = os.MkdirAll(basePath, defaultPerm)
	}
	if err != nil {
		panic(err)
	}
	fi, err = os.Stat(basePath)
	if !fi.IsDir() {
		panic(errors.NotValidf("path %s is invalid for storage", o.StoragePath))
	}
	return basePath
}

func (o Options) BoltDB() string {
	return fmt.Sprintf("%s/fedbox.bdb", o.BaseStoragePath())
}

func (o Options) BoltDBOAuth2() string {
	return fmt.Sprintf("%s/oauth.bdb", o.BaseStoragePath())
}

func (o Options) Badger() string {
	return o.BaseStoragePath()
}

func (o Options) BadgerOAuth2() string {
	return fmt.Sprintf("%s/%s/%s", o.StoragePath, o.Env, "oauth")
}

func prefKey(k string) string {
	if Prefix != "" {
		return fmt.Sprintf("%s_%s", strings.ToUpper(Prefix), k)
	}
	return k
}

func loadKeyFromEnv(name, def string) string {
	if val := os.Getenv(prefKey(name)); len(val) > 0 {
		return val
	}
	if val := os.Getenv(name); len(val) > 0 {
		return val
	}
	return def
}

func LoadFromEnv(e env.Type, timeOut time.Duration) (Options, error) {
	conf := Options{}
	if !env.ValidType(e) {
		e = env.Type(loadKeyFromEnv(KeyENV, ""))
	}
	configs := []string{
		".env",
	}
	appendIfFile := func(typ env.Type) {
		envFile := fmt.Sprintf(".env.%s", typ)
		if _, err := os.Stat(envFile); err == nil {
			configs = append(configs, envFile)
		}
	}
	if !env.ValidType(e) {
		for _, typ := range env.Types {
			appendIfFile(typ)
		}
	} else {
		appendIfFile(e)
	}
	for _, f := range configs {
		godotenv.Overload(f)
	}

	lvl := loadKeyFromEnv(KeyLogLevel, "")
	switch strings.ToLower(lvl) {
	case "trace":
		conf.LogLevel = logrus.TraceLevel
	case "debug":
		conf.LogLevel = logrus.DebugLevel
	case "warn":
		conf.LogLevel = logrus.WarnLevel
	case "error":
		conf.LogLevel = logrus.ErrorLevel
	case "info":
		fallthrough
	default:
		conf.LogLevel = logrus.InfoLevel
	}

	if !env.ValidType(e) {
		e = env.Type(loadKeyFromEnv(KeyENV, "dev"))
	}
	conf.Env = e
	if conf.Host == "" {
		conf.Host = loadKeyFromEnv(KeyHostname, conf.Host)
	}
	conf.TimeOut = timeOut
	if to, _ := time.ParseDuration(loadKeyFromEnv(KeyTimeOut, "")); to > 0 {
		conf.TimeOut = to
	}
	conf.Secure, _ = strconv.ParseBool(loadKeyFromEnv(KeyHTTPS, "false"))
	if conf.Secure {
		conf.BaseURL = fmt.Sprintf("https://%s", conf.Host)
	} else {
		conf.BaseURL = fmt.Sprintf("http://%s", conf.Host)
	}
	conf.KeyPath = loadKeyFromEnv(KeyKeyPath, "")
	conf.CertPath = loadKeyFromEnv(KeyCertPath, "")

	conf.Listen = loadKeyFromEnv(KeyListen, "")
	envStorage := loadKeyFromEnv(KeyStorage, string(StorageFS))
	conf.Storage = StorageType(strings.ToLower(envStorage))
	conf.StoragePath = loadKeyFromEnv(KeyStoragePath, "")
	if conf.StoragePath == "" {
		conf.StoragePath = os.TempDir()
	}
	conf.StoragePath = path.Clean(conf.StoragePath)

	return conf, nil
}

A  => internal/config/config_test.go +95 -0
@@ 1,95 @@
package config

import (
	"fmt"
	"git.sr.ht/~marius/motley/internal/env"
	"os"
	"strings"
	"testing"
	"time"
)

const (
	hostname = "testing.git"
	logLvl   = "panic"
	secure   = true
	listen   = "127.0.0.3:666"
	pgSQL    = "postgres"
	boltDB   = "boltdb"
	dbHost   = "127.0.0.6"
	dbPort   = 54321
	dbName   = "test"
	dbUser   = "test"
	dbPw     = "pw123+-098"
)

func TestLoadFromEnv(t *testing.T) {
	{
		t.Skipf("we're no longer loading SQL db config env variables")
		os.Setenv(KeyDBHost, dbHost)
		os.Setenv(KeyDBPort, fmt.Sprintf("%d", dbPort))
		os.Setenv(KeyDBName, dbName)
		os.Setenv(KeyDBUser, dbUser)
		os.Setenv(KeyDBPw, dbPw)

		os.Setenv(KeyHostname, hostname)
		os.Setenv(KeyLogLevel, logLvl)
		os.Setenv(KeyHTTPS, fmt.Sprintf("%t", secure))
		os.Setenv(KeyListen, listen)
		os.Setenv(KeyStorage, pgSQL)

		var baseURL = fmt.Sprintf("https://%s", hostname)
		c, err := LoadFromEnv(env.TEST, time.Second)
		if err != nil {
			t.Errorf("Error loading env: %s", err)
		}
		// @todo(marius): we're no longer loading SQL db config env variables
		db := BackendConfig{}
		if db.Host != dbHost {
			t.Errorf("Invalid loaded value for %s: %s, expected %s", KeyDBHost, db.Host, dbHost)
		}
		if db.Port != dbPort {
			t.Errorf("Invalid loaded value for %s: %d, expected %d", KeyDBPort, db.Port, dbPort)
		}
		if db.Name != dbName {
			t.Errorf("Invalid loaded value for %s: %s, expected %s", KeyDBName, db.Name, dbName)
		}
		if db.User != dbUser {
			t.Errorf("Invalid loaded value for %s: %s, expected %s", KeyDBUser, db.User, dbUser)
		}
		if db.Pw != dbPw {
			t.Errorf("Invalid loaded value for %s: %s, expected %s", KeyDBPw, db.Pw, dbPw)
		}

		if c.Host != hostname {
			t.Errorf("Invalid loaded value for %s: %s, expected %s", KeyHostname, c.Host, hostname)
		}
		if c.Secure != secure {
			t.Errorf("Invalid loaded value for %s: %t, expected %t", KeyHTTPS, c.Secure, secure)
		}
		if c.Listen != listen {
			t.Errorf("Invalid loaded value for %s: %s, expected %s", KeyListen, c.Listen, listen)
		}
		if c.Storage != pgSQL {
			t.Errorf("Invalid loaded value for %s: %s, expected %s", KeyStorage, c.Storage, pgSQL)
		}
		if c.BaseURL != baseURL {
			t.Errorf("Invalid loaded BaseURL value: %s, expected %s", c.BaseURL, baseURL)
		}
	}
	{
		os.Setenv(KeyStorage, boltDB)
		c, err := LoadFromEnv(env.TEST, time.Second)
		if err != nil {
			t.Errorf("Error loading env: %s", err)
		}
		var tmp = strings.TrimRight(os.TempDir(), "/")
		if strings.TrimRight(c.StoragePath, "/") != tmp {
			t.Errorf("Invalid loaded boltdb dir value: %s, expected %s", c.StoragePath, tmp)
		}
		var path = fmt.Sprintf("%s/%s-%s.bdb", tmp, strings.Replace(hostname, ".", "-", 1), env.TEST)
		if c.BoltDB() != path {
			t.Errorf("Invalid loaded boltdb file value: %s, expected %s", c.BoltDB(), path)
		}
	}
}

A  => internal/env/env.go +55 -0
@@ 1,55 @@
package env

import "strings"

// EnvType type alias
type Type string

// DEV environment
const DEV Type = "dev"

// PROD environment
const PROD Type = "prod"

// QA environment
const QA Type = "qa"

// testing environment
const TEST Type = "test"

var Types = []Type{
	PROD,
	QA,
	DEV,
	TEST,
}

func ValidTypeOrDev(typ Type) Type {
	if ValidType(typ) {
		return Type(typ)
	}

	return DEV
}

func ValidType(typ Type) bool {
	for _, t := range Types {
		if strings.ToLower(string(typ)) == strings.ToLower(string(t)) {
			return true
		}
	}
	return false
}

func (e Type) IsProd() bool {
	return strings.Contains(string(e), string(PROD))
}
func (e Type) IsQA() bool {
	return strings.Contains(string(e), string(QA))
}
func (e Type) IsTest() bool {
	return strings.Contains(string(e), string(TEST))
}
func (e Type) IsDev() bool {
	return strings.Contains(string(e), string(DEV))
}

A  => internal/env/env_test.go +118 -0
@@ 1,118 @@
package env

import "testing"

func TestType_IsProd(t *testing.T) {
	prod := PROD
	if !prod.IsProd() {
		t.Errorf("%T %s should have been production", prod, prod)
	}
	qa := QA
	if qa.IsProd() {
		t.Errorf("%T %s should not have been production", qa, qa)
	}
	dev := DEV
	if dev.IsProd() {
		t.Errorf("%T %s should not have been production", dev, dev)
	}
	test := TEST
	if test.IsProd() {
		t.Errorf("%T %s should not have been production", test, test)
	}
	rand := Type("Random")
	if rand.IsProd() {
		t.Errorf("%T %s should not have been production", rand, rand)
	}
}

func TestType_IsQA(t *testing.T) {
	qa := QA
	if !qa.IsQA() {
		t.Errorf("%T %s should not have been qa", qa, qa)
	}
	prod := PROD
	if prod.IsQA() {
		t.Errorf("%T %s should not have been qa", prod, prod)
	}
	dev := DEV
	if dev.IsQA() {
		t.Errorf("%T %s should not have been qa", dev, dev)
	}
	test := TEST
	if test.IsQA() {
		t.Errorf("%T %s shouldhave been qa", test, test)
	}
	rand := Type("Random")
	if rand.IsQA() {
		t.Errorf("%T %s should not have been qa", rand, rand)
	}
}

func TestType_IsTest(t *testing.T) {
	test := TEST
	if !test.IsTest() {
		t.Errorf("%T %s should have been test", test, test)
	}
	prod := PROD
	if prod.IsTest() {
		t.Errorf("%T %s should not have been test", prod, prod)
	}
	qa := QA
	if qa.IsTest() {
		t.Errorf("%T %s should not have been test", qa, qa)
	}
	dev := DEV
	if dev.IsTest() {
		t.Errorf("%T %s should not have been test", dev, dev)
	}
	rand := Type("Random")
	if rand.IsTest() {
		t.Errorf("%T %s should not have been test", rand, rand)
	}
}

func TestValidTypeOrDev(t *testing.T) {
	prod := PROD
	if prod != ValidTypeOrDev(prod) {
		t.Errorf("%T %s should have been valid, received %s", prod, prod, ValidTypeOrDev(prod))
	}
	qa := QA
	if qa != ValidTypeOrDev(qa) {
		t.Errorf("%T %s should have been valid, received %s", qa, qa, ValidTypeOrDev(qa))
	}
	test := TEST
	if test != ValidTypeOrDev(test) {
		t.Errorf("%T %s should have been valid, received %s", test, test, ValidTypeOrDev(test))
	}
	dev := DEV
	if dev != ValidTypeOrDev(dev) {
		t.Errorf("%T %s should have been valid, received %s", dev, dev, ValidTypeOrDev(dev))
	}
	rand := "Random"
	if dev != ValidTypeOrDev(Type(rand)) {
		t.Errorf("%T %s should not have been valid, received %s", rand, rand, ValidTypeOrDev(Type(rand)))
	}
}

func TestValidType(t *testing.T) {
	prod := PROD
	if !ValidType(prod) {
		t.Errorf("%T %s should have been valid", prod, prod)
	}
	qa := QA
	if !ValidType(qa) {
		t.Errorf("%T %s should have been valid", qa, qa)
	}
	dev := DEV
	if !ValidType(dev) {
		t.Errorf("%T %s should have been valid", dev, dev)
	}
	test := TEST
	if !ValidType(test) {
		t.Errorf("%T %s should have been valid", test, test)
	}
	rand := "Random"
	if ValidType(Type(rand)) {
		t.Errorf("%T %s should not have been valid", Type(rand), rand)
	}
}

A  => ui.go +661 -0
@@ 1,661 @@
package motley

import (
	"fmt"
	"github.com/charmbracelet/bubbles/spinner"
	"github.com/charmbracelet/bubbles/textinput"
	"github.com/charmbracelet/bubbles/viewport"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/glamour"
	pub "github.com/go-ap/activitypub"
	"github.com/go-ap/storage"
	rw "github.com/mattn/go-runewidth"
	"github.com/muesli/reflow/ansi"
	"github.com/muesli/reflow/wordwrap"
	te "github.com/muesli/termenv"
	"log"
	"math"
	"strings"
	"time"
)

const (
	noteCharacterLimit   = 256             // should match server
	statusMessageTimeout = time.Second * 2 // how long to show status messages like "stashed!"
	ellipsis             = "…"

	darkGray = "#333333"
	wrapAt = 60
	statusBarHeight = 1
)

var (
	normalFg    = newFgStyle(NewColorPair("#dddddd", "#1a1a1a"))
	dimNormalFg = newFgStyle(NewColorPair("#777777", "#A49FA5"))

	brightGrayFg    = newFgStyle(NewColorPair("#979797", "#847A85"))
	dimBrightGrayFg = newFgStyle(NewColorPair("#4D4D4D", "#C2B8C2"))

	grayFg     = newFgStyle(NewColorPair("#626262", "#909090"))
	midGrayFg  = newFgStyle(NewColorPair("#4A4A4A", "#B2B2B2"))
	darkGrayFg = newFgStyle(NewColorPair("#3C3C3C", "#DDDADA"))

	greenFg        = newFgStyle(NewColorPair("#04B575", "#04B575"))
	semiDimGreenFg = newFgStyle(NewColorPair("#036B46", "#35D79C"))
	dimGreenFg     = newFgStyle(NewColorPair("#0B5137", "#72D2B0"))

	fuchsiaFg    = newFgStyle(Fuschia)
	dimFuchsiaFg = newFgStyle(NewColorPair("#99519E", "#F1A8FF"))

	dullFuchsiaFg    = newFgStyle(NewColorPair("#AD58B4", "#F793FF"))
	dimDullFuchsiaFg = newFgStyle(NewColorPair("#6B3A6F", "#F6C9FF"))

	indigoFg    = newFgStyle(Indigo)
	dimIndigoFg = newFgStyle(NewColorPair("#494690", "#9498FF"))

	subtleIndigoFg    = newFgStyle(NewColorPair("#514DC1", "#7D79F6"))
	dimSubtleIndigoFg = newFgStyle(NewColorPair("#383584", "#BBBDFF"))

	yellowFg     = newFgStyle(YellowGreen)                        // renders light green on light backgrounds
	dullYellowFg = newFgStyle(NewColorPair("#9BA92F", "#6BCB94")) // renders light green on light backgrounds
	redFg        = newFgStyle(Red)
	faintRedFg   = newFgStyle(FaintRed)
)

var (
	// Color wraps termenv.ColorProfile.Color, which produces a termenv color
	// for use in termenv styling.
	Color func(string) te.Color = te.ColorProfile().Color

	// HasDarkBackground stores whether or not the terminal has a dark
	// background.
	HasDarkBackground = te.HasDarkBackground()
)

// Colors for dark and light backgrounds.
var (
	Indigo       ColorPair = NewColorPair("#7571F9", "#5A56E0")
	SubtleIndigo           = NewColorPair("#514DC1", "#7D79F6")
	Cream                  = NewColorPair("#FFFDF5", "#FFFDF5")
	YellowGreen            = NewColorPair("#ECFD65", "#04B575")
	Fuschia                = NewColorPair("#EE6FF8", "#EE6FF8")
	Green                  = NewColorPair("#04B575", "#04B575")
	Red                    = NewColorPair("#ED567A", "#FF4672")
	FaintRed               = NewColorPair("#C74665", "#FF6F91")
	SpinnerColor           = NewColorPair("#747373", "#8E8E8E")
	NoColor                = NewColorPair("", "")
)

// Functions for styling strings.
var (
	IndigoFg       func(string) string = te.Style{}.Foreground(Indigo.Color()).Styled
	SubtleIndigoFg                     = te.Style{}.Foreground(SubtleIndigo.Color()).Styled
	RedFg                              = te.Style{}.Foreground(Red.Color()).Styled
	FaintRedFg                         = te.Style{}.Foreground(FaintRed.Color()).Styled
)

var (
	GlamourStyle    = "auto"
	GlamourEnabled  = true
	GlamourMaxWidth = 800
)

var(
	pagerHelpHeight int

	mintGreen = NewColorPair("#89F0CB", "#89F0CB")
	darkGreen = NewColorPair("#1C8760", "#1C8760")

	statusBarNoteFg = NewColorPair("#7D7D7D", "#656565")
	statusBarBg     = NewColorPair("#242424", "#E6E6E6")

	// Styling funcs.
	statusBarScrollPosStyle        = newStyle(NewColorPair("#5A5A5A", "#949494"), statusBarBg, false)
	statusBarOKStyle               = newStyle(statusBarNoteFg, statusBarBg, false)
	statusBarFailStyle             = newStyle(statusBarNoteFg, FaintRed, false)
	statusBarStashDotStyle         = newStyle(Green, statusBarBg, false)
	statusBarMessageStyle          = newStyle(mintGreen, darkGreen, false)
	statusBarMessageStashIconStyle = newStyle(mintGreen, darkGreen, false)
	statusBarMessageScrollPosStyle = newStyle(mintGreen, darkGreen, false)
	statusBarMessageHelpStyle      = newStyle(NewColorPair("#B6FFE4", "#B6FFE4"), Green, false)
	helpViewStyle                  = newStyle(statusBarNoteFg, NewColorPair("#1B1B1B", "#f2f2f2"), false)
)


func Launch(base pub.IRI, r storage.Repository) error {
	return tea.NewProgram(newModel(base, r)).Start()
}

func newModel(base pub.IRI, r storage.Repository) *model {
	if te.HasDarkBackground() {
		GlamourStyle = "dark"
	} else {
		GlamourStyle = "light"
	}
	m := new(model)
	m.commonModel = new(commonModel)
	m.r = r
	m.baseIRI = base
	m.pager = newPagerModel(m.commonModel)
	return m
}

func newPagerModel(common *commonModel) pagerModel {
	// Init viewport
	vp := viewport.Model{}
	vp.YPosition = 0
	vp.HighPerformanceRendering = false

	// Text input for notes/memos
	ti := textinput.NewModel()
	ti.Prompt = te.String(" > ").
		Foreground(Color(darkGray)).
		Background(YellowGreen.Color()).
		String()
	ti.TextColor = darkGray
	ti.BackgroundColor = YellowGreen.String()
	ti.CursorColor = Fuschia.String()
	ti.CharLimit = noteCharacterLimit
	ti.Focus()

	// Text input for search
	sp := spinner.NewModel()
	sp.ForegroundColor = statusBarNoteFg.String()
	sp.BackgroundColor = statusBarBg.String()
	sp.HideFor = time.Millisecond * 50
	sp.MinimumLifetime = time.Millisecond * 180

	return pagerModel{
		commonModel: common,
		textInput: ti,
		viewport:  vp,
		spinner:   sp,
	}
}

type commonModel struct {
	baseIRI    pub.IRI
	r          storage.Repository
	cwd        string
	width      int
	height     int
}

type pagerModel struct {
	*commonModel
	state     int
	showHelp  bool

	// Inbox/Outbox tree model
	viewport  viewport.Model
	textInput textinput.Model
	spinner   spinner.Model

	statusMessage      string
	statusMessageTimer *time.Timer
}

type model struct {
	*commonModel
	fatalErr    error
	pager pagerModel
}

func (m model) Init() tea.Cmd {
	var cmds []tea.Cmd
	return tea.Batch(cmds...)
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	// If there's been an error, any key exits
	if m.fatalErr != nil {
		if _, ok := msg.(tea.KeyMsg); ok {
			return m, tea.Quit
		}
	}

	var cmds []tea.Cmd

	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case "q", "esc":
			return m, tea.Quit
		case "left", "h", "delete":

		// Ctrl+C always quits no matter where in the application you are.
		case "ctrl+c":
			return m, tea.Quit
		}
	// Window size is received when starting up and on every resize
	case tea.WindowSizeMsg:
		m.width = msg.Width
		m.height = msg.Height
		m.pager.setSize(msg.Width, msg.Height)
	}

	newPagerModel, cmd := m.pager.update(msg)
	m.pager = newPagerModel
	cmds = append(cmds, cmd)

	return m, tea.Batch(cmds...)
}

func (m model) View() string {
	return m.pager.View()
}
// ColorPair is a pair of colors, one intended for a dark background and the
// other intended for a light background. We'll automatically determine which
// of these colors to use.
type ColorPair struct {
	Dark  string
	Light string
}

// NewColorPair is a helper function for creating a ColorPair.
func NewColorPair(dark, light string) ColorPair {
	return ColorPair{dark, light}
}

// Color returns the appropriate termenv.Color for the terminal background.
func (c ColorPair) Color() te.Color {
	if HasDarkBackground {
		return Color(c.Dark)
	}

	return Color(c.Light)
}

// String returns a string representation of the color appropriate for the
// current terminal background.
func (c ColorPair) String() string {
	if HasDarkBackground {
		return c.Dark
	}

	return c.Light
}

// Wrap wraps lines at a predefined width via package muesli/reflow.
func Wrap(s string) string {
	return wordwrap.String(s, wrapAt)
}

// Keyword applies special formatting to imporant words or phrases.
func Keyword(s string) string {
	return te.String(s).Foreground(Green.Color()).String()
}

// Code applies special formatting to strings indeded to read as code.
func Code(s string) string {
	return te.String(" " + s + " ").
		Foreground(NewColorPair("#ED567A", "#FF4672").Color()).
		Background(NewColorPair("#2B2A2A", "#EBE5EC").Color()).
		String()
}

// Subtle applies formatting to strings intended to be "subtle".
func Subtle(s string) string {
	return te.String(s).Foreground(NewColorPair("#5C5C5C", "#9B9B9B").Color()).String()
}

type styleFunc func(string) string
// Returns a termenv style with foreground and background options.
func newStyle(fg, bg ColorPair, bold bool) func(string) string {
	s := te.Style{}.Foreground(fg.Color()).Background(bg.Color())
	if bold {
		s = s.Bold()
	}
	return s.Styled
}

// Returns a new termenv style with background options only.
func newFgStyle(c ColorPair) styleFunc {
	return te.Style{}.Foreground(c.Color()).Styled
}


func (m *pagerModel) setSize(w, h int) {
	m.viewport.Width = w
	m.viewport.Height = h - statusBarHeight
	m.textInput.Width = w - ansi.PrintableRuneWidth(m.textInput.Prompt) - 1

	if m.showHelp {
		if pagerHelpHeight == 0 {
			pagerHelpHeight = strings.Count(m.helpView(), "\n")
		}
		m.viewport.Height -= statusBarHeight + pagerHelpHeight
	}
}

func (m *pagerModel) setContent(s string) {
	m.viewport.SetContent(s)
}

func (m *pagerModel) toggleHelp() {
	m.showHelp = !m.showHelp
	m.setSize(m.width, m.height)
	if m.viewport.PastBottom() {
		m.viewport.GotoBottom()
	}
}

const (
	pagerStateBrowse int = iota
)
// Perform stuff that needs to happen after a successful markdown stash. Note
// that the the returned command should be sent back the through the pager
// update function.
func (m *pagerModel) showStatusMessage(statusMessage string) tea.Cmd {
	// Show a success message to the user
	m.statusMessage = statusMessage
	if m.statusMessageTimer != nil {
		m.statusMessageTimer.Stop()
	}
	m.statusMessageTimer = time.NewTimer(statusMessageTimeout)

	return waitForStatusMessageTimeout(1, m.statusMessageTimer)
}

func waitForStatusMessageTimeout(appCtx int, t *time.Timer) tea.Cmd {
	return func() tea.Msg {
		<-t.C
		return appCtx
	}
}
func (m *pagerModel) unload() {
	if m.showHelp {
		m.toggleHelp()
	}
	if m.statusMessageTimer != nil {
		m.statusMessageTimer.Stop()
	}
	m.state = pagerStateBrowse
	m.viewport.SetContent("")
	m.viewport.YOffset = 0
	m.textInput.Reset()
}

func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
	var (
		cmd  tea.Cmd
		cmds []tea.Cmd
	)

	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch m.state {
		default:
			switch msg.String() {
			case "q", "esc":
				if m.state != pagerStateBrowse {
					m.state = pagerStateBrowse
					return m, nil
				}
			case "home", "g":
				m.viewport.GotoTop()
				if m.viewport.HighPerformanceRendering {
					cmds = append(cmds, viewport.Sync(m.viewport))
				}
			case "end", "G":
				m.viewport.GotoBottom()
				if m.viewport.HighPerformanceRendering {
					cmds = append(cmds, viewport.Sync(m.viewport))
				}
			case "m":
			case "?":
				m.toggleHelp()
				if m.viewport.HighPerformanceRendering {
					cmds = append(cmds, viewport.Sync(m.viewport))
				}
			}
		}
	case spinner.TickMsg:
		if m.state > pagerStateBrowse || m.spinner.Visible() {
			// If we're still stashing, or if the spinner still needs to
			// finish, spin it along.
			newSpinnerModel, cmd := m.spinner.Update(msg)
			m.spinner = newSpinnerModel
			cmds = append(cmds, cmd)
		} else if m.state == pagerStateBrowse && !m.spinner.Visible() {
			// If the spinner's finished and we haven't told the user the
			// stash was successful, do that.
			m.state = pagerStateBrowse
			cmds = append(cmds, m.showStatusMessage("Stashed!"))
		}

	case tea.WindowSizeMsg:
		return m, renderWithGlamour(m, "")
	default:
		m.state = pagerStateBrowse
	}

	switch m.state {
	default:
		m.viewport, cmd = m.viewport.Update(msg)
		cmds = append(cmds, cmd)
	}

	return m, tea.Batch(cmds...)
}

func (m pagerModel) View() string {
	var b strings.Builder
	fmt.Fprint(&b, m.viewport.View()+"\n")

	// Footer
	switch m.state {
	default:
		m.statusBarView(&b)
	}

	if m.showHelp {
		fmt.Fprint(&b, m.helpView())
	}

	return b.String()
}

const (
	pagerStashIcon = "🔒"
)
var glowLogoTextColor = Color("#ECFD65")

func withPadding(s string) string {
	return " "+s+" "
}

func logoView(text string) string {
	return te.String(withPadding(text)).
		Bold().
		Foreground(glowLogoTextColor).
		Background(Fuschia.Color()).
		String()
}

func (m pagerModel) statusBarView(b *strings.Builder) {
	const (
		minPercent               float64 = 0.0
		maxPercent               float64 = 1.0
		percentToStringMagnitude float64 = 100.0
	)

	// Logo
	name := "FedBOX Admin TUI"
	haveErr := false
	if ob, _, err := m.r.LoadObjects(pub.IRI("http://example.com")); err == nil {
		pub.OnActor(ob.Collection().First(), func(a *pub.Actor) error {
			m.statusMessage = a.Summary.String()
			return nil
		})
	} else {
		haveErr = true
		m.statusMessage = fmt.Sprintf("Error: %s", err)
	}
	logo := logoView(name)

	// Scroll percent
	percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
	scrollPercent := statusBarMessageScrollPosStyle(fmt.Sprintf(" %3.f%% ", percent*percentToStringMagnitude))

	var statusMessage string
	if haveErr {
		statusMessage = statusBarFailStyle(withPadding(m.statusMessage))
	} else {
		statusMessage = statusBarMessageStyle(withPadding(m.statusMessage))
	}
	
	// Empty space
	padding := max(0,
		m.width-
			ansi.PrintableRuneWidth(logo)-
			ansi.PrintableRuneWidth(statusMessage)-
			ansi.PrintableRuneWidth(scrollPercent),
	)

	emptySpace := strings.Repeat(" ", padding)
	if haveErr {
		emptySpace = statusBarFailStyle(emptySpace)
	} else {
		emptySpace = statusBarMessageStyle(emptySpace)
	}

	fmt.Fprintf(b, "%s%s%s%s",
		logo,
		statusMessage,
		emptySpace,
		scrollPercent,
	)
}

func (m pagerModel) setNoteView(b *strings.Builder) {
	fmt.Fprint(b, m.textInput.View())
}

func (m pagerModel) helpView() (s string) {
	memoOrStash := "m       set memo"

	col1 := []string{
		"g/home  go to top",
		"G/end   go to bottom",
		"",
		memoOrStash,
		"esc     back to files",
		"q       quit",
	}

	s += "\n"
	s += "k/↑      up                  " + col1[0] + "\n"
	s += "j/↓      down                " + col1[1] + "\n"
	s += "b/pgup   page up             " + col1[2] + "\n"
	s += "f/pgdn   page down           " + col1[3] + "\n"
	s += "u        ½ page up           " + col1[4] + "\n"
	s += "d        ½ page down         "

	if len(col1) > 5 {
		s += col1[5]
	}

	s = indent(s, 2)

	// Fill up empty cells with spaces for background coloring
	if m.width > 0 {
		lines := strings.Split(s, "\n")
		for i := 0; i < len(lines); i++ {
			l := rw.StringWidth(lines[i])
			n := max(m.width-l, 0)
			lines[i] += strings.Repeat(" ", n)
		}

		s = strings.Join(lines, "\n")
	}

	return helpViewStyle(s)
}

// COMMANDS

func renderWithGlamour(m pagerModel, md string) tea.Cmd {
	return func() tea.Msg {
		s, err := glamourRender(m, md)
		if err != nil {
			if true {
				log.Println("error rendering with Glamour:", err)
			}
			return err
		}
		return s
	}
}

// This is where the magic happens.
func glamourRender(m pagerModel, markdown string) (string, error) {
	if !GlamourEnabled {
		return markdown, nil
	}

	// initialize glamour
	var gs glamour.TermRendererOption
	if GlamourStyle == "auto" {
		gs = glamour.WithAutoStyle()
	} else {
		gs = glamour.WithStylePath(GlamourStyle)
	}

	width := max(0, min(int(GlamourMaxWidth), m.viewport.Width))
	r, err := glamour.NewTermRenderer(
		gs,
		glamour.WithWordWrap(width),
	)
	if err != nil {
		return "", err
	}

	out, err := r.Render(markdown)
	if err != nil {
		return "", err
	}

	// trim lines
	lines := strings.Split(out, "\n")

	var content string
	for i, s := range lines {
		content += strings.TrimSpace(s)

		// don't add an artificial newline after the last split
		if i+1 < len(lines) {
			content += "\n"
		}
	}

	return content, nil
}

// Lightweight version of reflow's indent function.
func indent(s string, n int) string {
	if n <= 0 || s == "" {
		return s
	}
	l := strings.Split(s, "\n")
	b := strings.Builder{}
	i := strings.Repeat(" ", n)
	for _, v := range l {
		fmt.Fprintf(&b, "%s%s\n", i, v)
	}
	return b.String()
}

func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}