Files
Mako/cmd/basking/main.go
T
VeillaxandClaude Sonnet 5.5 b548faa258 Basking: a place to dump thoughts, jot notes and keep a journal
Plain markdown files, tags by typing #hashtags, a page per day, search,
pin and trash with undo, autosave with conflict warnings, API tokens,
zip export, and the Lightwell dashboard. Includes install.sh, CI and
release workflows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 20:16:33 -06:00

246 lines
6.7 KiB
Go

// Command basking is a place to dump thoughts, jot notes and keep a journal. Notes are plain
// markdown files; the dashboard is one small binary.
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.veillax.com/Veillax/basking/internal/config"
"git.veillax.com/Veillax/basking/internal/store"
"git.veillax.com/Veillax/basking/internal/web"
)
var version = "dev"
const usage = `basking is a place to dump thoughts, jot notes and keep a journal.
basking serve [-config basking.json] [-listen addr] [-data dir]
run the dashboard
basking password [-config ...] set the password (reads BASKING_PASSWORD or a line on stdin)
basking token [-config ...] NAME create an API token for scripts (shown once)
basking add [TEXT...] send a note to a running Basking; reads stdin if no text
basking version
"add" needs BASKING_URL (such as http://127.0.0.1:8490) and BASKING_TOKEN.
The settings path can also be set with BASKING_CONFIG, the address with BASKING_LISTEN and the
notes folder with BASKING_DATA.
`
func main() {
log.SetFlags(log.LstdFlags)
if len(os.Args) < 2 {
fmt.Fprint(os.Stderr, usage)
os.Exit(2)
}
var err error
switch os.Args[1] {
case "serve":
err = serve(os.Args[2:])
case "password":
err = passwordCmd(os.Args[2:])
case "token":
err = tokenCmd(os.Args[2:])
case "add":
err = addCmd(os.Args[2:])
case "version", "-v", "--version":
fmt.Println("basking", version)
case "help", "-h", "--help":
fmt.Print(usage)
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", os.Args[1], usage)
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "basking:", err)
os.Exit(1)
}
}
func configFlag(fs *flag.FlagSet) *string {
def := os.Getenv("BASKING_CONFIG")
if def == "" {
def = "basking.json"
}
return fs.String("config", def, "path of the settings file")
}
func serve(args []string) error {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
path := configFlag(fs)
listen := fs.String("listen", os.Getenv("BASKING_LISTEN"), "override the listen address")
data := fs.String("data", os.Getenv("BASKING_DATA"), "override the folder the notes are kept in")
fs.Parse(args)
h, err := config.Load(*path)
if err != nil {
return err
}
// First run: make a password so the dashboard can be opened, and show it once.
if h.Get().PasswordHash == "" {
pw := config.RandomPassword()
hash, err := config.HashPassword(pw)
if err != nil {
return err
}
if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
return err
}
log.Printf("first run: your password is %s (change it in Settings; written to %s)", pw, *path)
}
cfg := h.Get()
if *listen != "" {
cfg.Listen = *listen
}
if *data != "" {
cfg.DataDir = *data
}
st, err := store.Open(cfg.DataDir)
if err != nil {
return err
}
counts, _ := st.Summary()
srv := &http.Server{
Addr: cfg.Listen,
Handler: web.New(h, st, version).Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errc := make(chan error, 1)
go func() { errc <- srv.ListenAndServe() }()
log.Printf("basking %s: %d note(s) in %s; open http://%s", version, counts.All, cfg.DataDir, cfg.Listen)
if !strings.HasPrefix(cfg.Listen, "127.") && !strings.HasPrefix(cfg.Listen, "localhost") && !strings.HasPrefix(cfg.Listen, "[::1]") {
log.Print("note: this is not a loopback address; put it behind HTTPS (a reverse proxy or Hammerhead) and set cookie_secure")
}
select {
case err := <-errc:
return err
case <-ctx.Done():
}
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(sctx)
}
func readPassword() (string, error) {
if pw := os.Getenv("BASKING_PASSWORD"); pw != "" {
return pw, nil
}
fmt.Fprint(os.Stderr, "new password: ")
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return "", errors.New("no password given")
}
return strings.TrimRight(line, "\r\n"), nil
}
func passwordCmd(args []string) error {
fs := flag.NewFlagSet("password", flag.ExitOnError)
path := configFlag(fs)
fs.Parse(args)
pw, err := readPassword()
if err != nil {
return err
}
if len(pw) < 8 {
return errors.New("the password must be at least 8 characters")
}
hash, err := config.HashPassword(pw)
if err != nil {
return err
}
h, err := config.Load(*path)
if err != nil {
return err
}
if err := h.Update(func(c *config.Config) error { c.PasswordHash = hash; return nil }); err != nil {
return err
}
fmt.Println("password saved; restart Basking if it is running, and everyone will be signed out")
return nil
}
func tokenCmd(args []string) error {
fs := flag.NewFlagSet("token", flag.ExitOnError)
path := configFlag(fs)
fs.Parse(args)
if fs.NArg() != 1 {
return errors.New("expected NAME")
}
token, hash, err := config.NewToken()
if err != nil {
return err
}
h, err := config.Load(*path)
if err != nil {
return err
}
err = h.Update(func(c *config.Config) error {
c.Tokens = append(c.Tokens, config.Token{Name: fs.Arg(0), Hash: hash, Created: time.Now().UTC()})
return nil
})
if err == nil {
fmt.Println(token)
fmt.Fprintln(os.Stderr, "this is the only time the token is shown; send it as 'Authorization: Bearer <token>'")
fmt.Fprintln(os.Stderr, "a running Basking picks it up after a restart")
}
return err
}
func addCmd(args []string) error {
url, token := os.Getenv("BASKING_URL"), os.Getenv("BASKING_TOKEN")
if url == "" || token == "" {
return errors.New("set BASKING_URL and BASKING_TOKEN (make a token in Settings)")
}
text := strings.Join(args, " ")
if text == "" {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}
text = string(b)
}
if strings.TrimSpace(text) == "" {
return errors.New("nothing to add")
}
body, _ := json.Marshal(map[string]string{"body": text})
req, err := http.NewRequest("POST", strings.TrimRight(url, "/")+"/api/notes", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
var e struct{ Error string }
json.NewDecoder(resp.Body).Decode(&e)
return fmt.Errorf("%s: %s", resp.Status, e.Error)
}
fmt.Println("saved")
return nil
}