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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.s
Pin a version or choose an install directory:

```bash
curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.1
curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.2
curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --install-dir "$HOME/bin" --force
```

Expand All @@ -37,7 +37,7 @@ Pin a version or skip `PATH` changes (useful in CI):

```powershell
irm "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.ps1" | iex
Install-Tollbit -Version v0.3.1 -Force
Install-Tollbit -Version v0.3.2 -Force
Install-Tollbit -NoModifyPath -PrintPathInstructions
```

Expand Down Expand Up @@ -167,7 +167,7 @@ Installer channel updates:
curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash

# Pinned
curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.1 --force
curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.2 --force
```

```powershell
Expand Down
4 changes: 4 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ func (f *fakeAnalytics) Query(context.Context, analytics.QueryRequest, agent.Tok
return analytics.QueryResponse{}, nil
}

func (f *fakeAnalytics) Schema(context.Context, agent.Token) ([]analytics.QueryTable, error) {
return nil, nil
}

func TestBuildConsentStrategyAgentConfirmsIcons(t *testing.T) {
config := testConfig(t)
config.Runtime.EndUserProximity = configuration.RuntimeEndUserProximityRemote
Expand Down
52 changes: 52 additions & 0 deletions internal/cli/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func NewAnalyticsCommand(factory app.Factory) *cobra.Command {
},
}
cmd.AddCommand(NewAnalyticsQueryCommand(factory))
cmd.AddCommand(NewAnalyticsSchemaCommand(factory))
return cmd
}

Expand Down Expand Up @@ -75,3 +76,54 @@ func runAnalyticsQuery(cmd *cobra.Command, factory app.Factory, sql string) erro
}
return nil
}

func NewAnalyticsSchemaCommand(factory app.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "schema",
Short: "List available analytics tables and columns",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 0 {
return UsageError("analytics schema accepts no arguments")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return runAnalyticsSchema(cmd, factory)
},
}
cmd.Flags().String("user-agent", "", "user agent for request")
return cmd
}

func runAnalyticsSchema(cmd *cobra.Command, factory app.Factory) error {
application, err := appForCommand(factory, cmd)
if err != nil {
return RuntimeError(err)
}
credentials, err := application.Credentials()
if err != nil {
return RuntimeError(err)
}
analyticsClient, err := application.Analytics()
if err != nil {
return RuntimeError(err)
}
identity, err := credentials.ResolveIdentity(cmd.Context(), agenttoken.ResolveIdentityOptions{
UserAgent: flagChangedStr(cmd, "user-agent"),
})
if err != nil {
return RuntimeError(fmt.Errorf("error resolving identity: %w", err))
}
token, err := credentials.GetAgentToken(cmd, identity, agenttoken.WithOBO())
if err != nil {
return RuntimeError(fmt.Errorf("error fetching agent token: %w", err))
}
result, err := analyticsClient.Schema(cmd.Context(), token)
if err != nil {
return RuntimeError(fmt.Errorf("error fetching analytics schema: %w", err))
}
if err := writeJSON(cmd.OutOrStdout(), result); err != nil {
return RuntimeError(fmt.Errorf("error writing analytics schema: %w", err))
}
return nil
}
46 changes: 46 additions & 0 deletions internal/cli/analytics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,52 @@ func TestAnalyticsQueryUsesOBOAgentTokenAndWritesJSON(t *testing.T) {
}
}

func TestAnalyticsSchemaUsesOBOAgentTokenAndWritesJSON(t *testing.T) {
token := testAgentJWTWithOBO(t)
storageDir := t.TempDir()
if err := os.WriteFile(filepath.Join(storageDir, "agent-token.jwt"), []byte(token), 0o600); err != nil {
t.Fatal(err)
}
analyticsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/analytics/agent/v1/query/schema" {
t.Fatalf("unexpected analytics request: %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer "+token {
t.Fatal("unexpected authorization header")
}
_, _ = w.Write([]byte(`[{"name":"agent_logs_by_page","columns":[{"name":"host","type":"STRING"}]}]`))
}))
defer analyticsSrv.Close()

config := testConfig()
config.Analytics.Enabled = true
config.Analytics.BaseURL = analyticsSrv.URL
config.Credentials.StorageDir = storageDir
config.Runtime.StateDir = storageDir

var stdout, stderr bytes.Buffer
code := executeTestCommandWithConfig(config, []string{"analytics", "schema"}, nil, &stdout, &stderr)
if code != 0 {
t.Fatalf("expected success, got %d (stderr=%q)", code, stderr.String())
}
var output []struct {
Name string `json:"name"`
Columns []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"columns"`
}
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatalf("invalid JSON output %q: %v", stdout.String(), err)
}
if len(output) != 1 || output[0].Name != "agent_logs_by_page" {
t.Fatalf("unexpected tables: %#v", output)
}
if len(output[0].Columns) != 1 || output[0].Columns[0].Name != "host" {
t.Fatalf("unexpected columns: %#v", output[0].Columns)
}
}

func TestAnalyticsQueryRequiresOneSQLArgument(t *testing.T) {
config := testConfig()
config.Analytics.Enabled = true
Expand Down
45 changes: 44 additions & 1 deletion internal/client/analytics/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import (
"github.com/tollbit/cli/internal/tokens/agent"
)

const queryPath = "/analytics/agent/v1/query"
const (
queryPath = "/analytics/agent/v1/query"
schemaPath = queryPath + "/schema"
)

type (
Config struct {
Expand All @@ -24,6 +27,7 @@ type (

Client interface {
Query(context.Context, QueryRequest, agent.Token) (QueryResponse, error)
Schema(context.Context, agent.Token) ([]QueryTable, error)
}

client struct {
Expand All @@ -44,6 +48,11 @@ type (
Columns []QueryColumn `json:"columns"`
Rows [][]any `json:"rows"`
}

QueryTable struct {
Name string `json:"name"`
Columns []QueryColumn `json:"columns"`
}
)

var _ Client = (*client)(nil)
Expand Down Expand Up @@ -101,3 +110,37 @@ func (c *client) Query(ctx context.Context, request QueryRequest, token agent.To
}
return result, nil
}

func (c *client) Schema(ctx context.Context, token agent.Token) ([]QueryTable, error) {
if strings.TrimSpace(token.RawToken) == "" {
return nil, errors.New("agent token is required")
}
if err := token.Validate(); err != nil {
return nil, err
}

u := *c.baseURL
u.Path = strings.TrimRight(c.baseURL.Path, "/") + schemaPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token.RawToken)

resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return nil, errorsx.ParseResponseError(ctx, resp.Status, resp.StatusCode, resp.Header, body)
}

var result []QueryTable
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
35 changes: 35 additions & 0 deletions internal/client/analytics/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,41 @@ func TestQuery(t *testing.T) {
}
}

func TestSchema(t *testing.T) {
token := validAgentToken(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/prefix/analytics/agent/v1/query/schema" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("Accept") != "application/json" {
t.Fatalf("unexpected accept header: %q", r.Header.Get("Accept"))
}
if r.Header.Get("Authorization") != "Bearer "+token.RawToken {
t.Fatal("unexpected authorization header")
}
_ = json.NewEncoder(w).Encode([]QueryTable{{
Name: "agent_logs_by_page",
Columns: []QueryColumn{{Name: "host", Type: "STRING"}},
}})
}))
defer srv.Close()

client, err := NewClient(Config{BaseURL: " " + srv.URL + "/prefix "})
if err != nil {
t.Fatal(err)
}
tables, err := client.Schema(context.Background(), token)
if err != nil {
t.Fatal(err)
}
if len(tables) != 1 || tables[0].Name != "agent_logs_by_page" {
t.Fatalf("unexpected tables: %#v", tables)
}
if len(tables[0].Columns) != 1 || tables[0].Columns[0].Name != "host" {
t.Fatalf("unexpected columns: %#v", tables[0].Columns)
}
}

func TestQueryParsesProblemJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Request-ID", "request-123")
Expand Down
2 changes: 1 addition & 1 deletion internal/version/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
// Bump this when shipping; keep skill frontmatter `version` in sync (tests enforce it).
package version

const Version = "0.3.1"
const Version = "0.3.2"
2 changes: 1 addition & 1 deletion skill/tollbit-cli/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: tollbit-cli
version: 0.3.1
version: 0.3.2
description: Search for news and articles and ground answers in licensed publisher content on the TollBit network. Use whenever the user wants to find news, articles, reporting, or sources on a topic or current event — searches the catalog, then prices and fetches full article content (paid) with the tollbit CLI.
---

Expand Down