Skip to content

Commit f567320

Browse files
committed
Address PR review feedback: config lockout, OAuth logging, temp cleanup
- Move HTTPS enforcement from config.Load() to PersistentPreRunE, skip for "config" subcommands so users can fix a bad base_url without lockout - Route discoverOAuth messages through LoginOptions.Logger instead of writing directly to os.Stderr - Clean up stale temp files on non-Windows rename failure in both atomicWriteFile (config.go) and saveAllToFile (keyring.go)
1 parent 1b02364 commit f567320

6 files changed

Lines changed: 51 additions & 25 deletions

File tree

internal/auth/auth.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error {
241241
credKey := m.credentialKey()
242242

243243
// Discover OAuth config
244-
oauthCfg, oauthType, err := m.discoverOAuth(ctx)
244+
oauthCfg, oauthType, err := m.discoverOAuth(ctx, opts.log)
245245
if err != nil {
246246
return err
247247
}
@@ -293,11 +293,11 @@ func (m *Manager) Logout() error {
293293
return m.store.Delete(credKey)
294294
}
295295

296-
func (m *Manager) discoverOAuth(ctx context.Context) (*oauth.Config, string, error) {
296+
func (m *Manager) discoverOAuth(ctx context.Context, log func(string)) (*oauth.Config, string, error) {
297297
discoverer := oauth.NewDiscoverer(m.httpClient)
298298
cfg, err := discoverer.Discover(ctx, m.cfg.BaseURL)
299299
if err != nil {
300-
fmt.Fprintf(os.Stderr, "warning: OAuth discovery failed for %s, using Launchpad fallback\n", m.cfg.BaseURL)
300+
log(fmt.Sprintf("warning: OAuth discovery failed for %s, using Launchpad fallback", m.cfg.BaseURL))
301301
// Fallback to Launchpad
302302
lpURL, lpErr := m.launchpadURL()
303303
if lpErr != nil {
@@ -307,10 +307,10 @@ func (m *Manager) discoverOAuth(ctx context.Context) (*oauth.Config, string, err
307307
AuthorizationEndpoint: lpURL + "/authorization/new",
308308
TokenEndpoint: lpURL + "/authorization/token",
309309
}
310-
fmt.Fprintf(os.Stderr, "Authenticating via launchpad (%s)\n", fallbackCfg.AuthorizationEndpoint)
310+
log(fmt.Sprintf("Authenticating via launchpad (%s)", fallbackCfg.AuthorizationEndpoint))
311311
return fallbackCfg, "launchpad", nil
312312
}
313-
fmt.Fprintf(os.Stderr, "Authenticating via bc3 (%s)\n", cfg.AuthorizationEndpoint)
313+
log(fmt.Sprintf("Authenticating via bc3 (%s)", cfg.AuthorizationEndpoint))
314314
return cfg, "bc3", nil
315315
}
316316

internal/auth/auth_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,8 @@ func TestDiscoverOAuth_PropagatesInsecureLaunchpadError(t *testing.T) {
443443
// Set insecure Launchpad URL — should cause hard error, not silent fallback.
444444
t.Setenv("BASECAMP_LAUNCHPAD_URL", "http://evil.example.com")
445445

446-
_, _, err := m.discoverOAuth(context.Background())
446+
noop := func(string) {}
447+
_, _, err := m.discoverOAuth(context.Background(), noop)
447448
require.Error(t, err, "insecure launchpad URL error must propagate through discoverOAuth")
448449
assert.Contains(t, err.Error(), "BASECAMP_LAUNCHPAD_URL")
449450
}

internal/auth/keyring.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,15 @@ func (s *Store) saveAllToFile(all map[string]*Credentials) error {
159159
// Windows: rename fails when destination exists. Try rename first to
160160
// preserve the old file on unrelated errors; only remove+retry on failure.
161161
destPath := s.credentialsPath()
162-
if err := os.Rename(tmpPath, destPath); err != nil && runtime.GOOS == "windows" {
163-
_ = os.Remove(destPath)
164-
return os.Rename(tmpPath, destPath)
165-
} else { //nolint:revive // else-with-return kept for clarity of the two-branch pattern
162+
if err := os.Rename(tmpPath, destPath); err != nil {
163+
if runtime.GOOS == "windows" {
164+
_ = os.Remove(destPath)
165+
return os.Rename(tmpPath, destPath)
166+
}
167+
os.Remove(tmpPath) // Clean up stale temp on failure
166168
return err
167169
}
170+
return nil
168171
}
169172

170173
func (s *Store) loadFromFile(origin string) (*Credentials, error) {

internal/cli/root.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,31 @@ func NewRootCmd() *cobra.Command {
6767
Todolist: flags.Todolist,
6868
CacheDir: flags.CacheDir,
6969
})
70-
// Re-validate: profile may have changed base_url
71-
if err := hostutil.RequireSecureURL(cfg.BaseURL); err != nil {
72-
return fmt.Errorf("base_url (from profile %q): %w", profileName, err)
73-
}
7470
// Profile-scoped cache (only if cache dir was not explicitly set via flag or env)
7571
if flags.CacheDir == "" && os.Getenv("BASECAMP_CACHE_DIR") == "" {
7672
cfg.CacheDir = filepath.Join(cfg.CacheDir, "profiles", profileName)
7773
}
7874
}
7975

76+
// Enforce HTTPS for non-localhost base_url.
77+
// Skip for "config" subcommands so users can fix a bad base_url
78+
// without being locked out by the validation they need to repair.
79+
if !isConfigCmd(cmd) {
80+
if err := hostutil.RequireSecureURL(cfg.BaseURL); err != nil {
81+
source := cfg.Sources["base_url"]
82+
if source == "" {
83+
source = "unknown"
84+
}
85+
return fmt.Errorf("base_url (%s): %w\nFix with: basecamp config unset base_url", source, err)
86+
}
87+
if profileName != "" {
88+
// Re-validate: profile may have changed base_url
89+
if err := hostutil.RequireSecureURL(cfg.BaseURL); err != nil {
90+
return fmt.Errorf("base_url (from profile %q): %w\nFix with: basecamp config unset base_url", profileName, err)
91+
}
92+
}
93+
}
94+
8095
// Resolve behavior preferences: explicit flag > config > version.IsDev()
8196
resolvePreferences(cmd, cfg, &flags)
8297

@@ -364,6 +379,17 @@ func promptForProfile(cfg *config.Config) (string, error) {
364379

365380
// transformCobraError transforms Cobra's default error messages to match the
366381
// Bash CLI format for consistency with existing tests and user expectations.
382+
// isConfigCmd returns true if cmd is "config" or any of its subcommands.
383+
// Used to skip HTTPS enforcement so users can repair a bad base_url.
384+
func isConfigCmd(cmd *cobra.Command) bool {
385+
for c := cmd; c != nil; c = c.Parent() {
386+
if c.Name() == "config" {
387+
return true
388+
}
389+
}
390+
return false
391+
}
392+
367393
func transformCobraError(err error) error {
368394
msg := err.Error()
369395

internal/commands/config.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -417,12 +417,15 @@ func atomicWriteFile(path string, data []byte) error {
417417
// Unix: rename atomically replaces the destination.
418418
// Windows: rename fails when destination exists. Try rename first to
419419
// preserve the old file on unrelated errors; only remove+retry on failure.
420-
if err := os.Rename(tmpPath, path); err != nil && runtime.GOOS == "windows" {
421-
_ = os.Remove(path)
422-
return os.Rename(tmpPath, path)
423-
} else { //nolint:revive // else-with-return kept for clarity of the two-branch pattern
420+
if err := os.Rename(tmpPath, path); err != nil {
421+
if runtime.GOOS == "windows" {
422+
_ = os.Remove(path)
423+
return os.Rename(tmpPath, path)
424+
}
425+
os.Remove(tmpPath) // Clean up stale temp on failure
424426
return err
425427
}
428+
return nil
426429
}
427430

428431
func newConfigProjectCmd() *cobra.Command {

internal/config/config.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import (
77
"os"
88
"path/filepath"
99
"strings"
10-
11-
"github.com/basecamp/basecamp-cli/internal/hostutil"
1210
)
1311

1412
// Config holds the resolved configuration.
@@ -122,11 +120,6 @@ func Load(overrides FlagOverrides) (*Config, error) {
122120
// Apply flag overrides
123121
ApplyOverrides(cfg, overrides)
124122

125-
// Enforce HTTPS for non-localhost base_url
126-
if err := hostutil.RequireSecureURL(cfg.BaseURL); err != nil {
127-
return nil, fmt.Errorf("base_url: %w", err)
128-
}
129-
130123
return cfg, nil
131124
}
132125

0 commit comments

Comments
 (0)