Skip to content

Commit 6236552

Browse files
AaronDDMclaude
andcommitted
feat(config): add nylas config reset to fully reset CLI state
Adds a global reset subcommand under `nylas config` that clears all stored data (API credentials, dashboard session, grants, config file) with a confirmation prompt. This ensures `IsFirstRun()` returns true afterward so first-time setup guidance is shown. Individual resets (`nylas auth config --reset`, `nylas dashboard logout`) remain unchanged and scoped to their own domains. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent a603eeb commit 6236552

4 files changed

Lines changed: 233 additions & 1 deletion

File tree

internal/app/auth/config_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package auth
2+
3+
import (
4+
"testing"
5+
6+
"github.com/nylas/cli/internal/ports"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// mockSecretStore is a simple in-memory secret store for testing.
12+
type mockSecretStore struct {
13+
data map[string]string
14+
}
15+
16+
func newMockSecretStore() *mockSecretStore {
17+
return &mockSecretStore{data: make(map[string]string)}
18+
}
19+
20+
func (m *mockSecretStore) Set(key, value string) error { m.data[key] = value; return nil }
21+
func (m *mockSecretStore) Get(key string) (string, error) {
22+
if v, ok := m.data[key]; ok {
23+
return v, nil
24+
}
25+
return "", nil
26+
}
27+
func (m *mockSecretStore) Delete(key string) error { delete(m.data, key); return nil }
28+
func (m *mockSecretStore) IsAvailable() bool { return true }
29+
func (m *mockSecretStore) Name() string { return "mock" }
30+
31+
func TestConfigService_ResetConfig(t *testing.T) {
32+
t.Run("clears only API credentials", func(t *testing.T) {
33+
secrets := newMockSecretStore()
34+
configStore := newMockConfigStore()
35+
36+
// Populate API credentials
37+
secrets.data[ports.KeyClientID] = "client-123"
38+
secrets.data[ports.KeyClientSecret] = "secret-456"
39+
secrets.data[ports.KeyAPIKey] = "nyl_abc"
40+
secrets.data[ports.KeyOrgID] = "org-789"
41+
42+
// Populate dashboard credentials (should NOT be cleared)
43+
secrets.data[ports.KeyDashboardUserToken] = "user-token"
44+
secrets.data[ports.KeyDashboardAppID] = "app-id"
45+
46+
svc := NewConfigService(configStore, secrets)
47+
48+
err := svc.ResetConfig()
49+
require.NoError(t, err)
50+
51+
// API credentials should be cleared
52+
assert.Empty(t, secrets.data[ports.KeyClientID])
53+
assert.Empty(t, secrets.data[ports.KeyClientSecret])
54+
assert.Empty(t, secrets.data[ports.KeyAPIKey])
55+
assert.Empty(t, secrets.data[ports.KeyOrgID])
56+
57+
// Dashboard credentials should be untouched
58+
assert.Equal(t, "user-token", secrets.data[ports.KeyDashboardUserToken])
59+
assert.Equal(t, "app-id", secrets.data[ports.KeyDashboardAppID])
60+
})
61+
}

internal/cli/config/config.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,18 @@ If the config file doesn't exist, sensible defaults are used automatically.`,
4343
nylas config set gpg.auto_sign true
4444
4545
# Initialize config with defaults
46-
nylas config init`,
46+
nylas config init
47+
48+
# Reset everything (credentials, grants, config)
49+
nylas config reset`,
4750
}
4851

4952
cmd.AddCommand(newListCmd())
5053
cmd.AddCommand(newGetCmd())
5154
cmd.AddCommand(newSetCmd())
5255
cmd.AddCommand(newInitCmd())
5356
cmd.AddCommand(newPathCmd())
57+
cmd.AddCommand(newResetCmd())
5458

5559
return cmd
5660
}

internal/cli/config/reset.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
8+
adapterconfig "github.com/nylas/cli/internal/adapters/config"
9+
"github.com/nylas/cli/internal/adapters/keyring"
10+
authapp "github.com/nylas/cli/internal/app/auth"
11+
"github.com/nylas/cli/internal/cli/common"
12+
"github.com/nylas/cli/internal/domain"
13+
"github.com/nylas/cli/internal/ports"
14+
)
15+
16+
func newResetCmd() *cobra.Command {
17+
var force bool
18+
19+
cmd := &cobra.Command{
20+
Use: "reset",
21+
Short: "Reset all CLI configuration and credentials",
22+
Long: `Reset the Nylas CLI to a clean state by clearing all stored data:
23+
24+
- API credentials (API key, client ID, client secret)
25+
- Dashboard session (login tokens, selected app)
26+
- Grants (authenticated email accounts)
27+
- Config file (reset to defaults)
28+
29+
After reset, run 'nylas init' to set up again.
30+
31+
To reset only part of the CLI:
32+
nylas auth config --reset Reset API credentials only
33+
nylas dashboard logout Log out of Dashboard only`,
34+
Example: ` # Reset with confirmation prompt
35+
nylas config reset
36+
37+
# Reset without confirmation
38+
nylas config reset --force`,
39+
RunE: func(cmd *cobra.Command, args []string) error {
40+
if !force {
41+
fmt.Println("This will remove all stored credentials, grants, and configuration.")
42+
fmt.Println()
43+
if !common.Confirm("Are you sure you want to reset the CLI?", false) {
44+
fmt.Println("Reset cancelled.")
45+
return nil
46+
}
47+
fmt.Println()
48+
}
49+
50+
secretStore, err := keyring.NewSecretStore(adapterconfig.DefaultConfigDir())
51+
if err != nil {
52+
return fmt.Errorf("access secret store: %w", err)
53+
}
54+
55+
// 1. Clear API credentials
56+
configSvc := authapp.NewConfigService(configStore, secretStore)
57+
if err := configSvc.ResetConfig(); err != nil {
58+
return fmt.Errorf("reset API config: %w", err)
59+
}
60+
_, _ = common.Green.Println(" ✓ API credentials cleared")
61+
62+
// 2. Clear dashboard credentials
63+
clearDashboardCredentials(secretStore)
64+
_, _ = common.Green.Println(" ✓ Dashboard session cleared")
65+
66+
// 3. Clear grants
67+
grantStore := keyring.NewGrantStore(secretStore)
68+
if err := grantStore.ClearGrants(); err != nil {
69+
return fmt.Errorf("clear grants: %w", err)
70+
}
71+
_, _ = common.Green.Println(" ✓ Grants cleared")
72+
73+
// 4. Reset config file to defaults
74+
if err := configStore.Save(domain.DefaultConfig()); err != nil {
75+
return fmt.Errorf("reset config file: %w", err)
76+
}
77+
_, _ = common.Green.Println(" ✓ Config file reset")
78+
79+
fmt.Println()
80+
_, _ = common.Green.Println("CLI has been reset.")
81+
fmt.Println()
82+
fmt.Println("Run 'nylas init' to set up again.")
83+
84+
return nil
85+
},
86+
}
87+
88+
cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt")
89+
90+
return cmd
91+
}
92+
93+
// clearDashboardCredentials removes all dashboard-related keys from the secret store.
94+
func clearDashboardCredentials(secrets ports.SecretStore) {
95+
_ = secrets.Delete(ports.KeyDashboardUserToken)
96+
_ = secrets.Delete(ports.KeyDashboardOrgToken)
97+
_ = secrets.Delete(ports.KeyDashboardUserPublicID)
98+
_ = secrets.Delete(ports.KeyDashboardOrgPublicID)
99+
_ = secrets.Delete(ports.KeyDashboardDPoPKey)
100+
_ = secrets.Delete(ports.KeyDashboardAppID)
101+
_ = secrets.Delete(ports.KeyDashboardAppRegion)
102+
}

internal/cli/config/reset_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestNewResetCmd(t *testing.T) {
11+
t.Run("command name and flags", func(t *testing.T) {
12+
cmd := newResetCmd()
13+
14+
assert.Equal(t, "reset", cmd.Use)
15+
assert.NotEmpty(t, cmd.Short)
16+
assert.NotEmpty(t, cmd.Long)
17+
assert.NotEmpty(t, cmd.Example)
18+
19+
flag := cmd.Flags().Lookup("force")
20+
require.NotNil(t, flag, "expected --force flag")
21+
assert.Equal(t, "false", flag.DefValue)
22+
})
23+
}
24+
25+
func TestClearDashboardCredentials(t *testing.T) {
26+
t.Run("clears all dashboard keys", func(t *testing.T) {
27+
store := &memStore{data: map[string]string{
28+
"dashboard_user_token": "tok",
29+
"dashboard_org_token": "org-tok",
30+
"dashboard_user_public_id": "uid",
31+
"dashboard_org_public_id": "oid",
32+
"dashboard_dpop_key": "dpop",
33+
"dashboard_app_id": "app",
34+
"dashboard_app_region": "us",
35+
"api_key": "keep-me",
36+
}}
37+
38+
clearDashboardCredentials(store)
39+
40+
// Dashboard keys should be gone
41+
assert.Empty(t, store.data["dashboard_user_token"])
42+
assert.Empty(t, store.data["dashboard_org_token"])
43+
assert.Empty(t, store.data["dashboard_user_public_id"])
44+
assert.Empty(t, store.data["dashboard_org_public_id"])
45+
assert.Empty(t, store.data["dashboard_dpop_key"])
46+
assert.Empty(t, store.data["dashboard_app_id"])
47+
assert.Empty(t, store.data["dashboard_app_region"])
48+
49+
// Non-dashboard keys should be untouched
50+
assert.Equal(t, "keep-me", store.data["api_key"])
51+
})
52+
}
53+
54+
// memStore is a minimal in-memory SecretStore for testing.
55+
type memStore struct {
56+
data map[string]string
57+
}
58+
59+
func (m *memStore) Set(key, value string) error { m.data[key] = value; return nil }
60+
func (m *memStore) Get(key string) (string, error) {
61+
return m.data[key], nil
62+
}
63+
func (m *memStore) Delete(key string) error { delete(m.data, key); return nil }
64+
func (m *memStore) IsAvailable() bool { return true }
65+
func (m *memStore) Name() string { return "mem" }

0 commit comments

Comments
 (0)