Skip to content

Commit ac62cdf

Browse files
authored
Import personal access tokens with auth login --with-token (#681)
* Import personal access tokens with auth login --with-token A bot's Basecamp identity should never come from a browser sign-in on a shared machine: whoever's session the browser holds is who the profile quietly becomes, and nothing checks. Basecamp now mints personal access tokens, so the CLI can take one from a secret store and refuse to keep it unless it authenticates as the expected identity. `basecamp auth login --with-token -P <profile> --account <id>` reads the token from stdin (a terminal is refused, with the `op read … |` shape shown), stores it under the profile as a non-expiring bc5 credential — ExpiresAt 0 is the path AccessToken already never refreshes — and creates the profile when --account names its account. The profile entry is written only after /my/profile.json and the authorization endpoint have answered for the token; a rejected token leaves the profile exactly as it was found, previous credential included. Profile registration is factored out of `profile create` so both paths write the same entry, and the root pre-run lets a may-create command name a profile that does not exist yet. `--expect-identity <id>` makes every login assertive — browser and device flows included — discarding the new credential on a mismatch. `--json` returns the envelope (profile, account, identity, person, oauth_type, scope, expires_at null), and the interactive flows now refuse machine output modes up front instead of printing prose and blocking (the machine-mode half of #669). `BASECAMP_OAUTH_ISSUER` pins the BC5 authorization server and skips discovery, so a device login reaches a server that is piloting the client while its metadata is still dark. It is a temporary escape hatch and is documented as one. `--login-hint <email>` is wired through LoginOptions; the pinned SDK cannot put it on the wire yet (basecamp/basecamp-sdk#841 adds the option), so this build tells the user the hint instead of sending it, and Launchpad ignores it. * Verify a login before storing it Review of the token import found the rollback doing the wrong job: the credential was written under the live profile key, checked, and then restored or deleted — a window where another command could pick up an unverified token, a restore that could itself fail, and a success line printed before the check. Turn it around: the candidate token gets a client of its own (App.SDKClientFor, same transport, hooks and user agent as the real one), proves itself, and only then is stored. LoginOptions.Verify runs that check inside the device and Launchpad flows before their store.Save, so --expect-identity on a browser login stores nothing on a mismatch; the snapshot/restore code is gone. The check now covers the account, not only the identity. Production serves /my/profile.json only under an account prefix (the unscoped path 404s — the old "Logged in as" line had been failing silently), so the person lookup goes through the account-scoped People().Me, and that account must be numeric, must be the effective one (--account or BASECAMP_ACCOUNT_ID override the profile binding at runtime, so the mismatch guard compares against that), must appear non-expired in the authorization document, and for a new profile must have been given explicitly rather than inherited from the operator's config. A reported scope outside read/full is refused rather than stored. Smaller findings from the same round: --scope and --expect-identity are validated before stdin is consumed; --expect-identity compares parsed integers; the envelope reports the identity's own email and marks an existing default profile as default; server-supplied names, the login hint and the pinned issuer are reduced to one line before reaching the terminal; an unregistered profile name is no longer used as a cache path component; the e2e cases assert exact error and code with BASECAMP_PROFILE cleared; README and the doctor skill describe the token import, the identity assertion, and that --login-hint is announced, not sent. * Bind what the token was verified for, and keep ordinary logins best-effort The import verifies a token for one account and base URL and stores it under a profile; those must be the same place. An existing profile's base URL must match the effective one (a BASECAMP_BASE_URL override would verify against one host and store for another), an unbound profile is bound to the explicitly given account alongside the token (other fields preserved), and account comparisons are numeric like the rest of the package. The profile entry is written before the credential, so a failure between the two leaves a visibly unauthenticated profile rather than an orphaned secret. Strict verification refuses an authorization document with no identity id. An ordinary browser or device login without --expect-identity is not the assertive kind: an account the token cannot reach, or a person lookup that fails, no longer discards the login — the identity line falls back to the authorization document and no person id is fabricated (nothing is written as user_id 0 either). Stdin accepts exactly one trailing line ending, the shape a pipe delivers, rather than trimming whatever whitespace surrounds the secret. * Keep the server's expiry, refuse stray arguments and non-global bindings The authorization document reports when the token expires; the import now stores that instead of claiming the token never does, and the envelope and status line report it. With no refresh token, AccessToken refuses the token near expiry with the existing "No refresh token" error, so the remedy — import again — is named rather than discovered from failing requests. A document without an expiry still stores zero. The login command takes no positional arguments, so a token pasted after --with-token is refused instead of being ignored while it sits in shell history. Binding an accountless profile rewrites the global config file, so a profile that arrived from a system, repo or local config is refused before stdin is read rather than left shadowing a binding it never sees. * Judge a profile binding by the global file's own entry, refuse near-expiry tokens Config layers merge profiles per name and record one aggregate source for the whole map, so cfg.Sources["profiles"] cannot say where a particular profile came from — a global unbound profile was refused whenever a repo or local config contributed any other profile. Read the global file's entry instead: binding proceeds when that entry exists and is unbound, and is refused (before stdin) when it is missing or already bound, since the accountless effective profile then came from another layer and would keep shadowing whatever is written. An imported token has nothing to refresh with, so one that the server reports as expiring inside the refresh window would be refused by its first command; the import now declines it up front. RefreshWindow is exported from the auth package for that check instead of repeating the five-minute literal. * Refuse to rewrite a malformed config, and limit the expiry refusal to imports Every writer of the global config file read it with parse errors ignored and then wrote the map back, so a token import that registered a profile could replace an operator's malformed-but-present config with a partial decode. loadGlobalConfigFile now treats only a missing file as empty and returns read and parse errors; register, unregister, bind and set-default all go through it and refuse before writing. The near-expiry refusal exists because an imported token has nothing to refresh with. An asserted OAuth login (--expect-identity) reaches the same verifier with a refresh token in hand, so the check is keyed to the import (noRefresh) rather than to strict mode. Imported credentials record source: "token", and auth status and profile show report it, so a machine consumer can tell a non-refreshable personal token from an OAuth credential. * Pin an origin only, refuse a non-object profiles value, and name app.basecamp.com BASECAMP_OAUTH_ISSUER derives its endpoints by appending /oauth/... to the value, so a path would produce endpoints nothing serves; only an origin is accepted now (no path, no opaque form). The global config writers refuse a "profiles" value that is not an object for the same reason they refuse a parse failure, and loadGlobalConfigFile creates the config directory so every writer — set-default included — can run before a global config exists. The README names app.basecamp.com for the token page and the pinned issuer. * Prove the config can take a profile before profile create logs in profile create registers its entry after the OAuth login, and the global config writers now refuse a malformed file, so a refusal there would have left a live credential with no profile. The file is checked for writability before the login runs; nothing is written by the check. The README no longer calls an imported token non-expiring — its expiry is whatever the server reports. * Bump basecamp-sdk to main and send --login-hint on the wire basecamp-sdk#841 (47e7ca38) adds oauth.WithDeviceLoginHint, so the device flow now carries --login-hint as Basecamp's login_hint extension to the device authorization request instead of telling the user the hint. The option is appended only when a hint was given, so an unqualified login's form is unchanged. Launchpad still logs that it ignores the flag. make bump-sdk REF=main: v0.15.0 → v0.16.1-0.20260903193203-47e7ca381a49, provenance updated; the catalog parity tests pass against the new SDK. * Record the pinned SDK revision in the MCP model provenance main now checks that the vendored MCP model was synced from the same basecamp-sdk revision go.mod pins. This branch pins the post-v0.16.0 commit that carries the device-flow login hint, so the provenance moves to that commit. scripts/sync-mcp-model.sh against that checkout leaves behavior-model.json and openapi.json byte-identical to the v0.16.0 snapshot, and would describe the ref as go/v0.16.0-1-g47e7ca38; the ref is written as the go.mod pseudo-version instead, which is what the provenance test compares. A v0.16.1 tag would restore the tagged shape. * Strip a trailing CR only as part of a CRLF pair A token ending in a bare carriage return was accepted: the LF and CR suffixes were trimmed independently, so "token\r" lost its CR and passed the control-character check the contract says it must fail. The CR now goes only when it precedes the final LF; on its own it is left for the single-line check to refuse. * Refuse a null config, and prove the file is writable before anything irreversible The global config writers refuse a malformed file rather than rewrite it, but a top-level null decoded into a nil map and the next assignment panicked instead. It is refused like any other non-object value. Two callers ran the refusal after a step that cannot be taken back: the token import read stdin and verified the token against the server before registering or binding the profile discovered the file was unusable, and profile delete removed the credential first. Both now prove the file can take the write up front, as profile create already did. * Refuse a login whose override disagrees with the profile's binding, OAuth included The token import refused an --account / BASECAMP_ACCOUNT_ID / BASECAMP_BASE_URL override that disagreed with the named profile's binding, since the credential would be verified for the override and stored under a key that keeps the binding. The browser and device flows had no such check: an asserted login under an override verified against the override and then replaced the profile's working credential with one proven for somewhere else. The check is one helper now, run by every login that names an existing profile before any request is made. * Let profile create assert the identity it signs in as --expect-identity ran only on auth login, which refuses a profile that does not exist yet; the browser or device login that creates one had no way to refuse a wrong session. profile create takes the same flag and runs the same verifier before the credential is stored: a mismatch leaves no credential and no profile entry. The verifier's account-scoped person lookup replaces the unscoped /my/profile.json fetch that ran after registration, which production answers with a 404. SDKClientFor now addresses the effective base URL rather than the one the startup client was built with, since a profile being created sets its own in memory before its login runs; for every existing caller the two are the same value. * Return only the error from writableGlobalProfiles, since no caller reads the map * Bump google.golang.org/grpc to v1.83.2 for CVE-2026-84445 An indirect dependency main carries too; the Security workflow's Trivy scan started failing on it once the advisory reached the database. * Reject a bare query or fragment delimiter on the pinned issuer url.Parse reports "https://as.example#" with an empty fragment, so the origin check passed it, and the endpoints derived by concatenation carried the delimiter: the token endpoint became https://as.example#/oauth/tokens, posting to / with the path as a fragment. The delimiters are now refused on the raw value. * Say an imported token reported no expiry, not that it never expires * Name the config preflight for what it checks globalConfigTakesProfiles reads and validates the file's shape; it does not probe permissions, and the old name claimed more than that. * Recompute the Nix vendorHash for the SDK pin and grpc bump Computed and verified with make update-nix-hash (Docker build succeeds with the new hash).
1 parent 6c88ef2 commit ac62cdf

23 files changed

Lines changed: 2640 additions & 139 deletions

File tree

‎.surface‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1945,13 +1945,15 @@ FLAG basecamp auth login --agent type=bool
19451945
FLAG basecamp auth login --cache-dir type=string
19461946
FLAG basecamp auth login --count type=bool
19471947
FLAG basecamp auth login --device-code type=bool
1948+
FLAG basecamp auth login --expect-identity type=string
19481949
FLAG basecamp auth login --help type=bool
19491950
FLAG basecamp auth login --hints type=bool
19501951
FLAG basecamp auth login --ids-only type=bool
19511952
FLAG basecamp auth login --in type=string
19521953
FLAG basecamp auth login --jq type=string
19531954
FLAG basecamp auth login --json type=bool
19541955
FLAG basecamp auth login --local type=bool
1956+
FLAG basecamp auth login --login-hint type=string
19551957
FLAG basecamp auth login --markdown type=bool
19561958
FLAG basecamp auth login --md type=bool
19571959
FLAG basecamp auth login --no-browser type=bool
@@ -1966,6 +1968,7 @@ FLAG basecamp auth login --stats type=bool
19661968
FLAG basecamp auth login --styled type=bool
19671969
FLAG basecamp auth login --todolist type=string
19681970
FLAG basecamp auth login --verbose type=count
1971+
FLAG basecamp auth login --with-token type=bool
19691972
FLAG basecamp auth logout --account type=string
19701973
FLAG basecamp auth logout --agent type=bool
19711974
FLAG basecamp auth logout --cache-dir type=string
@@ -10740,13 +10743,15 @@ FLAG basecamp login --agent type=bool
1074010743
FLAG basecamp login --cache-dir type=string
1074110744
FLAG basecamp login --count type=bool
1074210745
FLAG basecamp login --device-code type=bool
10746+
FLAG basecamp login --expect-identity type=string
1074310747
FLAG basecamp login --help type=bool
1074410748
FLAG basecamp login --hints type=bool
1074510749
FLAG basecamp login --ids-only type=bool
1074610750
FLAG basecamp login --in type=string
1074710751
FLAG basecamp login --jq type=string
1074810752
FLAG basecamp login --json type=bool
1074910753
FLAG basecamp login --local type=bool
10754+
FLAG basecamp login --login-hint type=string
1075010755
FLAG basecamp login --markdown type=bool
1075110756
FLAG basecamp login --md type=bool
1075210757
FLAG basecamp login --no-browser type=bool
@@ -10761,6 +10766,7 @@ FLAG basecamp login --stats type=bool
1076110766
FLAG basecamp login --styled type=bool
1076210767
FLAG basecamp login --todolist type=string
1076310768
FLAG basecamp login --verbose type=count
10769+
FLAG basecamp login --with-token type=bool
1076410770
FLAG basecamp logout --account type=string
1076510771
FLAG basecamp logout --agent type=bool
1076610772
FLAG basecamp logout --cache-dir type=string
@@ -11927,6 +11933,7 @@ FLAG basecamp profile create --base-url type=string
1192711933
FLAG basecamp profile create --cache-dir type=string
1192811934
FLAG basecamp profile create --count type=bool
1192911935
FLAG basecamp profile create --device-code type=bool
11936+
FLAG basecamp profile create --expect-identity type=string
1193011937
FLAG basecamp profile create --help type=bool
1193111938
FLAG basecamp profile create --hints type=bool
1193211939
FLAG basecamp profile create --ids-only type=bool

‎README.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,34 @@ basecamp auth login --scope full # Full read+write access (default; ignored by L
194194
basecamp auth token # Print token for scripts
195195
```
196196

197+
`--expect-identity <id>` makes any login assert who it authenticated as: the
198+
new credential is checked before it is stored, and on a mismatch nothing is
199+
written (a profile's previous credential is untouched) and the command exits
200+
non-zero. `basecamp profile create <name> --expect-identity <id>` does the same
201+
for a profile that does not exist yet. `--login-hint <email>` names the account to sign in as on the
202+
device-flow approval page (sent as `login_hint`; it steers the sign-in page and
203+
never authenticates on its own; ignored by Launchpad).
204+
205+
### Personal access tokens
206+
207+
A [personal access token](https://app.basecamp.com/my/access_tokens) can be
208+
imported instead of running OAuth — the shape for bots, CI, and any machine
209+
that should never sign in interactively. The token is read from stdin (never
210+
an argument), verified against the server — who it authenticates as, and that
211+
it can reach the profile's account — and only then stored under a named
212+
profile, with whatever expiry the server reports for it:
213+
214+
```bash
215+
op read "op://Vault/Item/credential" | basecamp auth login --with-token -P bot --account 999
216+
op read "op://Vault/Item/credential" | basecamp auth login --with-token -P bot --account 999 --expect-identity 12345 --json
217+
```
218+
219+
`--account` is required when the profile does not exist yet. `--json` returns
220+
an envelope with the profile, account, identity and person, `oauth_type`,
221+
`scope`, and `expires_at` (the expiry the server reports for the token, or
222+
`null` when it reports none). A token has no refresh token, so near a reported
223+
expiry the CLI refuses it and asks for a fresh import.
224+
197225
### Multiple Identities
198226

199227
Use named profiles when the same machine or agent gateway needs more than one Basecamp identity. Each profile has its own stored OAuth credentials and can be selected per command:
@@ -218,6 +246,12 @@ To use your own OAuth app (e.g., a custom Launchpad integration):
218246

219247
Both `BASECAMP_OAUTH_CLIENT_ID` and `BASECAMP_OAUTH_CLIENT_SECRET` must be set together.
220248

249+
`BASECAMP_OAUTH_ISSUER=https://app.basecamp.com` pins the OAuth authorization
250+
server and skips discovery, so `basecamp auth login` reaches a server that is
251+
serving piloted clients but not yet advertising itself (discovery still 404s).
252+
It is a temporary escape hatch for that dark pilot, not a configuration
253+
surface, and will be removed once the server advertises its metadata.
254+
221255
## AI Agent Integration
222256

223257
`basecamp` works with any AI agent that can run shell commands.

‎e2e/auth.bats‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,35 @@ load test_helper
9090
assert_output_contains "default full"
9191
}
9292

93+
@test "basecamp auth login --help shows --with-token, --expect-identity, and --login-hint" {
94+
run basecamp auth login --help
95+
assert_success
96+
assert_output_contains "--with-token"
97+
assert_output_contains "--expect-identity"
98+
assert_output_contains "--login-hint"
99+
assert_output_contains "op read"
100+
}
101+
102+
@test "basecamp auth login --with-token requires a profile" {
103+
run env -u BASECAMP_PROFILE basecamp auth login --with-token </dev/null
104+
assert_failure
105+
assert_json_value '.error' '--with-token stores the token under a named profile'
106+
assert_json_value '.code' 'usage'
107+
}
108+
109+
@test "basecamp auth login --with-token needs --account to create the profile" {
110+
run basecamp auth login --with-token -P bot </dev/null
111+
assert_failure
112+
assert_json_value '.error' 'Profile "bot" does not exist'
113+
assert_json_value '.code' 'usage'
114+
}
115+
116+
@test "basecamp auth login --with-token rejects --device-code" {
117+
run basecamp auth login --with-token --device-code </dev/null
118+
assert_failure
119+
assert_output_contains "with-token"
120+
}
121+
93122
@test "basecamp auth login rejects --device-code --local" {
94123
run basecamp auth login --device-code --local
95124
assert_failure

‎go.mod‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
charm.land/bubbles/v2 v2.2.1
77
charm.land/bubbletea/v2 v2.0.9
88
charm.land/lipgloss/v2 v2.0.6
9-
github.com/basecamp/basecamp-sdk/go v0.16.0
9+
github.com/basecamp/basecamp-sdk/go v0.16.1-0.20260903193203-47e7ca381a49
1010
github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d
1111
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d
1212
github.com/basecamp/surfguard/go v0.1.0
@@ -141,7 +141,7 @@ require (
141141
golang.org/x/time v0.15.0 // indirect
142142
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
143143
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
144-
google.golang.org/grpc v1.83.1 // indirect
144+
google.golang.org/grpc v1.83.2 // indirect
145145
google.golang.org/protobuf v1.36.11 // indirect
146146
k8s.io/klog/v2 v2.140.0 // indirect
147147
)

‎go.sum‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
8787
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
8888
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
8989
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
90-
github.com/basecamp/basecamp-sdk/go v0.16.0 h1:6MfdBf+tgDtfzSHLDhJO9yqsCExNvtWUlK6gzTKzMvM=
91-
github.com/basecamp/basecamp-sdk/go v0.16.0/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY=
90+
github.com/basecamp/basecamp-sdk/go v0.16.1-0.20260903193203-47e7ca381a49 h1:acgFif/siLT3R1/XWHdXLGGsE4x7HqKCvSaXw6j7xHo=
91+
github.com/basecamp/basecamp-sdk/go v0.16.1-0.20260903193203-47e7ca381a49/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY=
9292
github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d h1:jAzDrCCzDpIwhbFT1xVVs0z2xpXoDEkomHfKB2bUUp8=
9393
github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE=
9494
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4=
@@ -508,8 +508,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:
508508
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
509509
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
510510
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
511-
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
512-
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
511+
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
512+
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
513513
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
514514
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
515515
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

‎internal/appctx/context.go‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ type App struct {
4747

4848
// Flags holds the global flag values
4949
Flags GlobalFlags
50+
51+
// SDKOptions are the client options SDK was built with, so a second
52+
// client — one verifying a credential that is not stored yet — rides
53+
// the same transport, hooks, and user agent.
54+
SDKOptions []basecamp.ClientOption
5055
}
5156

5257
// GlobalFlags holds values for global CLI flags.
@@ -126,11 +131,12 @@ func NewApp(cfg *config.Config) *App {
126131
CacheDir: cfg.CacheDir,
127132
CacheEnabled: cfg.CacheEnabled,
128133
}
129-
sdkClient := basecamp.NewClient(sdkCfg, &authAdapter{mgr: authMgr},
134+
sdkOptions := []basecamp.ClientOption{
130135
basecamp.WithHooks(hooks),
131136
basecamp.WithTransport(transport),
132-
basecamp.WithUserAgent(version.UserAgent()+" "+basecamp.DefaultUserAgent),
133-
)
137+
basecamp.WithUserAgent(version.UserAgent() + " " + basecamp.DefaultUserAgent),
138+
}
139+
sdkClient := basecamp.NewClient(sdkCfg, &authAdapter{mgr: authMgr}, sdkOptions...)
134140

135141
// Create name resolver using SDK client and account ID
136142
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)
@@ -147,19 +153,31 @@ func NewApp(cfg *config.Config) *App {
147153
}
148154

149155
return &App{
150-
Config: cfg,
151-
Auth: authMgr,
152-
SDK: sdkClient,
153-
Names: nameResolver,
154-
Collector: collector,
155-
Hooks: cliHooks,
156+
Config: cfg,
157+
Auth: authMgr,
158+
SDK: sdkClient,
159+
SDKOptions: sdkOptions,
160+
Names: nameResolver,
161+
Collector: collector,
162+
Hooks: cliHooks,
156163
Output: output.New(output.Options{
157164
Format: format,
158165
Writer: os.Stdout,
159166
}),
160167
}
161168
}
162169

170+
// SDKClientFor returns a client configured like SDK but authenticating with
171+
// the given provider instead of the stored credential — the way to exercise
172+
// a token before deciding whether to keep it.
173+
func (a *App) SDKClientFor(provider basecamp.TokenProvider) *basecamp.Client {
174+
cfg := a.SDK.Config()
175+
// The effective base URL, not the one SDK was built with: a profile
176+
// being created sets its own in memory before its login runs.
177+
cfg.BaseURL = a.Config.BaseURL
178+
return basecamp.NewClient(&cfg, provider, a.SDKOptions...)
179+
}
180+
163181
// ApplyFlags applies global flag values to the app configuration.
164182
func (a *App) ApplyFlags() {
165183
// Apply output format from flags (order matters: specific modes first)

0 commit comments

Comments
 (0)