diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..80e0033 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Agent Guidelines + +## Tests + +Do not write policy assertion tests. + +It is acceptable to set configuration to a particular value and test whether the resulting behavior works or fails. + +It is not acceptable to test how configuration itself is set, including shipped defaults, environment-specific values, or whether a feature is enabled or disabled by default. + +Test behavior under configuration, not configuration policy. diff --git a/README.md b/README.md index 322bcd5..5d5a143 100644 --- a/README.md +++ b/README.md @@ -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.2.5 +curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.0 curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --install-dir "$HOME/bin" --force ``` @@ -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.2.5 -Force +Install-Tollbit -Version v0.3.0 -Force Install-Tollbit -NoModifyPath -PrintPathInstructions ``` @@ -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.2.5 --force +curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.0 --force ``` ```powershell diff --git a/internal/app/app.go b/internal/app/app.go index 80f2e5f..8aea9b9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,6 +9,7 @@ import ( "github.com/tollbit/cli/internal/agentauth/agentconfirmsicons" "github.com/tollbit/cli/internal/agentauth/browserselecticon" "github.com/tollbit/cli/internal/agentauth/redirect" + "github.com/tollbit/cli/internal/client/analytics" "github.com/tollbit/cli/internal/client/auth" "github.com/tollbit/cli/internal/client/tollbit" "github.com/tollbit/cli/internal/cliruntime" @@ -17,6 +18,7 @@ import ( ) type Dependencies struct { + Analytics analytics.Client Auth *auth.Client Tollbit tollbit.Client OBOAuthorizer agentauth.OBOAuthorizer @@ -30,6 +32,7 @@ type App struct { deps Dependencies auth func() (*auth.Client, error) + analytics func() (analytics.Client, error) tollbit func() (tollbit.Client, error) oboAuthorizer func() (agentauth.OBOAuthorizer, error) credentials func() (*agenttoken.CredentialManager, error) @@ -46,6 +49,7 @@ func New(config configuration.Config, opts ...Option) (*App, error) { deps: cfg.dependencies, } a.auth = sync.OnceValues(a.buildAuth) + a.analytics = sync.OnceValues(a.buildAnalytics) a.tollbit = sync.OnceValues(a.buildTollbit) a.oboAuthorizer = sync.OnceValues(a.buildOBOAuthorizer) a.credentials = sync.OnceValues(a.buildCredentials) @@ -61,6 +65,21 @@ func (a *App) Auth() (*auth.Client, error) { return a.auth() } +func (a *App) Analytics() (analytics.Client, error) { + return a.analytics() +} + +func (a *App) buildAnalytics() (analytics.Client, error) { + if a.deps.Analytics != nil { + return a.deps.Analytics, nil + } + client, err := analytics.NewClient(analytics.Config{BaseURL: a.config.Analytics.BaseURL}) + if err != nil { + return nil, fmt.Errorf("build analytics client: %w", err) + } + return client, nil +} + func (a *App) buildAuth() (*auth.Client, error) { if a.deps.Auth != nil { return a.deps.Auth, nil diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 799200c..9607753 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1,10 +1,13 @@ package app import ( + "context" "testing" "github.com/tollbit/cli/internal/agentauth" + "github.com/tollbit/cli/internal/client/analytics" "github.com/tollbit/cli/internal/configuration" + "github.com/tollbit/cli/internal/tokens/agent" ) func TestNewExposesConfigAndBuildsClients(t *testing.T) { @@ -17,6 +20,9 @@ func TestNewExposesConfigAndBuildsClients(t *testing.T) { if _, err := app.Auth(); err != nil { t.Fatalf("expected auth client: %v", err) } + if _, err := app.Analytics(); err != nil { + t.Fatalf("expected analytics client: %v", err) + } if _, err := app.Tollbit(); err != nil { t.Fatalf("expected tollbit client: %v", err) } @@ -31,6 +37,22 @@ func TestNewExposesConfigAndBuildsClients(t *testing.T) { } } +func TestNewUsesInjectedAnalytics(t *testing.T) { + fake := &fakeAnalytics{} + application, err := New(testConfig(t), OverrideDependencies(Dependencies{Analytics: fake})) + if err != nil { + t.Fatal(err) + } + + analyticsClient, err := application.Analytics() + if err != nil { + t.Fatal(err) + } + if analyticsClient != fake { + t.Fatal("expected injected analytics client") + } +} + func TestNewBuildsBrowserSelectIconAuthorizer(t *testing.T) { config := testConfig(t) config.Runtime.EndUserProximity = configuration.RuntimeEndUserProximityRemote @@ -112,7 +134,8 @@ func testConfig(t *testing.T) configuration.Config { App: configuration.AppConfig{ Name: "test-cli", }, - Runtime: configuration.RuntimeConfig{EndUserProximity: configuration.RuntimeEndUserProximityLocal, StateDir: t.TempDir()}, + Analytics: configuration.AnalyticsConfig{BaseURL: "https://analytics.example"}, + Runtime: configuration.RuntimeConfig{EndUserProximity: configuration.RuntimeEndUserProximityLocal, StateDir: t.TempDir()}, Auth: configuration.AuthConfig{ BaseURL: "https://auth.example", Consent: configuration.ConsentConfig{ @@ -133,6 +156,12 @@ func testConfig(t *testing.T) configuration.Config { } } +type fakeAnalytics struct{} + +func (f *fakeAnalytics) Query(context.Context, analytics.QueryRequest, agent.Token) (analytics.QueryResponse, error) { + return analytics.QueryResponse{}, nil +} + func TestBuildConsentStrategyAgentConfirmsIcons(t *testing.T) { config := testConfig(t) config.Runtime.EndUserProximity = configuration.RuntimeEndUserProximityRemote diff --git a/internal/cli/analytics.go b/internal/cli/analytics.go new file mode 100644 index 0000000..c4b1ae0 --- /dev/null +++ b/internal/cli/analytics.go @@ -0,0 +1,77 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/tollbit/cli/internal/app" + analyticsclient "github.com/tollbit/cli/internal/client/analytics" + "github.com/tollbit/cli/internal/credentials/agenttoken" +) + +func NewAnalyticsCommand(factory app.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "analytics", + Short: "Query TollBit analytics", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return UsageError("analytics requires a subcommand") + } + return UsageError("unknown analytics command %q", args[0]) + }, + } + cmd.AddCommand(NewAnalyticsQueryCommand(factory)) + return cmd +} + +func NewAnalyticsQueryCommand(factory app.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "query ", + Short: "Execute an analytics SQL query", + Example: " tollbit analytics query 'SELECT * FROM logs LIMIT 10'", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return UsageError("analytics query requires ") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runAnalyticsQuery(cmd, factory, args[0]) + }, + } + cmd.Flags().String("user-agent", "", "user agent for request") + return cmd +} + +func runAnalyticsQuery(cmd *cobra.Command, factory app.Factory, sql string) 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.Query(cmd.Context(), analyticsclient.QueryRequest{SQL: sql}, token) + if err != nil { + return RuntimeError(fmt.Errorf("error querying analytics: %w", err)) + } + if err := writeJSON(cmd.OutOrStdout(), result); err != nil { + return RuntimeError(fmt.Errorf("error writing analytics response: %w", err)) + } + return nil +} diff --git a/internal/cli/analytics_test.go b/internal/cli/analytics_test.go new file mode 100644 index 0000000..3686058 --- /dev/null +++ b/internal/cli/analytics_test.go @@ -0,0 +1,98 @@ +package cli + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAnalyticsCommandDisabled(t *testing.T) { + config := testConfig() + config.Analytics.Enabled = false + + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, []string{"analytics", "query", "SELECT 1"}, nil, &stdout, &stderr) + if code != 2 { + t.Fatalf("expected usage exit code, got %d (stderr=%q)", code, stderr.String()) + } + if !strings.Contains(stderr.String(), `unknown command "analytics"`) { + t.Fatalf("expected disabled command to be unknown, got %q", stderr.String()) + } +} + +func TestAnalyticsQueryUsesOBOAgentTokenAndWritesJSON(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.MethodPost || r.URL.Path != "/analytics/agent/v1/query" { + t.Fatalf("unexpected analytics request: %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer "+token { + t.Fatal("unexpected authorization header") + } + var request struct { + SQL string `json:"sql"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + if request.SQL != "SELECT * FROM logs" { + t.Fatalf("unexpected SQL: %q", request.SQL) + } + _, _ = w.Write([]byte(`{"columns":[{"name":"requests","type":"INTEGER"},{"name":"optional","type":"STRING"}],"rows":[[42,null]]}`)) + })) + 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", "query", "SELECT * FROM logs"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected success, got %d (stderr=%q)", code, stderr.String()) + } + var output struct { + Columns []struct { + Name string `json:"name"` + Type string `json:"type"` + } `json:"columns"` + Rows [][]any `json:"rows"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("invalid JSON output %q: %v", stdout.String(), err) + } + if len(output.Columns) != 2 || output.Columns[0].Name != "requests" { + t.Fatalf("unexpected columns: %#v", output.Columns) + } + if len(output.Rows) != 1 || output.Rows[0][0] != float64(42) || output.Rows[0][1] != nil { + t.Fatalf("unexpected rows: %#v", output.Rows) + } +} + +func TestAnalyticsQueryRequiresOneSQLArgument(t *testing.T) { + config := testConfig() + config.Analytics.Enabled = true + config.Analytics.BaseURL = "https://analytics.example" + + for _, args := range [][]string{ + {"analytics", "query"}, + {"analytics", "query", "SELECT", "1"}, + } { + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, args, nil, &stdout, &stderr) + if code != 2 || !strings.Contains(stderr.String(), "analytics query requires ") { + t.Fatalf("expected SQL usage error for %#v, got code=%d stderr=%q", args, code, stderr.String()) + } + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index f2cbfe3..ce7a485 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -100,7 +100,8 @@ func testConfig() configuration.Config { App: configuration.AppConfig{ Name: "tollbit", }, - Runtime: configuration.RuntimeConfig{EndUserProximity: configuration.RuntimeEndUserProximityLocal, StateDir: storageDir}, + Analytics: configuration.AnalyticsConfig{BaseURL: "https://gateway.tollbit.com"}, + Runtime: configuration.RuntimeConfig{EndUserProximity: configuration.RuntimeEndUserProximityLocal, StateDir: storageDir}, Auth: configuration.AuthConfig{ BaseURL: authBaseURL, UseRefreshTokens: true, diff --git a/internal/cli/index.go b/internal/cli/index.go index ef8d5b9..fe66e6d 100644 --- a/internal/cli/index.go +++ b/internal/cli/index.go @@ -16,5 +16,8 @@ func NewCommandTree(factory app.Factory) *cobra.Command { NewGuideCommand(factory), NewVersionCommand(), ) + if factory.Config.Analytics.Enabled { + rootCmd.AddCommand(NewAnalyticsCommand(factory)) + } return rootCmd } diff --git a/internal/client/analytics/client.go b/internal/client/analytics/client.go new file mode 100644 index 0000000..e894fa9 --- /dev/null +++ b/internal/client/analytics/client.go @@ -0,0 +1,103 @@ +package analytics + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/tollbit/cli/internal/errorsx" + "github.com/tollbit/cli/internal/tokens/agent" +) + +const queryPath = "/analytics/agent/v1/query" + +type ( + Config struct { + BaseURL string + } + + Client interface { + Query(context.Context, QueryRequest, agent.Token) (QueryResponse, error) + } + + client struct { + baseURL *url.URL + http *http.Client + } + + QueryRequest struct { + SQL string `json:"sql"` + } + + QueryColumn struct { + Name string `json:"name"` + Type string `json:"type"` + } + + QueryResponse struct { + Columns []QueryColumn `json:"columns"` + Rows [][]any `json:"rows"` + } +) + +var _ Client = (*client)(nil) + +func NewClient(cfg Config) (Client, error) { + baseURL := strings.TrimSpace(cfg.BaseURL) + if baseURL == "" { + return nil, errors.New("analytics base URL is required") + } + parsed, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + return &client{ + baseURL: parsed, + http: &http.Client{Timeout: 30 * time.Second}, + }, nil +} + +func (c *client) Query(ctx context.Context, request QueryRequest, token agent.Token) (QueryResponse, error) { + if strings.TrimSpace(token.RawToken) == "" { + return QueryResponse{}, errors.New("agent token is required") + } + if err := token.Validate(); err != nil { + return QueryResponse{}, err + } + + body := new(bytes.Buffer) + if err := json.NewEncoder(body).Encode(request); err != nil { + return QueryResponse{}, err + } + u := *c.baseURL + u.Path = strings.TrimRight(c.baseURL.Path, "/") + queryPath + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), body) + if err != nil { + return QueryResponse{}, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token.RawToken) + + resp, err := c.http.Do(req) + if err != nil { + return QueryResponse{}, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return QueryResponse{}, errorsx.ParseResponseError(ctx, resp.Status, resp.StatusCode, resp.Header, body) + } + + var result QueryResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return QueryResponse{}, err + } + return result, nil +} diff --git a/internal/client/analytics/client_test.go b/internal/client/analytics/client_test.go new file mode 100644 index 0000000..8d36fd9 --- /dev/null +++ b/internal/client/analytics/client_test.go @@ -0,0 +1,117 @@ +package analytics + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/tollbit/cli/internal/errorsx/problemjson" + "github.com/tollbit/cli/internal/tokens/agent" +) + +func TestQuery(t *testing.T) { + token := validAgentToken(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/prefix/analytics/agent/v1/query" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Accept") != "application/json" || r.Header.Get("Content-Type") != "application/json" { + t.Fatalf("unexpected content headers: %#v", r.Header) + } + if r.Header.Get("Authorization") != "Bearer "+token.RawToken { + t.Fatal("unexpected authorization header") + } + var request QueryRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + if request.SQL != "SELECT * FROM logs" { + t.Fatalf("unexpected SQL: %q", request.SQL) + } + _ = json.NewEncoder(w).Encode(QueryResponse{ + Columns: []QueryColumn{{Name: "requests", Type: "INTEGER"}, {Name: "optional", Type: "STRING"}}, + Rows: [][]any{{42, nil}}, + }) + })) + defer srv.Close() + + client, err := NewClient(Config{BaseURL: " " + srv.URL + "/prefix "}) + if err != nil { + t.Fatal(err) + } + response, err := client.Query(context.Background(), QueryRequest{SQL: "SELECT * FROM logs"}, token) + if err != nil { + t.Fatal(err) + } + if len(response.Columns) != 2 || response.Columns[0].Name != "requests" { + t.Fatalf("unexpected columns: %#v", response.Columns) + } + if len(response.Rows) != 1 || response.Rows[0][0] != float64(42) || response.Rows[0][1] != nil { + t.Fatalf("unexpected rows: %#v", response.Rows) + } +} + +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") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"title":"Forbidden","status":403,"detail":"organization access required"}`)) + })) + defer srv.Close() + + client, err := NewClient(Config{BaseURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + _, err = client.Query(context.Background(), QueryRequest{SQL: "SELECT 1"}, validAgentToken(t)) + var problem problemjson.Problem + if !errors.As(err, &problem) { + t.Fatalf("expected ProblemJSON error, got %v", err) + } + if problem.RequestID == nil || *problem.RequestID != "request-123" { + t.Fatalf("unexpected request ID: %#v", problem.RequestID) + } +} + +func TestQueryRejectsMissingToken(t *testing.T) { + client, err := NewClient(Config{BaseURL: "https://analytics.example"}) + if err != nil { + t.Fatal(err) + } + if _, err := client.Query(context.Background(), QueryRequest{SQL: "SELECT 1"}, agent.Token{}); err == nil { + t.Fatal("expected missing token error") + } +} + +func TestNewClientRequiresBaseURL(t *testing.T) { + if _, err := NewClient(Config{}); err == nil { + t.Fatal("expected missing base URL error") + } +} + +func validAgentToken(t *testing.T) agent.Token { + t.Helper() + claims := struct { + jwt.RegisteredClaims + TBT string `json:"tbt"` + }{ + RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))}, + TBT: "agent-token", + } + header, err := json.Marshal(map[string]any{"alg": "none"}) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + signature := base64.RawURLEncoding.EncodeToString([]byte("signature")) + return agent.Token{RawToken: base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + "." + signature} +} diff --git a/internal/configuration/config.go b/internal/configuration/config.go index dabb71b..582298b 100644 --- a/internal/configuration/config.go +++ b/internal/configuration/config.go @@ -117,6 +117,9 @@ func validate(config Config) error { if strings.TrimSpace(config.App.Name) == "" { return errors.New("app.name is required") } + if config.Analytics.Enabled && strings.TrimSpace(config.Analytics.BaseURL) == "" { + return errors.New("analytics.base_url is required when analytics is enabled") + } if strings.TrimSpace(config.Auth.BaseURL) == "" { return errors.New("auth.base_url is required") } diff --git a/internal/configuration/config_test.go b/internal/configuration/config_test.go index 2581144..cb97c57 100644 --- a/internal/configuration/config_test.go +++ b/internal/configuration/config_test.go @@ -188,6 +188,15 @@ func TestValidateConsentStrategyAcceptsAgentConfirmsIcons(t *testing.T) { } } +func TestValidateRequiresAnalyticsBaseURLWhenEnabled(t *testing.T) { + config := assembleTestConfiguration(t, t.TempDir()) + config.Analytics.Enabled = true + config.Analytics.BaseURL = "" + if err := validate(config); err == nil { + t.Fatal("expected enabled analytics to require a base URL") + } +} + func assembleTestConfiguration(t *testing.T, wd string) Config { t.Helper() config, err := assembleConfiguration(readTestdata(t, "default-config.yaml"), func() (string, error) { return wd, nil }) diff --git a/internal/configuration/models.go b/internal/configuration/models.go index f19d08c..f41cb77 100644 --- a/internal/configuration/models.go +++ b/internal/configuration/models.go @@ -6,6 +6,7 @@ type Config struct { App AppConfig Runtime RuntimeConfig Auth AuthConfig + Analytics AnalyticsConfig Agent AgentConfig Credentials CredentialsConfig Gateway GatewayConfig @@ -29,6 +30,11 @@ type AuthConfig struct { BrowserConsent BrowserConsentConfig `mapstructure:"browser_consent"` } +type AnalyticsConfig struct { + Enabled bool `mapstructure:"enabled" configurable:"dev"` + BaseURL string `mapstructure:"base_url" configurable:"dev"` +} + type ConsentConfig struct { Strategy ConsentStrategyConfig `mapstructure:"strategy"` } @@ -59,6 +65,7 @@ type BrowserConsentConfig struct { } type OverrideOptions struct { + AnalyticsBaseURL *string AuthBaseURL *string AuthRetryOnOBORequired *bool AuthTokenTTLSeconds *int32 @@ -73,6 +80,9 @@ type OverrideOptions struct { } func (c Config) WithOverrides(opts OverrideOptions) (Config, error) { + if opts.AnalyticsBaseURL != nil { + c.Analytics.BaseURL = *opts.AnalyticsBaseURL + } if opts.AuthBaseURL != nil { c.Auth.BaseURL = *opts.AuthBaseURL } diff --git a/internal/configuration/testdata/default-config.yaml b/internal/configuration/testdata/default-config.yaml index f2fe61c..94befb5 100644 --- a/internal/configuration/testdata/default-config.yaml +++ b/internal/configuration/testdata/default-config.yaml @@ -1,5 +1,8 @@ app: name: tollbit +analytics: + enabled: false + base_url: https://gateway.tollbit.com runtime: end_user_proximity: auto-detect state_dir: __default__ diff --git a/internal/version/version.go b/internal/version/version.go index ed98e9c..8bae7bb 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -2,4 +2,4 @@ // Bump this when shipping; keep skill frontmatter `version` in sync (tests enforce it). package version -const Version = "0.2.5" +const Version = "0.3.0" diff --git a/skill/tollbit-cli/SKILL.md b/skill/tollbit-cli/SKILL.md index 186746e..305d8b9 100644 --- a/skill/tollbit-cli/SKILL.md +++ b/skill/tollbit-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: tollbit-cli -version: 0.2.5 +version: 0.3.0 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. --- diff --git a/tb-cli.config.development.yaml b/tb-cli.config.development.yaml index 5caecd4..cd84f70 100644 --- a/tb-cli.config.development.yaml +++ b/tb-cli.config.development.yaml @@ -2,6 +2,9 @@ # auth: base_url: http://oauth.localhost:7011 +analytics: + enabled: true + base_url: http://analytics.localhost:7011 gateway: base_url: http://tollbit.localhost:7011 # auth: diff --git a/tb-cli.config.yaml b/tb-cli.config.yaml index e6b9b23..d3af71c 100644 --- a/tb-cli.config.yaml +++ b/tb-cli.config.yaml @@ -1,5 +1,8 @@ app: name: tollbit +analytics: + enabled: true + base_url: https://gateway.tollbit.com runtime: end_user_proximity: auto-detect state_dir: __default__