mirror of
https://github.com/fiatjaf/nak.git
synced 2026-10-05 15:18:23 +00:00
feat(bunker): bunker info fixed at bottom
This commit is contained in:
committed by
fiatjaf
parent
160afb540c
commit
7228b83705
@@ -6,11 +6,13 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -18,14 +20,95 @@ import (
|
|||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
"fiatjaf.com/nostr/nip19"
|
"fiatjaf.com/nostr/nip19"
|
||||||
|
"fiatjaf.com/nostr/nip42"
|
||||||
"fiatjaf.com/nostr/nip46"
|
"fiatjaf.com/nostr/nip46"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/mdp/qrterminal/v3"
|
"github.com/mdp/qrterminal/v3"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
const PERSISTENCE = "PERSISTENCE"
|
const PERSISTENCE = "PERSISTENCE"
|
||||||
|
|
||||||
|
type bunkerTerminal struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
out io.Writer
|
||||||
|
log func(string, ...any)
|
||||||
|
width int
|
||||||
|
footer string
|
||||||
|
footerRows int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBunkerTerminal(out io.Writer, log func(string, ...any), width int) *bunkerTerminal {
|
||||||
|
return &bunkerTerminal{out: out, log: log, width: width}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *bunkerTerminal) Log(msg string, args ...any) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if t.width == 0 || t.footer == "" {
|
||||||
|
t.log(msg, args...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.clearFooter()
|
||||||
|
text := fmt.Sprintf(msg, args...)
|
||||||
|
t.log("%s", text)
|
||||||
|
if !strings.HasSuffix(text, "\n") {
|
||||||
|
fmt.Fprintln(t.out)
|
||||||
|
}
|
||||||
|
t.drawFooter()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *bunkerTerminal) SetFooter(footer string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
footer = strings.TrimRight(footer, "\n") + "\n"
|
||||||
|
if t.width == 0 {
|
||||||
|
t.footer = footer
|
||||||
|
t.log("%s", footer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.clearFooter()
|
||||||
|
t.footer = footer
|
||||||
|
t.footerRows = bunkerFooterRows(footer, t.width)
|
||||||
|
t.drawFooter()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *bunkerTerminal) clearFooter() {
|
||||||
|
for range t.footerRows {
|
||||||
|
fmt.Fprint(t.out, "\033[1A\033[2K\r")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *bunkerTerminal) drawFooter() {
|
||||||
|
fmt.Fprint(t.out, t.footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bunkerFooterRows(footer string, width int) int {
|
||||||
|
rows := 0
|
||||||
|
for line := range strings.SplitSeq(strings.TrimSuffix(footer, "\n"), "\n") {
|
||||||
|
lineWidth := ansi.StringWidth(line)
|
||||||
|
rows += max(1, (lineWidth+width-1)/width)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func bunkerTerminalWidth() int {
|
||||||
|
if runtime.GOOS == "windows" || !term.IsTerminal(int(os.Stderr.Fd())) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
width, _, err := term.GetSize(int(os.Stderr.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
var bunker = &cli.Command{
|
var bunker = &cli.Command{
|
||||||
Name: "bunker",
|
Name: "bunker",
|
||||||
Usage: "starts a nip46 signer daemon with the given --sec key",
|
Usage: "starts a nip46 signer daemon with the given --sec key",
|
||||||
@@ -206,6 +289,24 @@ var bunker = &cli.Command{
|
|||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
footerWidth := 0
|
||||||
|
if c.Count("quiet") == 0 {
|
||||||
|
footerWidth = bunkerTerminalWidth()
|
||||||
|
}
|
||||||
|
terminal := newBunkerTerminal(color.Error, log, footerWidth)
|
||||||
|
sys.Pool.RelayOptions.NoticeHandler = func(relay *nostr.Relay, notice string) {
|
||||||
|
terminal.Log("NOTICE from %s: '%s'\n", relay.URL, notice)
|
||||||
|
}
|
||||||
|
sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error {
|
||||||
|
return authSigner(ctx, c, func(s string, args ...any) {
|
||||||
|
if strings.HasPrefix(s, "authenticating as") {
|
||||||
|
cleanURL, _ := strings.CutPrefix(nip42.GetRelayURLFromAuthEvent(*authEvent), "wss://")
|
||||||
|
s = "authenticating to " + color.CyanString(cleanURL) + " as" + s[len("authenticating as"):]
|
||||||
|
}
|
||||||
|
terminal.Log(s+"\n", args...)
|
||||||
|
}, authEvent)
|
||||||
|
}
|
||||||
|
|
||||||
// try to connect to the relays here
|
// try to connect to the relays here
|
||||||
qs := url.Values{}
|
qs := url.Values{}
|
||||||
allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
|
allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
|
||||||
@@ -236,19 +337,15 @@ var bunker = &cli.Command{
|
|||||||
// it will be stored
|
// it will be stored
|
||||||
newSecret := randString(12)
|
newSecret := randString(12)
|
||||||
|
|
||||||
// guards config.Clients, newSecret and cancelPreviousBunkerInfoPrint, which are
|
// guards config.Clients and newSecret, which are accessed from the socket
|
||||||
// accessed from the socket goroutine, the per-request handler goroutines and here
|
// goroutine, the per-request handler goroutines and here
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
|
|
||||||
// static information
|
// static information
|
||||||
pubkey := sec.Public()
|
pubkey := sec.Public()
|
||||||
npub := nip19.EncodeNpub(pubkey)
|
npub := nip19.EncodeNpub(pubkey)
|
||||||
|
|
||||||
// this function will be called every now and then
|
bunkerInfo := func() (string, string) {
|
||||||
printBunkerInfo := func() {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
iqs := make(url.Values)
|
iqs := make(url.Values)
|
||||||
maps.Copy(iqs, qs)
|
maps.Copy(iqs, qs)
|
||||||
iqs.Set("secret", newSecret)
|
iqs.Set("secret", newSecret)
|
||||||
@@ -309,7 +406,7 @@ var bunker = &cli.Command{
|
|||||||
strings.Join(relayURLsPossiblyWithoutSchema, " "),
|
strings.Join(relayURLsPossiblyWithoutSchema, " "),
|
||||||
)
|
)
|
||||||
|
|
||||||
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n to restart: %s\n bunker: %s\n\n",
|
return fmt.Sprintf("listening at %v:\n pubkey: %s \n npub: %s%s%s\n to restart: %s\n bunker: %s\n",
|
||||||
colors.bold(config.Relays),
|
colors.bold(config.Relays),
|
||||||
colors.bold(pubkey.Hex()),
|
colors.bold(pubkey.Hex()),
|
||||||
colors.bold(npub),
|
colors.bold(npub),
|
||||||
@@ -317,27 +414,32 @@ var bunker = &cli.Command{
|
|||||||
authorizedSecretsStr,
|
authorizedSecretsStr,
|
||||||
color.CyanString(restartCommand),
|
color.CyanString(restartCommand),
|
||||||
colors.bold(bunkerURI),
|
colors.bold(bunkerURI),
|
||||||
)
|
), bunkerURI
|
||||||
} else {
|
} else {
|
||||||
// otherwise just print the data
|
// otherwise just print the data
|
||||||
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n bunker: %s\n\n",
|
return fmt.Sprintf("listening at %v:\n pubkey: %s \n npub: %s%s%s\n bunker: %s\n",
|
||||||
colors.bold(config.Relays),
|
colors.bold(config.Relays),
|
||||||
colors.bold(pubkey.Hex()),
|
colors.bold(pubkey.Hex()),
|
||||||
colors.bold(npub),
|
colors.bold(npub),
|
||||||
authorizedKeysStr,
|
authorizedKeysStr,
|
||||||
authorizedSecretsStr,
|
authorizedSecretsStr,
|
||||||
colors.bold(bunkerURI),
|
colors.bold(bunkerURI),
|
||||||
)
|
), bunkerURI
|
||||||
}
|
|
||||||
|
|
||||||
// print QR code if requested
|
|
||||||
if c.Bool("qrcode") {
|
|
||||||
log("QR Code for bunker URI:\n")
|
|
||||||
qrterminal.Generate(bunkerURI, qrterminal.L, os.Stdout)
|
|
||||||
log("\n\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
printBunkerInfo()
|
|
||||||
|
setBunkerInfo := func() {
|
||||||
|
info, _ := bunkerInfo()
|
||||||
|
terminal.SetFooter(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, bunkerURI := bunkerInfo()
|
||||||
|
if c.Bool("qrcode") {
|
||||||
|
log("QR Code for bunker URI:\n")
|
||||||
|
qrterminal.Generate(bunkerURI, qrterminal.L, os.Stdout)
|
||||||
|
log("\n\n")
|
||||||
|
}
|
||||||
|
terminal.SetFooter(info)
|
||||||
|
|
||||||
// subscribe to relays
|
// subscribe to relays
|
||||||
events := sys.Pool.SubscribeMany(ctx, allRelays, nostr.Filter{
|
events := sys.Pool.SubscribeMany(ctx, allRelays, nostr.Filter{
|
||||||
@@ -350,26 +452,21 @@ var bunker = &cli.Command{
|
|||||||
signer := nip46.NewStaticKeySigner(sec)
|
signer := nip46.NewStaticKeySigner(sec)
|
||||||
signer.DefaultRelays = config.Relays
|
signer.DefaultRelays = config.Relays
|
||||||
|
|
||||||
// just a gimmick
|
|
||||||
var cancelPreviousBunkerInfoPrint context.CancelFunc
|
|
||||||
_, cancel := context.WithCancel(ctx)
|
|
||||||
cancelPreviousBunkerInfoPrint = cancel
|
|
||||||
|
|
||||||
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
|
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
|
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
|
||||||
|
mu.Unlock()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if slices.Contains(authorizedSecrets, secret) {
|
if slices.Contains(authorizedSecrets, secret) {
|
||||||
// add client to authorized list for subsequent requests
|
// add client to authorized list for subsequent requests
|
||||||
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == from }) {
|
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
|
||||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
|
if persist != nil {
|
||||||
if persist != nil {
|
persist()
|
||||||
persist()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
setBunkerInfo()
|
||||||
|
mu.Unlock()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,27 +475,21 @@ var bunker = &cli.Command{
|
|||||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: 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
|
|
||||||
go func() {
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
printBunkerInfo()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if persist != nil {
|
if persist != nil {
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBunkerInfo()
|
||||||
|
mu.Unlock()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mu.Unlock()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBunkerRequest := func(ie nostr.RelayEvent) {
|
handleBunkerRequest := func(ie nostr.RelayEvent) {
|
||||||
mu.Lock()
|
|
||||||
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
// handle the NIP-46 request event
|
// handle the NIP-46 request event
|
||||||
from := ie.Event.PubKey
|
from := ie.Event.PubKey
|
||||||
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
|
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
|
||||||
@@ -407,14 +498,14 @@ var bunker = &cli.Command{
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
|
terminal.Log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jreq, _ := json.MarshalIndent(req, "", " ")
|
jreq, _ := json.MarshalIndent(req, "", " ")
|
||||||
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
|
terminal.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))
|
terminal.Log("~ responding with %s\n", string(jresp))
|
||||||
|
|
||||||
// use custom relays if they are defined for this client
|
// use custom relays if they are defined for this client
|
||||||
// (normally if the initial connection came from a nostrconnect:// URL)
|
// (normally if the initial connection came from a nostrconnect:// URL)
|
||||||
@@ -430,43 +521,27 @@ var bunker = &cli.Command{
|
|||||||
|
|
||||||
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
||||||
if res.Error == nil {
|
if res.Error == nil {
|
||||||
log("* sent response through %s\n", res.Relay.URL)
|
terminal.Log("* sent response through %s\n", res.Relay.URL)
|
||||||
} else {
|
} else {
|
||||||
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
|
terminal.Log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// just after handling one request we trigger this
|
|
||||||
go func() {
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
|
||||||
defer cancel()
|
|
||||||
mu.Lock()
|
|
||||||
cancelPreviousBunkerInfoPrint = cancel
|
|
||||||
mu.Unlock()
|
|
||||||
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
|
|
||||||
// but we will only do if the bunker is inactive for more than 5 minutes
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
case <-time.After(time.Minute * 5):
|
|
||||||
log("\n")
|
|
||||||
printBunkerInfo()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// unix socket nostrconnect:// handling
|
// unix socket nostrconnect:// handling
|
||||||
go func() {
|
go func() {
|
||||||
for uri := range onSocketConnect(ctx, c) {
|
for uri := range onSocketConnect(ctx, c, terminal.Log) {
|
||||||
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
|
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
|
terminal.Log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
|
||||||
|
|
||||||
relays := uri.Query()["relay"]
|
relays := uri.Query()["relay"]
|
||||||
|
|
||||||
// pre-authorize this client since the user has explicitly added it
|
// pre-authorize this client since the user has explicitly added it
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
|
clientAdded := false
|
||||||
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
|
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
|
||||||
return c.PubKey == clientPublicKey
|
return c.PubKey == clientPublicKey
|
||||||
}) {
|
}) {
|
||||||
@@ -477,16 +552,20 @@ var bunker = &cli.Command{
|
|||||||
Icon: uri.Query().Get("icon"),
|
Icon: uri.Query().Get("icon"),
|
||||||
CustomRelays: relays,
|
CustomRelays: relays,
|
||||||
})
|
})
|
||||||
|
clientAdded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if persist != nil {
|
if persist != nil {
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
if clientAdded {
|
||||||
|
setBunkerInfo()
|
||||||
|
}
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
||||||
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
|
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log("* failed to handle: %s\n", err)
|
terminal.Log("* failed to handle: %s\n", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,12 +584,12 @@ var bunker = &cli.Command{
|
|||||||
|
|
||||||
time.Sleep(time.Millisecond * 25)
|
time.Sleep(time.Millisecond * 25)
|
||||||
jresp, _ := json.MarshalIndent(resp, "", " ")
|
jresp, _ := json.MarshalIndent(resp, "", " ")
|
||||||
log("~ responding with %s\n", string(jresp))
|
terminal.Log("~ responding with %s\n", string(jresp))
|
||||||
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
||||||
if res.Error == nil {
|
if res.Error == nil {
|
||||||
log("* sent through %s\n", res.Relay.URL)
|
terminal.Log("* sent through %s\n", res.Relay.URL)
|
||||||
} else {
|
} else {
|
||||||
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
|
terminal.Log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -641,7 +720,7 @@ func getSocketPath(c *cli.Command) string {
|
|||||||
return filepath.Join(c.String("config-path"), "bunkerconn", profile)
|
return filepath.Join(c.String("config-path"), "bunkerconn", profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
|
func onSocketConnect(ctx context.Context, c *cli.Command, log func(string, ...any)) chan *url.URL {
|
||||||
res := make(chan *url.URL)
|
res := make(chan *url.URL)
|
||||||
socketPath := getSocketPath(c)
|
socketPath := getSocketPath(c)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
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
|
||||||
|
github.com/charmbracelet/x/ansi v0.8.0
|
||||||
github.com/charmbracelet/glamour v0.10.0
|
github.com/charmbracelet/glamour v0.10.0
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
||||||
@@ -51,7 +52,6 @@ require (
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
|||||||
Reference in New Issue
Block a user