Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions cmd/chain/chain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package chain

import (
"github.com/keeperhub/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)

// NewChainCmd creates the top-level chain command group.
func NewChainCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "chain",
Short: "Manage blockchain chains",
Aliases: []string{"ch"},
Example: ` # List supported chains
kh ch ls`,
}

cmd.AddCommand(NewListCmd(f))

return cmd
}
97 changes: 97 additions & 0 deletions cmd/chain/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package chain

import (
"encoding/json"
"fmt"
"io"
"net/http"

"github.com/jedib0t/go-pretty/v6/table"
khhttp "github.com/keeperhub/cli/internal/http"
"github.com/keeperhub/cli/internal/output"
"github.com/keeperhub/cli/internal/rpc"
"github.com/keeperhub/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)

// NewListCmd creates the chain list subcommand.
func NewListCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List supported blockchain chains",
Aliases: []string{"ls"},
Args: cobra.NoArgs,
Example: ` # List all chains
kh ch ls

# List chains as JSON
kh ch ls --json`,
RunE: func(cmd *cobra.Command, args []string) error {
chains, err := fetchChains(f, cmd)
if err != nil {
return err
}

p := output.NewPrinter(f.IOStreams, cmd)
if len(chains) == 0 && !p.IsJSON() {
fmt.Fprintln(f.IOStreams.Out, "No chains found.")
return nil
}
return p.PrintData(chains, func(tw table.Writer) {
tw.AppendHeader(table.Row{"CHAIN ID", "NAME", "TYPE", "ENABLED"})
for _, ch := range chains {
tw.AppendRow(table.Row{ch.ChainID, ch.Name, ch.Type, ch.IsEnabled})
}
tw.Render()
})
},
}

return cmd
}

// fetchChains calls /api/chains and caches the result for RPC resolution.
func fetchChains(f *cmdutil.Factory, cmd *cobra.Command) ([]rpc.ChainInfo, error) {
client, err := f.HTTPClient()
if err != nil {
return nil, fmt.Errorf("creating HTTP client: %w", err)
}

cfg, err := f.Config()
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}

host := cmdutil.ResolveHost(cmd, cfg)
url := khhttp.BuildBaseURL(host) + "/api/chains"

req, err := client.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}

resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, khhttp.NewAPIError(resp)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}

// Cache the chain data for RPC resolution
_ = rpc.CacheChains(json.RawMessage(body))

var chains []rpc.ChainInfo
if err := json.Unmarshal(body, &chains); err != nil {
return nil, fmt.Errorf("decoding chains response: %w", err)
}

return chains, nil
}
107 changes: 107 additions & 0 deletions cmd/chain/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package chain_test

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/keeperhub/cli/cmd/chain"
"github.com/keeperhub/cli/internal/config"
khhttp "github.com/keeperhub/cli/internal/http"
"github.com/keeperhub/cli/pkg/cmdutil"
"github.com/keeperhub/cli/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func newChainFactory(server *httptest.Server, ios *iostreams.IOStreams) *cmdutil.Factory {
client := khhttp.NewClient(khhttp.ClientOptions{Host: server.URL, AppVersion: "1.0.0"})
return &cmdutil.Factory{
AppVersion: "1.0.0",
IOStreams: ios,
HTTPClient: func() (*khhttp.Client, error) { return client, nil },
Config: func() (config.Config, error) { return config.Config{DefaultHost: server.URL}, nil },
}
}

func sampleChainsResponse() []map[string]interface{} {
return []map[string]interface{}{
{"chainId": 1, "name": "Ethereum", "type": "mainnet", "status": "active", "primaryRpcUrl": "https://eth.example.com", "fallbackRpcUrl": ""},
{"chainId": 137, "name": "Polygon", "type": "mainnet", "status": "active", "primaryRpcUrl": "https://polygon.example.com", "fallbackRpcUrl": ""},
{"chainId": 42161, "name": "Arbitrum", "type": "mainnet", "status": "active", "primaryRpcUrl": "", "fallbackRpcUrl": ""},
}
}

func TestChainListCmd(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())

called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/chains" {
called = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(sampleChainsResponse())
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer server.Close()

ios, outBuf, _, _ := iostreams.Test()
f := newChainFactory(server, ios)

cmd := chain.NewChainCmd(f)
cmd.SetArgs([]string{"list"})
err := cmd.Execute()
require.NoError(t, err)

assert.True(t, called, "expected GET /api/chains to be called")
out := outBuf.String()
assert.Contains(t, out, "Ethereum")
assert.Contains(t, out, "Polygon")
assert.Contains(t, out, "Arbitrum")
}

func TestChainListCmd_Empty(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]interface{}{})
}))
defer server.Close()

ios, outBuf, _, _ := iostreams.Test()
f := newChainFactory(server, ios)

cmd := chain.NewChainCmd(f)
cmd.SetArgs([]string{"ls"})
err := cmd.Execute()
require.NoError(t, err)
assert.Contains(t, outBuf.String(), "No chains found.")
}

func TestChainListCmd_ServerError(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "server error", http.StatusInternalServerError)
}))
defer server.Close()

ios, _, _, _ := iostreams.Test()
f := newChainFactory(server, ios)

cmd := chain.NewChainCmd(f)
cmd.SetArgs([]string{"ls"})
err := cmd.Execute()
assert.Error(t, err)
}

func TestChainCmd_HasAlias(t *testing.T) {
ios, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{AppVersion: "1.0.0", IOStreams: ios}
cmd := chain.NewChainCmd(f)
assert.Contains(t, cmd.Aliases, "ch")
}
12 changes: 9 additions & 3 deletions cmd/kh/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,19 @@ func main() {

hosts, err := config.ReadHosts()
if err != nil {
return nil, err
// If hosts.yml is unreadable, fall back to an unauthenticated
// client so public endpoints (chains, protocols, templates) work
// on a fresh install without login.
hosts = config.HostsConfig{}
}

// Resolve token using the auth chain: KH_API_KEY > keyring > hosts.yml
// Resolve token using the auth chain: KH_API_KEY > keyring > hosts.yml.
// If resolution fails (e.g. keyring unavailable on fresh install),
// proceed with an empty token; the HTTP client already skips the
// Authorization header when token is empty.
resolved, err := auth.ResolveToken(activeHost)
if err != nil {
return nil, err
resolved = auth.ResolvedToken{Token: "", Host: activeHost}
}

entry, _ := hosts.HostEntry(activeHost)
Expand Down
Loading
Loading