Skip to content
Merged
12 changes: 12 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type clientConfig struct {
// who pass WithRegistries retain full control over that baseline.
registries []Registry
userAgent string
userAgentSuffix string
baseURL string
credentialResolver func(*url.URL) (*auth.Credential, error)
globalConfig *GlobalConfig
Expand Down Expand Up @@ -74,6 +75,10 @@ func NewClient(opts ...Option) (*Client, error) {
opt(cfg)
}

if cfg.userAgentSuffix != "" {
cfg.userAgent += " " + cfg.userAgentSuffix
}

httpClient := cfg.httpClient
if httpClient == nil {
httpClient = &http.Client{
Expand Down Expand Up @@ -194,3 +199,10 @@ func WithBaseURL(u string) Option {
func WithUserAgent(ua string) Option {
return func(cfg *clientConfig) { cfg.userAgent = ua }
}

// WithUserAgentSuffix appends suffix, separated by a space, to the user agent
// string (the default or one set via WithUserAgent). Like WithUserAgent, it
// has no effect on a custom HTTP client supplied via WithHTTPClient.
func WithUserAgentSuffix(suffix string) Option {
return func(cfg *clientConfig) { cfg.userAgentSuffix = suffix }
}
26 changes: 26 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,29 @@ func TestNewClientWithUserAgent(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "custom-agent/1.0", c.UserAgent())
}

func TestNewClientWithUserAgentSuffix(t *testing.T) {
base, err := dbc.NewClient()
require.NoError(t, err)

c, err := dbc.NewClient(dbc.WithUserAgentSuffix("my-tool/2.0"))
require.NoError(t, err)
assert.Equal(t, base.UserAgent()+" my-tool/2.0", c.UserAgent())

c, err = dbc.NewClient(dbc.WithUserAgentSuffix(""))
require.NoError(t, err)
assert.Equal(t, base.UserAgent(), c.UserAgent(), "empty suffix must not add a trailing space")
}

// The suffix is applied after all options, so it composes with WithUserAgent
// regardless of option order.
func TestNewClientUserAgentSuffixAppliesToCustomUserAgent(t *testing.T) {
for _, opts := range [][]dbc.Option{
{dbc.WithUserAgent("custom-agent/1.0"), dbc.WithUserAgentSuffix("my-tool/2.0")},
{dbc.WithUserAgentSuffix("my-tool/2.0"), dbc.WithUserAgent("custom-agent/1.0")},
} {
c, err := dbc.NewClient(opts...)
require.NoError(t, err)
assert.Equal(t, "custom-agent/1.0 my-tool/2.0", c.UserAgent())
}
}
27 changes: 23 additions & 4 deletions cmd/dbc/completions/dbc.bash
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,33 @@ _dbc() {
local subcommands="install uninstall list init add sync search info docs remove completion auth"
local global_opts="--help -h --version --quiet -q"

# If we're completing the first argument (subcommand)
if [[ $cword -eq 1 ]]; then
# Skip leading global options to find the subcommand.
local i=1
while [[ $i -lt $COMP_CWORD ]]; do
case "${COMP_WORDS[i]}" in
-*)
((i += 1))
;;
*)
break
;;
esac
done

# If we're completing the subcommand itself
if [[ $i -ge $COMP_CWORD ]]; then
COMPREPLY=($(compgen -W "$subcommands $global_opts" -- "$cur"))
return 0
fi

# Get the subcommand (first argument)
local subcommand="${words[1]}"
local subcommand="${COMP_WORDS[i]}"

# The per-subcommand completers index COMP_WORDS positionally (subcommand
# at 1), so hand them a view with the global options removed.
local -a sub_words=("${COMP_WORDS[0]}" "${COMP_WORDS[@]:i}")
local sub_cword=$((COMP_CWORD - i + 1))
local -a COMP_WORDS=("${sub_words[@]}")
local COMP_CWORD=$sub_cword

case "$subcommand" in
install)
Expand Down
31 changes: 25 additions & 6 deletions cmd/dbc/completions/dbc.fish
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
# fish completion for dbc -*- shell-script -*-

# Tokens before the cursor with leading global options removed, so the
# subcommand is always at index 2.
function __fish_dbc_tokens
set -l tokens (commandline -opc)
printf '%s\n' $tokens[1]
set -l i 2
while test $i -le (count $tokens)
switch $tokens[$i]
case '-*'
set i (math $i + 1)
case '*'
break
end
end
if test $i -le (count $tokens)
printf '%s\n' $tokens[$i..-1]
end
end

# Helper function to check if a subcommand has been given
function __fish_dbc_needs_command
set -l cmd (commandline -opc)
set -l cmd (__fish_dbc_tokens)
set -e cmd[1]
if test (count $cmd) -eq 0
return 0
Expand All @@ -12,7 +31,7 @@ end

# Helper function to check if we're using a specific subcommand
function __fish_dbc_using_subcommand
set -l cmd (commandline -opc)
set -l cmd (__fish_dbc_tokens)
if test (count $cmd) -gt 1
if test $argv[1] = $cmd[2]
return 0
Expand Down Expand Up @@ -111,7 +130,7 @@ complete -f -c dbc -n '__fish_dbc_using_subcommand completion' -a 'fish' -d 'Gen

# Helper function to check if we're using auth subcommand and need a nested subcommand
function __fish_dbc_auth_needs_subcommand
set -l cmd (commandline -opc)
set -l cmd (__fish_dbc_tokens)
if test (count $cmd) -eq 2
if test $cmd[2] = "auth"
return 0
Expand All @@ -122,7 +141,7 @@ end

# Helper function to check if we're using a specific auth subcommand
function __fish_dbc_auth_using_subcommand
set -l cmd (commandline -opc)
set -l cmd (__fish_dbc_tokens)
if test (count $cmd) -gt 2
if test $cmd[2] = "auth" -a $argv[1] = $cmd[3]
return 0
Expand Down Expand Up @@ -150,7 +169,7 @@ complete -f -c dbc -n '__fish_dbc_auth_using_subcommand logout' -l purge -d 'Rem

# Helper function to check if we need a license subcommand
function __fish_dbc_auth_license_needs_subcommand
set -l cmd (commandline -opc)
set -l cmd (__fish_dbc_tokens)
if test (count $cmd) -eq 3
if test $cmd[2] = "auth" -a $cmd[3] = "license"
return 0
Expand All @@ -161,7 +180,7 @@ end

# Helper function to check if we're using a specific auth license subcommand
function __fish_dbc_auth_license_using_subcommand
set -l cmd (commandline -opc)
set -l cmd (__fish_dbc_tokens)
if test (count $cmd) -gt 3
if test $cmd[2] = "auth" -a $cmd[3] = "license" -a $argv[1] = $cmd[4]
return 0
Expand Down
7 changes: 4 additions & 3 deletions cmd/dbc/completions/dbc.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ compdef _dbc dbc
function _dbc {
local line state

# Global options are declared here (not only listed as values below) so
# _arguments skips them when locating the subcommand.
_arguments -C \
'(-q --quiet)'{-q,--quiet}'[Suppress all output]' \
"1: :->subcommand" \
"*::arg:->args"

Expand All @@ -27,9 +30,7 @@ function _dbc {
'auth[Authenticate with a driver registry]' \
'--help[Show help]' \
'-h[Show help]' \
'--version[Show version]' \
'--quiet[Suppress all output]' \
'-q[Suppress all output]'
'--version[Show version]'
;;
args)
case $line[1] in
Expand Down
34 changes: 30 additions & 4 deletions cmd/dbc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ type NeedsRenderer interface {
}

var (
userAgentSuffix string

dbcClient *dbc.Client
dbcClientErr error
dbcClientOnce = &sync.Once{}
Expand All @@ -93,10 +95,15 @@ func newDefaultClient() (*dbc.Client, error) {

// authHTTPClient returns an *http.Client suitable for auth flows (device-code
// login, license fetch, etc.) that do not need driver registries resolved.
// It bypasses dbc.NewClient so registry misconfiguration — a valid state for
// a user who is about to run `dbc auth login` to fix it — cannot break login.
// It bypasses dbc.NewClient's registry resolution so registry misconfiguration
// — a valid state for a user who is about to run `dbc auth login` to fix it —
// cannot break login. It carries the DBC_USER_AGENT_SUFFIX suffix, if any.
func authHTTPClient() *http.Client {
c, err := dbc.NewClient(dbc.WithBaseURL("https://placeholder.invalid"))
opts := []dbc.Option{dbc.WithBaseURL("https://placeholder.invalid")}
if userAgentSuffix != "" {
opts = append(opts, dbc.WithUserAgentSuffix(userAgentSuffix))
}
c, err := dbc.NewClient(opts...)
if err != nil {
// Impossible in practice: WithBaseURL short-circuits all registry
// validation. Fall back to http.DefaultClient so auth still works.
Expand All @@ -105,6 +112,18 @@ func authHTTPClient() *http.Client {
return c.HTTPClient()
}

// setUserAgentSuffix records the DBC_USER_AGENT_SUFFIX value for every client
// the CLI builds. Must run before the program issues requests, as
// dbc.DefaultClient requires.
func setUserAgentSuffix(suffix string) {
userAgentSuffix = suffix
if suffix != "" {
// Package downloads (PkgInfo.DownloadPackage) use the package-level
// client rather than dbcClient.
dbc.DefaultClient = authHTTPClient()
}
}

// newDBCClient builds a client with the given project registry overrides.
// Callers pass nil/nil for process-wide operations (search, info, install);
// project commands (add, sync, remove) pass the values parsed from dbc.toml.
Expand All @@ -120,6 +139,11 @@ func newDBCClient(projectRegs []dbc.RegistryEntry, projectReplaceDefaults *bool)
opts = append(opts, dbc.WithProjectRegistries(projectRegs, projectReplaceDefaults))
}
}

if userAgentSuffix != "" {
opts = append(opts, dbc.WithUserAgentSuffix(userAgentSuffix))
}

return dbc.NewClient(opts...)
}

Expand Down Expand Up @@ -167,7 +191,6 @@ var getDriverRegistry = func() ([]dbc.Driver, error) {
return dbcClient.Search(context.Background(), "")
}


func findDriver(name string, drivers []dbc.Driver) (dbc.Driver, error) {
idx := slices.IndexFunc(drivers, func(d dbc.Driver) bool {
return d.Path == name
Expand Down Expand Up @@ -464,6 +487,9 @@ func main() {
if jm, ok := m.(interface{ IsJSONMode() bool }); ok && jm.IsJSONMode() {
needsRenderer = false
}

setUserAgentSuffix(os.Getenv("DBC_USER_AGENT_SUFFIX"))

// Work around https://github.com/columnar-tech/dbc/issues/351
usedRenderer := false
if !isatty.IsTerminal(os.Stdout.Fd()) || !needsRenderer {
Expand Down
103 changes: 103 additions & 0 deletions cmd/dbc/user_agent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright 2026 Columnar Technologies Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/columnar-tech/dbc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func defaultUserAgent() string {
return fmt.Sprintf("dbc-cli/%s (%s; %s)", dbc.Version, runtime.GOOS, runtime.GOARCH)
}

// TestNewDBCClientSendsUserAgent verifies the header that actually reaches the
// server, with and without a custom suffix.
func TestNewDBCClientSendsUserAgent(t *testing.T) {
tests := []struct {
name string
suffix string
want string
}{
{"default", "", defaultUserAgent()},
{"custom", "my-tool/2.0", defaultUserAgent() + " my-tool/2.0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("User-Agent")
}))
t.Cleanup(srv.Close)

t.Setenv("DBC_BASE_URL", srv.URL)
// Keep the expected header independent of the runner's CI env.
t.Setenv("CI", "")
prev := userAgentSuffix
t.Cleanup(func() { userAgentSuffix = prev })
userAgentSuffix = tt.suffix

c, err := newDBCClient(nil, nil)
require.NoError(t, err)
assert.Equal(t, tt.want, c.UserAgent())

resp, err := c.HTTPClient().Get(srv.URL)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, tt.want, got)
})
}
}

// TestSetUserAgentSuffixCoversAuthAndDownloads verifies that the HTTP paths
// outside dbcClient — the auth client and package downloads via
// PkgInfo.DownloadPackage — also send the suffix.
func TestSetUserAgentSuffixCoversAuthAndDownloads(t *testing.T) {
var got []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.Header.Get("User-Agent"))
_, _ = w.Write([]byte("pkg"))
}))
t.Cleanup(srv.Close)
t.Setenv("CI", "")

prevSuffix, prevDefault := userAgentSuffix, dbc.DefaultClient
t.Cleanup(func() { userAgentSuffix, dbc.DefaultClient = prevSuffix, prevDefault })
setUserAgentSuffix("my-tool/2.0")
want := defaultUserAgent() + " my-tool/2.0"

resp, err := authHTTPClient().Get(srv.URL)
require.NoError(t, err)
resp.Body.Close()

u, err := url.Parse(srv.URL + "/pkg.tar.gz")
require.NoError(t, err)
f, err := dbc.PkgInfo{Path: u}.DownloadPackage(func(int64, int64) {})
require.NoError(t, err)
f.Close()
t.Cleanup(func() { os.RemoveAll(filepath.Dir(f.Name())) })

assert.Equal(t, []string{want, want}, got)
}
Loading