Compare commits

...

11 Commits

Author SHA1 Message Date
fiatjaf
3ee6320312 bunker: ignore duplicates caused by switch_relays. 2026-01-21 23:17:00 -03:00
fiatjaf
91474d65eb bunker: set default relays so switch_relays works. 2026-01-21 22:19:00 -03:00
fiatjaf
7d782737c4 git status: fix commit printing. 2026-01-21 14:36:25 -03:00
fiatjaf
9160c68cb5 bunker: using unix sockets. 2026-01-21 14:31:12 -03:00
fiatjaf
bf19f38996 nak bunker connect 'nostrconnect://...' working. 2026-01-21 12:44:40 -03:00
fiatjaf
4e2c136e45 nostrconnect:// beginnings. 2026-01-20 17:19:30 -03:00
fiatjaf
8cef1ed0ea group: publishing moderation actions. 2026-01-20 12:52:00 -03:00
fiatjaf
e05b455a05 group: publishing chat messages. 2026-01-18 23:38:03 -03:00
fiatjaf
9190c9d988 nip29/group command with read-only functionality for now. 2026-01-18 23:18:16 -03:00
fiatjaf
e64ad8f078 git: better printing of server statuses. 2026-01-18 21:44:06 -03:00
fiatjaf
b36718caaa git: status.
and a fix for repository announcements getting updated every time due to time shifts.
2026-01-18 21:32:01 -03:00
8 changed files with 985 additions and 94 deletions

View File

@@ -427,6 +427,7 @@ gitnostr.com... ok.
```shell ```shell
~> nak git clone ~> nak git clone
~> nak git init ~> nak git init
~> nak git status
~> nak git sync ~> nak git sync
~> nak git fetch ~> nak git fetch
~> nak git pull ~> nak git pull

299
bunker.go
View File

@@ -4,13 +4,14 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"net"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"sync"
"time" "time"
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
@@ -73,13 +74,7 @@ var bunker = &cli.Command{
}, },
Action: func(ctx context.Context, c *cli.Command) error { Action: func(ctx context.Context, c *cli.Command) error {
// read config from file // read config from file
config := struct { config := BunkerConfig{}
AuthorizedKeys []nostr.PubKey `json:"authorized-keys"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
}{
AuthorizedKeys: make([]nostr.PubKey, 0, 3),
}
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...) baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
for i, url := range baseRelaysUrls { for i, url := range baseRelaysUrls {
baseRelaysUrls[i] = nostr.NormalizeURL(url) baseRelaysUrls[i] = nostr.NormalizeURL(url)
@@ -142,6 +137,15 @@ var bunker = &cli.Command{
if err := json.Unmarshal(b, &config); err != nil { if err := json.Unmarshal(b, &config); err != nil {
return err return err
} }
// convert from deprecated field
if len(config.AuthorizedKeys) > 0 {
config.Clients = make([]BunkerConfigClient, len(config.AuthorizedKeys))
for i := range config.AuthorizedKeys {
config.Clients[i] = BunkerConfigClient{PubKey: config.AuthorizedKeys[i]}
}
config.AuthorizedKeys = nil
persist()
}
} else if !os.IsNotExist(err) { } else if !os.IsNotExist(err) {
return err return err
} }
@@ -150,7 +154,11 @@ var bunker = &cli.Command{
config.Relays[i] = nostr.NormalizeURL(url) config.Relays[i] = nostr.NormalizeURL(url)
} }
config.Relays = appendUnique(config.Relays, baseRelaysUrls...) config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, baseAuthorizedKeys...) for _, bak := range baseAuthorizedKeys {
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == bak }) {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
}
}
if config.Secret.Plain == nil && config.Secret.Encrypted == nil { if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
// we don't have any secret key stored, so just use whatever was given via flags // we don't have any secret key stored, so just use whatever was given via flags
@@ -167,7 +175,9 @@ var bunker = &cli.Command{
} else { } else {
config.Secret = baseSecret config.Secret = baseSecret
config.Relays = baseRelaysUrls config.Relays = baseRelaysUrls
config.AuthorizedKeys = baseAuthorizedKeys for _, bak := range baseAuthorizedKeys {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
}
} }
// if we got here without any keys set (no flags, first time using a profile), use the default // if we got here without any keys set (no flags, first time using a profile), use the default
@@ -205,8 +215,17 @@ var bunker = &cli.Command{
// try to connect to the relays here // try to connect to the relays here
qs := url.Values{} qs := url.Values{}
relayURLs := make([]string, 0, len(config.Relays)) allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{}) copy(allRelays, config.Relays)
for _, c := range config.Clients {
for _, url := range c.CustomRelays {
if !slices.ContainsFunc(allRelays, func(u string) bool { return u == url }) {
allRelays = append(allRelays, url)
}
}
}
relayURLs := make([]string, 0, len(allRelays))
relays := connectToAllRelays(ctx, c, allRelays, nil, nostr.PoolOptions{})
if len(relays) == 0 { if len(relays) == 0 {
log("failed to connect to any of the given relays.\n") log("failed to connect to any of the given relays.\n")
os.Exit(3) os.Exit(3)
@@ -236,10 +255,22 @@ var bunker = &cli.Command{
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode()) bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
authorizedKeysStr := "" authorizedKeysStr := ""
if len(config.AuthorizedKeys) != 0 { if len(config.Clients) != 0 {
authorizedKeysStr = "\n authorized keys:" authorizedKeysStr = "\n authorized clients:"
for _, pubkey := range config.AuthorizedKeys { for _, c := range config.Clients {
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex()) authorizedKeysStr += "\n - " + colors.italic(c.PubKey.Hex())
name := ""
if c.Name != "" {
name = c.Name
if c.URL != "" {
name += " " + colors.underline(c.URL)
}
} else if c.URL != "" {
name = colors.underline(c.URL)
}
if name != "" {
authorizedKeysStr += " (" + name + ")"
}
} }
} }
@@ -249,8 +280,8 @@ var bunker = &cli.Command{
} }
preauthorizedFlags := "" preauthorizedFlags := ""
for _, k := range config.AuthorizedKeys { for _, c := range config.Clients {
preauthorizedFlags += " -k " + k.Hex() preauthorizedFlags += " -k " + c.PubKey.Hex()
} }
for _, s := range authorizedSecrets { for _, s := range authorizedSecrets {
preauthorizedFlags += " -s " + s preauthorizedFlags += " -s " + s
@@ -314,28 +345,85 @@ var bunker = &cli.Command{
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}}, Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(), Since: nostr.Now(),
LimitZero: true, LimitZero: true,
}, nostr.SubscriptionOptions{ }, nostr.SubscriptionOptions{Label: "nak-bunker"})
Label: "nak-bunker",
})
signer := nip46.NewStaticKeySigner(sec) signer := nip46.NewStaticKeySigner(sec)
handlerWg := sync.WaitGroup{} signer.DefaultRelays = config.Relays
printLock := sync.Mutex{}
// unix socket nostrconnect:// handling
go func() {
for uri := range onSocketConnect(ctx, c) {
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
if err != nil {
continue
}
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
relays := uri.Query()["relay"]
// pre-authorize this client since the user has explicitly added it
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
return c.PubKey == clientPublicKey
}) {
config.Clients = append(config.Clients, BunkerConfigClient{
PubKey: clientPublicKey,
Name: uri.Query().Get("name"),
URL: uri.Query().Get("url"),
Icon: uri.Query().Get("icon"),
CustomRelays: relays,
})
}
if persist != nil {
persist()
}
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
if err != nil {
log("* failed to handle: %s\n", err)
continue
}
go func() {
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{Label: "nak-bunker"}) {
events <- event
}
}()
time.Sleep(time.Millisecond * 25)
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent through %s\n", res.Relay.URL)
} else {
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
}
}
}
}()
// just a gimmick // just a gimmick
var cancelPreviousBunkerInfoPrint context.CancelFunc var cancelPreviousBunkerInfoPrint context.CancelFunc
_, cancel := context.WithCancel(ctx) _, cancel := context.WithCancel(ctx)
cancelPreviousBunkerInfoPrint = cancel cancelPreviousBunkerInfoPrint = cancel
// asking user for authorization
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool { signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
if slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret) { if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
return true
}
if slices.Contains(authorizedSecrets, secret) {
return true return true
} }
if secret == newSecret { if secret == newSecret {
// store this key // store this key
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from) config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
// discard this and generate a new secret // discard this and generate a new secret
newSecret = randString(12) newSecret = randString(12)
// print bunker info again after this // print bunker info again after this
@@ -358,34 +446,39 @@ var bunker = &cli.Command{
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
// handle the NIP-46 request event // handle the NIP-46 request event
from := ie.Event.PubKey
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event) req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil { if err != nil {
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error()) if errors.Is(err, nip46.AlreadyHandled) {
continue
}
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
continue continue
} }
jreq, _ := json.MarshalIndent(req, "", " ") jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq)) log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ") jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp)) log("~ responding with %s\n", string(jresp))
handlerWg.Add(len(relayURLs)) // use custom relays if they are defined for this client
for _, relayURL := range relayURLs { // (normally if the initial connection came from a nostrconnect:// URL)
go func(relayURL string) { relays := relayURLs
defer handlerWg.Done() for _, c := range config.Clients {
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil { if c.PubKey == from && len(c.CustomRelays) > 0 {
err := relay.Publish(ctx, eventResponse) relays = c.CustomRelays
printLock.Lock() break
if err == nil { }
log("* sent response through %s\n", relay.URL) }
} else {
log("* failed to send response: %s\n", err) for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
} if res.Error == nil {
printLock.Unlock() log("* sent response through %s\n", res.Relay.URL)
} } else {
}(relayURL) log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
}
} }
handlerWg.Wait()
// just after handling one request we trigger this // just after handling one request we trigger this
go func() { go func() {
@@ -410,24 +503,44 @@ var bunker = &cli.Command{
Name: "connect", Name: "connect",
Usage: "use the client-initiated NostrConnect flow of NIP46", Usage: "use the client-initiated NostrConnect flow of NIP46",
ArgsUsage: "<nostrconnect-uri>", ArgsUsage: "<nostrconnect-uri>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "profile",
Usage: "profile name of the bunker to connect to",
},
},
Action: func(ctx context.Context, c *cli.Command) error { Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 1 { if c.Args().Len() != 1 {
return fmt.Errorf("must be called with a nostrconnect://... uri") return fmt.Errorf("must be called with a nostrconnect://... uri")
} }
uri, err := url.Parse(c.Args().First()) if err := sendToSocket(c, c.Args().First()); err != nil {
if err != nil || uri.Scheme != "nostrconnect" { return fmt.Errorf("failed to connect to running bunker: %w", err)
return fmt.Errorf("invalid uri")
} }
// TODO return nil
return fmt.Errorf("this is not implemented yet")
}, },
}, },
}, },
} }
type BunkerConfig struct {
Clients []BunkerConfigClient `json:"clients"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
// deprecated
AuthorizedKeys []nostr.PubKey `json:"authorized-keys,omitempty"`
}
type BunkerConfigClient struct {
PubKey nostr.PubKey `json:"pubkey"`
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
Icon string `json:"icon,omitempty"`
CustomRelays []string `json:"custom_relays,omitempty"`
}
type plainOrEncryptedKey struct { type plainOrEncryptedKey struct {
Plain *nostr.SecretKey Plain *nostr.SecretKey
Encrypted *string Encrypted *string
@@ -495,3 +608,89 @@ func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
return true return true
} }
func getSocketPath(c *cli.Command) string {
profile := "default"
if c.IsSet("profile") {
profile = c.String("profile")
}
return filepath.Join(c.String("config-path"), "bunkerconn", profile)
}
func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
res := make(chan *url.URL)
socketPath := getSocketPath(c)
// ensure directory exists
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
log(color.RedString("failed to create socket directory: %w\n", err))
return res
}
// delete existing socket file if it exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
log(color.RedString("failed to remove existing socket file: %w\n", err))
return res
}
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
log(color.RedString("failed to listen on unix socket %s: %w\n", socketPath, err))
return res
}
go func() {
defer listener.Close()
defer os.Remove(socketPath) // cleanup socket file on exit
for {
conn, err := listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
continue
}
}
go func(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 4096)
for {
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := conn.Read(buf)
if err != nil {
break
}
uri, err := url.Parse(string(buf[:n]))
if err == nil && uri.Scheme == "nostrconnect" {
res <- uri
}
}
}(conn)
}
}()
return res
}
func sendToSocket(c *cli.Command, value string) error {
socketPath := getSocketPath(c)
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
if err != nil {
return fmt.Errorf("failed to connect to bunker unix socket at %s: %w", socketPath, err)
}
defer conn.Close()
_, err = conn.Write([]byte(value))
if err != nil {
return fmt.Errorf("failed to send uri to bunker: %w", err)
}
return nil
}

162
git.go
View File

@@ -181,7 +181,7 @@ aside from those, there is also:
var fetchedRepo *nip34.Repository var fetchedRepo *nip34.Repository
if existingConfig.Identifier == "" { if existingConfig.Identifier == "" {
log(" searching for existing events... ") log(" searching for existing events... ")
repo, _, _, err := fetchRepositoryAndState(ctx, owner, identifier, nil) repo, _, _, _, err := fetchRepositoryAndState(ctx, owner, identifier, nil)
if err == nil && repo.Event.ID != nostr.ZeroID { if err == nil && repo.Event.ID != nostr.ZeroID {
fetchedRepo = &repo fetchedRepo = &repo
log("found one from %s.\n", repo.Event.CreatedAt.Time().Format(time.DateOnly)) log("found one from %s.\n", repo.Event.CreatedAt.Time().Format(time.DateOnly))
@@ -371,7 +371,7 @@ aside from those, there is also:
} }
// fetch repository metadata and state // fetch repository metadata and state
repo, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints) repo, _, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
if err != nil { if err != nil {
return err return err
} }
@@ -782,6 +782,98 @@ aside from those, there is also:
return err return err
}, },
}, },
{
Name: "status",
Usage: "show repository status and synchronization information",
Action: func(ctx context.Context, c *cli.Command) error {
// read local config
localConfig, err := readNip34ConfigFile("")
if err != nil {
return fmt.Errorf("failed to read nip34.json: %w (run 'nak git init' first)", err)
}
// parse owner
owner, err := parsePubKey(localConfig.Owner)
if err != nil {
return fmt.Errorf("invalid owner public key: %w", err)
}
repo := localConfig.ToRepository()
stdout("\n" + color.CyanString("metadata:"))
stdout(" identifier:", color.CyanString(repo.ID))
stdout(" name:", color.CyanString(repo.Name))
stdout(" owner:", color.CyanString(nip19.EncodeNpub(repo.Event.PubKey)))
stdout(" description:", color.CyanString(repo.Description))
stdout(" web urls:")
for _, url := range repo.Web {
stdout(" ", url)
}
stdout(" earliest unique commit:", color.CyanString(repo.EarliestUniqueCommitID))
// fetch repository announcement and state from relays
_, _, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
if err != nil {
// create a local repo object for display purposes
log("failed to fetch repository announcement from relays: %s\n", err)
}
if state == nil {
stdout(color.YellowString("\n repository state not published."))
}
stateHEAD, _ := state.Branches[state.HEAD]
stdout("\n" + color.CyanString("grasp status:"))
rows := make([][3]string, len(localConfig.GraspServers))
for s, server := range localConfig.GraspServers {
row := [3]string{}
url := graspServerHost(server)
row[0] = url
upToDate := upToDateRelays != nil && slices.ContainsFunc(upToDateRelays, func(s string) bool { return graspServerHost(s) == url })
if upToDate {
row[1] = color.GreenString("announcement up-to-date")
} else {
row[1] = color.YellowString("announcement outdated")
}
if state != nil {
remoteName := gitRemoteName(url)
refSpec := fmt.Sprintf("refs/remotes/%s/HEAD", remoteName)
lsRemoteCmd := exec.Command("git", "rev-parse", "--verify", refSpec)
commitOutput, err := lsRemoteCmd.Output()
if err != nil {
row[2] = color.YellowString("repository not pushed")
} else {
commit := strings.TrimSpace(string(commitOutput))
if commit == stateHEAD {
row[2] = color.GreenString("repository synced with state")
} else {
row[2] = color.YellowString("mismatched HEAD state=%s, pushed=%s", stateHEAD[0:5], commit[0:5])
}
}
}
rows[s] = row
}
maxCol := [3]int{}
for i := range maxCol {
for _, row := range rows {
if len(row[i]) > maxCol[i] {
maxCol[i] = len(row[i])
}
}
}
for _, row := range rows {
line := " " + row[0] + strings.Repeat(" ", maxCol[0]-len(row[0])) + " " + strings.Repeat(" ", maxCol[1]-len(row[1])) + row[1] + " " + strings.Repeat(" ", maxCol[2]-len(row[2])) + row[2]
stdout(line)
}
return nil
},
},
}, },
} }
@@ -869,7 +961,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
} }
// fetch repository announcement and state from relays // fetch repository announcement and state from relays
repo, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers) repo, upToDateAnnouncementEvent, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
notUpToDate := func(graspServer string) bool { notUpToDate := func(graspServer string) bool {
return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer)) return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer))
} }
@@ -889,33 +981,40 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
} }
log("some grasp servers (%v) are not up-to-date, will publish to them\n", relays) log("some grasp servers (%v) are not up-to-date, will publish to them\n", relays)
} }
// create a local repository object from config and publish it var event nostr.Event
localRepo := localConfig.ToRepository() if upToDateAnnouncementEvent != nil {
// publish the latest event to the other relays
if signer != nil { event = *upToDateAnnouncementEvent
signerPk, err := signer.GetPublicKey(ctx) repo = nip34.ParseRepository(event)
if err != nil { } else {
return repo, nil, fmt.Errorf("failed to get signer pubkey: %w", err) // create a local repository object from config and publish it
} localRepo := localConfig.ToRepository()
if signerPk != owner { if signer != nil {
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository") signerPk, err := signer.GetPublicKey(ctx)
} else { if err != nil {
event := localRepo.ToEvent() return repo, nil, fmt.Errorf("failed to get signer pubkey: %w", err)
if err := signer.SignEvent(ctx, &event); err != nil {
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
} }
if signerPk != owner {
for res := range sys.Pool.PublishMany(ctx, relays, event) { return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
if res.Error != nil { } else {
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error) event = localRepo.ToEvent()
} else { if err := signer.SignEvent(ctx, &event); err != nil {
log("> published to %s\n", color.GreenString(res.RelayURL)) return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
} }
} }
repo = localRepo } else {
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
}
repo = localRepo
}
for res := range sys.Pool.PublishMany(ctx, relays, *upToDateAnnouncementEvent) {
if res.Error != nil {
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
} else {
log("> published to %s\n", color.GreenString(res.RelayURL))
} }
} else {
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
} }
} else { } else {
if err != nil { if err != nil {
@@ -951,6 +1050,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
} else { } else {
log("local configuration is newer, publishing updated repository announcement...\n") log("local configuration is newer, publishing updated repository announcement...\n")
announcementEvent := localRepo.ToEvent() announcementEvent := localRepo.ToEvent()
announcementEvent.CreatedAt = nostr.Timestamp(configModTime.Unix())
if err := signer.SignEvent(ctx, &announcementEvent); err != nil { if err := signer.SignEvent(ctx, &announcementEvent); err != nil {
return repo, state, fmt.Errorf("failed to sign announcement: %w", err) return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
} }
@@ -1155,7 +1255,7 @@ func fetchRepositoryAndState(
pubkey nostr.PubKey, pubkey nostr.PubKey,
identifier string, identifier string,
relayHints []string, relayHints []string,
) (repo nip34.Repository, upToDateRelays []string, state *nip34.RepositoryState, err error) { ) (repo nip34.Repository, upToDateAnnouncementEvent *nostr.Event, upToDateRelays []string, state *nip34.RepositoryState, err error) {
// fetch repository announcement (30617) // fetch repository announcement (30617)
relays := appendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...) relays := appendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...)
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{ for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
@@ -1176,13 +1276,15 @@ func fetchRepositoryAndState(
// reset this list as the previous was for relays with the older version // reset this list as the previous was for relays with the older version
upToDateRelays = []string{ie.Relay.URL} upToDateRelays = []string{ie.Relay.URL}
upToDateAnnouncementEvent = &ie.Event
} else if ie.Event.CreatedAt == repo.CreatedAt { } else if ie.Event.CreatedAt == repo.CreatedAt {
// we discard this because it's the same, but this relay is up-to-date // we discard this because it's the same, but this relay is up-to-date
upToDateRelays = append(upToDateRelays, ie.Relay.URL) upToDateRelays = append(upToDateRelays, ie.Relay.URL)
} }
} }
if repo.Event.ID == nostr.ZeroID { if repo.Event.ID == nostr.ZeroID {
return repo, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier) return repo, nil, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
} }
// fetch repository state (30618) // fetch repository state (30618)
@@ -1212,10 +1314,10 @@ func fetchRepositoryAndState(
} }
} }
if stateErr != nil { if stateErr != nil {
return repo, upToDateRelays, state, stateErr return repo, upToDateAnnouncementEvent, upToDateRelays, state, stateErr
} }
return repo, upToDateRelays, state, nil return repo, upToDateAnnouncementEvent, upToDateRelays, state, nil
} }
type StateErr struct{ string } type StateErr struct{ string }

2
go.mod
View File

@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
go 1.25 go 1.25
require ( require (
fiatjaf.com/nostr v0.0.0-20260118173002-57d595a5b4c7 fiatjaf.com/nostr v0.0.0-20260122014616-241959d1e3f4
github.com/AlecAivazis/survey/v2 v2.3.7 github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1 github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6 github.com/btcsuite/btcd/btcec/v2 v2.3.6

6
go.sum
View File

@@ -1,9 +1,7 @@
fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q= fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q=
fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8= fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6 h1:yH+cU9ZNgUdMCRa5eS3pmqTPP/QdZtSmQAIrN/U5nEc= fiatjaf.com/nostr v0.0.0-20260122014616-241959d1e3f4 h1:1KAEp9ktrnm7pB/o2QrdRT3dJyXYwei8N9RRRppiMFY=
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU= fiatjaf.com/nostr v0.0.0-20260122014616-241959d1e3f4/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
fiatjaf.com/nostr v0.0.0-20260118173002-57d595a5b4c7 h1:CkMr8zFLfoOO59+oNlBXXrga00lTKyl2A4fUXAJQ7fY=
fiatjaf.com/nostr v0.0.0-20260118173002-57d595a5b4c7/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc= github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=

586
group.go Normal file
View File

@@ -0,0 +1,586 @@
package main
import (
"context"
"fmt"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip29"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
var group = &cli.Command{
Name: "group",
Aliases: []string{"nip29"},
Usage: "group-related operations: info, chat, forum, members, admins, roles",
Description: `manage and interact with Nostr communities (NIP-29). Use "nak group <subcommand> <relay>'<identifier>" where host.tld is the relay and identifier is the group identifier.`,
DisableSliceFlagSeparator: true,
ArgsUsage: "<subcommand> <relay>'<identifier> [flags]",
Flags: defaultKeyFlags,
Commands: []*cli.Command{
{
Name: "info",
Usage: "show group information",
Description: "displays basic group metadata.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
group := nip29.Group{}
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupMetadata},
Tags: nostr.TagMap{"d": []string{identifier}},
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
if err := group.MergeInMetadataEvent(&ie.Event); err != nil {
return err
}
break
}
stdout("address:", color.HiBlueString(strings.SplitN(nostr.NormalizeURL(relay), "/", 3)[2]+"'"+identifier))
stdout("name:", color.HiBlueString(group.Name))
stdout("picture:", color.HiBlueString(group.Picture))
stdout("about:", color.HiBlueString(group.About))
stdout("restricted:",
color.HiBlueString("%s", cond(group.Restricted, "yes", "no"))+
", "+
cond(group.Restricted, "only explicit members can publish", "non-members can publish (restricted by relay policy)"),
)
stdout("closed:",
color.HiBlueString("%s", cond(group.Closed, "yes", "no"))+
", "+
cond(group.Closed, "joining requires an invite", "anyone can join (restricted by relay policy)"),
)
stdout("hidden:",
color.HiBlueString("%s", cond(group.Hidden, "yes", "no"))+
", "+
cond(group.Hidden, "group doesn't show up when listing relay groups", "group is visible to users browsing the relay"),
)
stdout("private:",
color.HiBlueString("%s", cond(group.Private, "yes", "no"))+
", "+
cond(group.Private, "group content is not accessible to non-members", "group content is public"),
)
return nil
},
},
{
Name: "members",
Usage: "list and manage group members",
Description: "view group membership information.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
group := nip29.Group{
Members: make(map[nostr.PubKey][]*nip29.Role),
}
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupMembers},
Tags: nostr.TagMap{"d": []string{identifier}},
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
if err := group.MergeInMembersEvent(&ie.Event); err != nil {
return err
}
break
}
lines := make(chan string)
wg := sync.WaitGroup{}
for member, roles := range group.Members {
wg.Go(func() {
line := member.Hex()
meta := sys.FetchProfileMetadata(ctx, member)
line += " (" + color.HiBlueString(meta.ShortName()) + ")"
for _, role := range roles {
line += ", " + role.Name
}
lines <- line
})
}
go func() {
wg.Wait()
close(lines)
}()
for line := range lines {
stdout(line)
}
return nil
},
},
{
Name: "admins",
Usage: "manage group administrators",
Description: "view and manage group admin permissions.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
group := nip29.Group{
Members: make(map[nostr.PubKey][]*nip29.Role),
}
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupAdmins},
Tags: nostr.TagMap{"d": []string{identifier}},
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
if err := group.MergeInAdminsEvent(&ie.Event); err != nil {
return err
}
break
}
lines := make(chan string)
wg := sync.WaitGroup{}
for member, roles := range group.Members {
wg.Go(func() {
line := member.Hex()
meta := sys.FetchProfileMetadata(ctx, member)
line += " (" + color.HiBlueString(meta.ShortName()) + ")"
for _, role := range roles {
line += ", " + role.Name
}
lines <- line
})
}
go func() {
wg.Wait()
close(lines)
}()
for line := range lines {
stdout(line)
}
return nil
},
},
{
Name: "roles",
Usage: "manage group roles and permissions",
Description: "configure custom roles and permissions within the group.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
group := nip29.Group{
Roles: make([]*nip29.Role, 0),
}
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupRoles},
Tags: nostr.TagMap{"d": []string{identifier}},
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
if err := group.MergeInRolesEvent(&ie.Event); err != nil {
return err
}
break
}
for _, role := range group.Roles {
stdout(color.HiBlueString(role.Name) + " " + role.Description)
}
return nil
},
},
{
Name: "chat",
Usage: "send and read group chat messages",
Description: "interact with group chat functionality.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
r, err := sys.Pool.EnsureRelay(relay)
if err != nil {
return err
}
sub, err := r.Subscribe(ctx, nostr.Filter{
Kinds: []nostr.Kind{9},
Tags: nostr.TagMap{"h": []string{identifier}},
Limit: 200,
}, nostr.SubscriptionOptions{Label: "nak-nip29"})
if err != nil {
return err
}
defer sub.Close()
eosed := false
messages := make([]struct {
message string
rendered bool
}, 200)
base := len(messages)
tryRender := func(i int) {
// if all messages before these are loaded we can render this,
// otherwise we render whatever we can and stop
for m, msg := range messages[base:] {
if msg.rendered {
continue
}
if msg.message == "" {
break
}
messages[base+m].rendered = true
stdout(msg.message)
}
}
for {
select {
case evt := <-sub.Events:
var i int
if eosed {
i = len(messages)
messages = append(messages, struct {
message string
rendered bool
}{})
} else {
base--
i = base
}
go func() {
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
messages[i].message = color.HiBlueString(meta.ShortName()) + " " + color.HiCyanString(evt.CreatedAt.Time().Format(time.DateTime)) + ": " + evt.Content
if eosed {
tryRender(i)
}
}()
case reason := <-sub.ClosedReason:
stdout("closed:" + color.YellowString(reason))
case <-sub.EndOfStoredEvents:
eosed = true
tryRender(len(messages) - 1)
case <-sub.Context.Done():
return fmt.Errorf("subscription ended: %w", context.Cause(sub.Context))
}
}
},
Commands: []*cli.Command{
{
Name: "send",
Usage: "sends a message to the chat",
ArgsUsage: "<relay>'<identifier> <message>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
msg := nostr.Event{
Kind: 9,
CreatedAt: nostr.Now(),
Content: strings.Join(c.Args().Tail(), " "),
Tags: nostr.Tags{
{"h", identifier},
},
}
if err := kr.SignEvent(ctx, &msg); err != nil {
return fmt.Errorf("failed to sign message: %w", err)
}
if r, err := sys.Pool.EnsureRelay(relay); err != nil {
return err
} else {
return r.Publish(ctx, msg)
}
},
},
},
},
{
Name: "forum",
Usage: "read group forum posts",
Description: "access group forum functionality.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
for evt := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{11},
Tags: nostr.TagMap{"#h": []string{identifier}},
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
title := evt.Tags.Find("title")
if title != nil {
stdout(colors.bold(title[1]))
} else {
stdout(colors.bold("<untitled>"))
}
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
stdout("by " + evt.PubKey.Hex() + " (" + color.HiBlueString(meta.ShortName()) + ") at " + evt.CreatedAt.Time().Format(time.DateTime))
stdout(evt.Content)
}
// TODO: see what to do about this
return nil
},
},
{
Name: "put-user",
Usage: "add a user to the group with optional roles",
ArgsUsage: "<relay>'<identifier>",
Flags: []cli.Flag{
&PubKeyFlag{
Name: "pubkey",
Required: true,
},
&cli.StringSliceFlag{
Name: "role",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
return createModerationEvent(ctx, c, 9000, func(evt *nostr.Event, args []string) error {
pubkey := getPubKey(c, "pubkey")
tag := nostr.Tag{"p", pubkey.Hex()}
tag = append(tag, c.StringSlice("role")...)
evt.Tags = append(evt.Tags, tag)
return nil
})
},
},
{
Name: "remove-user",
Usage: "remove a user from the group",
ArgsUsage: "<relay>'<identifier> <pubkey>",
Flags: []cli.Flag{
&PubKeyFlag{
Name: "pubkey",
Required: true,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
return createModerationEvent(ctx, c, 9001, func(evt *nostr.Event, args []string) error {
pubkey := getPubKey(c, "pubkey")
evt.Tags = append(evt.Tags, nostr.Tag{"p", pubkey.Hex()})
return nil
})
},
},
{
Name: "edit-metadata",
Usage: "edits the group metadata",
ArgsUsage: "<relay>'<identifier>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
},
&cli.StringFlag{
Name: "about",
},
&cli.StringFlag{
Name: "picture",
},
&cli.BoolFlag{
Name: "restricted",
},
&cli.BoolFlag{
Name: "unrestricted",
},
&cli.BoolFlag{
Name: "closed",
},
&cli.BoolFlag{
Name: "open",
},
&cli.BoolFlag{
Name: "hidden",
},
&cli.BoolFlag{
Name: "visible",
},
&cli.BoolFlag{
Name: "private",
},
&cli.BoolFlag{
Name: "public",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
return createModerationEvent(ctx, c, 9002, func(evt *nostr.Event, args []string) error {
if name := c.String("name"); name != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"name", name})
}
if picture := c.String("picture"); picture != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"picture", picture})
}
if about := c.String("about"); about != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"about", about})
}
if c.Bool("restricted") {
evt.Tags = append(evt.Tags, nostr.Tag{"restricted"})
} else if c.Bool("unrestricted") {
evt.Tags = append(evt.Tags, nostr.Tag{"unrestricted"})
}
if c.Bool("closed") {
evt.Tags = append(evt.Tags, nostr.Tag{"closed"})
} else if c.Bool("open") {
evt.Tags = append(evt.Tags, nostr.Tag{"open"})
}
if c.Bool("hidden") {
evt.Tags = append(evt.Tags, nostr.Tag{"hidden"})
} else if c.Bool("visible") {
evt.Tags = append(evt.Tags, nostr.Tag{"visible"})
}
if c.Bool("private") {
evt.Tags = append(evt.Tags, nostr.Tag{"private"})
} else if c.Bool("public") {
evt.Tags = append(evt.Tags, nostr.Tag{"public"})
}
return nil
})
},
},
{
Name: "delete-event",
Usage: "delete an event from the group",
ArgsUsage: "<relay>'<identifier>",
Flags: []cli.Flag{
&IDFlag{
Name: "event",
Required: true,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
return createModerationEvent(ctx, c, 9005, func(evt *nostr.Event, args []string) error {
id := getID(c, "event")
evt.Tags = append(evt.Tags, nostr.Tag{"e", id.Hex()})
return nil
})
},
},
{
Name: "delete-group",
Usage: "deletes the group",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
return createModerationEvent(ctx, c, 9008, func(evt *nostr.Event, args []string) error {
return nil
})
},
},
{
Name: "create-invite",
Usage: "creates an invite code",
ArgsUsage: "<relay>'<identifier>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "code",
Required: true,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
return createModerationEvent(ctx, c, 9009, func(evt *nostr.Event, args []string) error {
evt.Tags = append(evt.Tags, nostr.Tag{"code", c.String("code")})
return nil
})
},
},
},
}
func createModerationEvent(ctx context.Context, c *cli.Command, kind nostr.Kind, setupFunc func(*nostr.Event, []string) error) error {
args := c.Args().Slice()
if len(args) < 1 {
return fmt.Errorf("requires group identifier")
}
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
evt := nostr.Event{
Kind: kind,
CreatedAt: nostr.Now(),
Content: "",
Tags: nostr.Tags{
{"h", identifier},
},
}
if err := setupFunc(&evt, args); err != nil {
return err
}
if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("failed to sign event: %w", err)
}
stdout(evt.String())
r, err := sys.Pool.EnsureRelay(relay)
if err != nil {
return err
}
return r.Publish(ctx, evt)
}
func cond(b bool, ifYes string, ifNo string) string {
if b {
return ifYes
}
return ifNo
}
func parseGroupIdentifier(c *cli.Command) (relay string, identifier string, err error) {
groupArg := c.Args().First()
if !strings.Contains(groupArg, "'") {
return "", "", fmt.Errorf("invalid group identifier format, expected <relay>'<identifier>")
}
parts := strings.SplitN(groupArg, "'", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("invalid group identifier format, expected <relay>'<identifier>")
}
return strings.TrimSuffix(parts[0], "/"), parts[1], nil
}

View File

@@ -536,21 +536,25 @@ func decodeTagValue(value string) string {
} }
var colors = struct { var colors = struct {
reset func(...any) (int, error) reset func(...any) (int, error)
italic func(...any) string italic func(...any) string
italicf func(string, ...any) string italicf func(string, ...any) string
bold func(...any) string bold func(...any) string
boldf func(string, ...any) string boldf func(string, ...any) string
error func(...any) string underline func(...any) string
errorf func(string, ...any) string underlinef func(string, ...any) string
success func(...any) string error func(...any) string
successf func(string, ...any) string errorf func(string, ...any) string
success func(...any) string
successf func(string, ...any) string
}{ }{
color.New(color.Reset).Print, color.New(color.Reset).Print,
color.New(color.Italic).Sprint, color.New(color.Italic).Sprint,
color.New(color.Italic).Sprintf, color.New(color.Italic).Sprintf,
color.New(color.Bold).Sprint, color.New(color.Bold).Sprint,
color.New(color.Bold).Sprintf, color.New(color.Bold).Sprintf,
color.New(color.Underline).Sprint,
color.New(color.Underline).Sprintf,
color.New(color.Bold, color.FgHiRed).Sprint, color.New(color.Bold, color.FgHiRed).Sprint,
color.New(color.Bold, color.FgHiRed).Sprintf, color.New(color.Bold, color.FgHiRed).Sprintf,
color.New(color.Bold, color.FgHiGreen).Sprint, color.New(color.Bold, color.FgHiGreen).Sprint,

View File

@@ -50,6 +50,7 @@ var app = &cli.Command{
fsCmd, fsCmd,
publish, publish,
git, git,
group,
nip, nip,
syncCmd, syncCmd,
spell, spell,