add cli tool.

This commit is contained in:
fiatjaf
2023-11-28 15:34:12 -03:00
parent 0214061431
commit 4ccace1ea9
8 changed files with 347 additions and 0 deletions

39
cli/README.md Normal file
View File

@@ -0,0 +1,39 @@
# eventstore command-line tool
```
go install github.com/fiatjaf/eventstore/cli@latest
```
## Usage
This should be pretty straightforward. You pipe events or filters, as JSON, to the `eventstore` command, and they yield something. You can use [nak](https://github.com/fiatjaf/nak) to generate these events or filters easily.
### Querying the last 100 events of kind 1
```fish
~> nak req -k 1 -l 100 --bare | eventstore -d /path/to/store query
~> # or
~> echo '{"kinds":[1],"limit":100}' | eventstore -d /path/to/store query
```
This will automatically determine the storage type being used at `/path/to/store`, but you can also specify it manually using the `-t` option (`-t lmdb`, `-t sqlite` etc).
### Saving an event to the store
```fish
~> nak event -k 1 -c hello | eventstore -d /path/to/store put
~> # or
~> echo '{"id":"35369e6bae5f77c4e1745c2eb5db84c4493e87f6e449aee62a261bbc1fea2788","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1701193836,"kind":1,"tags":[],"content":"hello","sig":"ef08d559e042d9af4cdc3328a064f737603d86ec4f929f193d5a3ce9ea22a3fb8afc1923ee3c3742fd01856065352c5632e91f633528c80e9c5711fa1266824c"}' | eventstore -d /path/to/store put
```
You can also create a database from scratch if it's a disk database, but then you have to specify `-t` to `sqlite`, `badger` or `lmdb`.
### Connecting to Postgres, MySQL and other remote databases
You should be able to connect by just passing the database connection URI to `-d`:
```bash
~> eventstore -d 'postgres://myrelay:38yg4o83yf48a3s7g@localhost:5432/myrelay?sslmode=disable' <query|put|del>
```
That should be prefixed with `postgres://` for Postgres, `mysql://` for MySQL and `https://` for ElasticSearch.

21
cli/del.go Normal file
View File

@@ -0,0 +1,21 @@
package main
import (
"fmt"
"github.com/urfave/cli/v2"
)
var del = &cli.Command{
Name: "del",
Usage: "deletes an event",
Description: ``,
Action: func(c *cli.Context) error {
for line := range getStdinLinesOrBlank() {
fmt.Println(line)
}
exitIfLineProcessingError(c)
return nil
},
}

109
cli/helpers.go Normal file
View File

@@ -0,0 +1,109 @@
package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"strings"
"github.com/urfave/cli/v2"
)
const (
LINE_PROCESSING_ERROR = iota
)
func detect(dir string) (string, error) {
f, err := os.Stat(dir)
if err != nil {
return "", err
}
if !f.IsDir() {
f, err := os.Open(dir)
if err != nil {
return "", err
}
buf := make([]byte, 15)
f.Read(buf)
if string(buf) == "SQLite format 3" {
return "sqlite", nil
}
return "", fmt.Errorf("unknown format")
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".mdb") {
return "lmdb", nil
}
if strings.HasSuffix(entry.Name(), ".vlog") {
return "badger", nil
}
}
return "", fmt.Errorf("undetected")
}
func getStdin() string {
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) == 0 {
read := bytes.NewBuffer(make([]byte, 0, 1000))
_, err := io.Copy(read, os.Stdin)
if err == nil {
return read.String()
}
}
return ""
}
func isPiped() bool {
stat, _ := os.Stdin.Stat()
return stat.Mode()&os.ModeCharDevice == 0
}
func getStdinLinesOrBlank() chan string {
multi := make(chan string)
if hasStdinLines := writeStdinLinesOrNothing(multi); !hasStdinLines {
single := make(chan string, 1)
single <- ""
close(single)
return single
} else {
return multi
}
}
func writeStdinLinesOrNothing(ch chan string) (hasStdinLines bool) {
if isPiped() {
// piped
go func() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
ch <- strings.TrimSpace(scanner.Text())
}
close(ch)
}()
return true
} else {
// not piped
return false
}
}
func lineProcessingError(c *cli.Context, msg string, args ...any) {
c.Context = context.WithValue(c.Context, LINE_PROCESSING_ERROR, true)
fmt.Fprintf(os.Stderr, msg, args...)
}
func exitIfLineProcessingError(c *cli.Context) {
if val := c.Context.Value(LINE_PROCESSING_ERROR); val != nil && val.(bool) {
os.Exit(123)
}
}

99
cli/main.go Normal file
View File

@@ -0,0 +1,99 @@
package main
import (
"fmt"
"os"
"strings"
"github.com/fiatjaf/eventstore"
"github.com/fiatjaf/eventstore/badger"
"github.com/fiatjaf/eventstore/elasticsearch"
"github.com/fiatjaf/eventstore/lmdb"
"github.com/fiatjaf/eventstore/mysql"
"github.com/fiatjaf/eventstore/postgresql"
"github.com/fiatjaf/eventstore/sqlite3"
"github.com/urfave/cli/v2"
)
var db eventstore.Store
var app = &cli.App{
Name: "eventstore",
Usage: "a CLI for all the eventstore backends",
UsageText: "eventstore -d ./data/sqlite <query|put|del> ...",
Flags: []cli.Flag{
&cli.PathFlag{
Name: "store",
Aliases: []string{"d"},
Usage: "path to the database file or directory or database connection uri",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Usage: "store type ('sqlite', 'lmdb', 'badger', 'postgres', 'mysql', 'elasticsearch')",
},
},
Before: func(c *cli.Context) error {
path := c.Path("store")
typ := c.String("type")
if typ != "" {
// bypass automatic detection
// this also works for creating disk databases from scratch
} else {
// try to detect based on url scheme
switch {
case strings.HasPrefix(path, "postgres://"), strings.HasPrefix(path, "postgresql://"):
typ = "postgres"
case strings.HasPrefix(path, "mysql://"):
typ = "mysql"
case strings.HasPrefix(path, "https://"):
// if we ever add something else that uses URLs we'll have to modify this
typ = "elasticsearch"
default:
// try to detect based on the form and names of disk files
dbname, err := detect(path)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf(
"'%s' does not exist, to create a store there specify the --type argument", path)
}
return fmt.Errorf("failed to detect store type: %w", err)
}
typ = dbname
}
}
switch typ {
case "sqlite":
db = &sqlite3.SQLite3Backend{DatabaseURL: path}
case "lmdb":
db = &lmdb.LMDBBackend{Path: path, MaxLimit: 5000}
case "badger":
db = &badger.BadgerBackend{Path: path, MaxLimit: 5000}
case "postgres", "postgresql":
db = &postgresql.PostgresBackend{DatabaseURL: path}
case "mysql":
db = &mysql.MySQLBackend{DatabaseURL: path}
case "elasticsearch":
db = &elasticsearch.ElasticsearchStorage{URL: path}
case "":
return fmt.Errorf("couldn't determine store type, you can use --type to specify it manually")
default:
return fmt.Errorf("'%s' store type is not supported by this CLI", typ)
}
return db.Init()
},
Commands: []*cli.Command{
query,
put,
del,
},
}
func main() {
if err := app.Run(os.Args); err != nil {
fmt.Println(err)
os.Exit(1)
}
}

30
cli/put.go Normal file
View File

@@ -0,0 +1,30 @@
package main
import (
"github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr"
"github.com/urfave/cli/v2"
)
var put = &cli.Command{
Name: "put",
Usage: "saves an event to an eventstore",
Description: ``,
Action: func(c *cli.Context) error {
for line := range getStdinLinesOrBlank() {
var event nostr.Event
if err := easyjson.Unmarshal([]byte(line), &event); err != nil {
lineProcessingError(c, "invalid event '%s' received from stdin: %s", line, err)
continue
}
if err := db.SaveEvent(c.Context, &event); err != nil {
lineProcessingError(c, "failed to save event '%s': %s", line, err)
continue
}
}
exitIfLineProcessingError(c)
return nil
},
}

37
cli/query.go Normal file
View File

@@ -0,0 +1,37 @@
package main
import (
"fmt"
"github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr"
"github.com/urfave/cli/v2"
)
var query = &cli.Command{
Name: "query",
Usage: "queries an eventstore for events",
Description: ``,
Action: func(c *cli.Context) error {
for line := range getStdinLinesOrBlank() {
filter := nostr.Filter{}
if err := easyjson.Unmarshal([]byte(line), &filter); err != nil {
lineProcessingError(c, "invalid filter '%s' received from stdin: %s", line, err)
continue
}
ch, err := db.QueryEvents(c.Context, filter)
if err != nil {
lineProcessingError(c, "error querying: %w", err)
continue
}
for evt := range ch {
fmt.Println(evt)
}
}
exitIfLineProcessingError(c)
return nil
},
}

4
go.mod
View File

@@ -13,12 +13,14 @@ require (
github.com/mattn/go-sqlite3 v1.14.18
github.com/nbd-wtf/go-nostr v0.25.3
github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v2 v2.25.7
)
require (
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
@@ -43,9 +45,11 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tidwall/gjson v1.17.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/net v0.17.0 // indirect

8
go.sum
View File

@@ -14,6 +14,8 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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=
@@ -123,6 +125,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU=
github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -139,6 +143,10 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=