diff --git a/cmd/sptfy/main.go b/cmd/sptfy/main.go index 9fe6d37..0182187 100644 --- a/cmd/sptfy/main.go +++ b/cmd/sptfy/main.go @@ -12,14 +12,10 @@ import ( "github.com/spf13/cobra" "golang.org/x/term" - "github.com/open-cli-collective/spotify-cli/internal/cmd/configcmd" - "github.com/open-cli-collective/spotify-cli/internal/cmd/initcmd" "github.com/open-cli-collective/spotify-cli/internal/cmd/root" - "github.com/open-cli-collective/spotify-cli/internal/cmd/setcredential" "github.com/open-cli-collective/spotify-cli/internal/config" "github.com/open-cli-collective/spotify-cli/internal/credentials" "github.com/open-cli-collective/spotify-cli/internal/exitcode" - "github.com/open-cli-collective/spotify-cli/internal/session" ) func main() { @@ -29,26 +25,14 @@ func main() { } func run() int { - openStore := credentials.ProductionOpener(promptFilePassphrase) cmd := root.New(root.Dependencies{ - In: os.Stdin, - Out: os.Stdout, - ErrOut: os.Stderr, - Scope: statedir.Scope{Name: config.Service}, - Cache: statedir.Cache{Tool: config.Tool}, - Data: statedir.Data{Tool: config.Tool}, - OpenConfigStore: func(request credentials.OpenRequest) (configcmd.CredentialStore, error) { - return openStore(request) - }, - OpenInitStore: func(request credentials.OpenRequest) (initcmd.CredentialStore, error) { - return openStore(request) - }, - OpenSessionStore: func(request credentials.OpenRequest) (session.CredentialStore, error) { - return openStore(request) - }, - OpenSetCredentialStore: func(request credentials.OpenRequest) (setcredential.CredentialStore, error) { - return openStore(request) - }, + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + Scope: statedir.Scope{Name: config.Service}, + Cache: statedir.Cache{Tool: config.Tool}, + Data: statedir.Data{Tool: config.Tool}, + OpenStore: credentials.ProductionOpener(promptFilePassphrase), Interactive: term.IsTerminal(int(os.Stdin.Fd())), OpenBrowser: browser.OpenURL, HTTPClient: spotifyHTTPClient(), diff --git a/cmd/sptfy/main_test.go b/cmd/sptfy/main_test.go index 6c0773f..145e746 100644 --- a/cmd/sptfy/main_test.go +++ b/cmd/sptfy/main_test.go @@ -15,7 +15,6 @@ import ( "github.com/open-cli-collective/cli-common/statedirtest" "github.com/open-cli-collective/spotify-cli/internal/cmd/root" - "github.com/open-cli-collective/spotify-cli/internal/cmd/setcredential" "github.com/open-cli-collective/spotify-cli/internal/config" "github.com/open-cli-collective/spotify-cli/internal/credentials" "github.com/open-cli-collective/spotify-cli/internal/exitcode" @@ -27,7 +26,10 @@ func (s processFailStore) Backend() (credstore.Backend, credstore.Source) { return credstore.BackendMemory, credstore.SourceExplicit } func (s processFailStore) Close() error { return nil } +func (s processFailStore) Get(string, string) (string, error) { return "", s.err } func (s processFailStore) Set(string, string, string, ...credstore.SetOpt) error { return s.err } +func (s processFailStore) Delete(string, string) error { return s.err } +func (s processFailStore) Exists(string, string) (bool, error) { return false, s.err } func TestUnknownCommandsExitUsage(t *testing.T) { for _, args := range [][]string{{"frobnicate"}, {"config", "frobnicate"}} { @@ -111,7 +113,7 @@ func TestCredentialStoreFailureIsSecretSafeAtProcessBoundary(t *testing.T) { In: &in, Out: &out, ErrOut: &errOut, Scope: statedir.Scope{Name: config.Service}, Cache: statedir.Cache{Tool: config.Tool}, Data: statedir.Data{Tool: config.Tool}, Now: func() time.Time { return time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC) }, - OpenSetCredentialStore: func(credentials.OpenRequest) (setcredential.CredentialStore, error) { + OpenStore: func(credentials.OpenRequest) (credentials.Store, error) { return processFailStore{err: errors.New("backend echoed " + canary)}, nil }, }) diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 560e497..c41e22f 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -33,22 +33,19 @@ import ( // Dependencies contains the runtime effects used by the command tree. type Dependencies struct { - In io.Reader - Out io.Writer - ErrOut io.Writer - Scope statedir.Scope - Cache statedir.Cache - Data statedir.Data - OpenConfigStore configcmd.StoreOpener - OpenInitStore initcmd.StoreOpener - OpenSessionStore session.StoreOpener - OpenSetCredentialStore setcredential.StoreOpener - Now func() time.Time - Interactive bool - OpenBrowser func(string) error - HTTPClient *http.Client - OAuthEndpoints auth.Endpoints - APIBaseURL string + In io.Reader + Out io.Writer + ErrOut io.Writer + Scope statedir.Scope + Cache statedir.Cache + Data statedir.Data + OpenStore credentials.StoreOpener + Now func() time.Time + Interactive bool + OpenBrowser func(string) error + HTTPClient *http.Client + OAuthEndpoints auth.Endpoints + APIBaseURL string } // New constructs the top-level command from its runtime effects. @@ -79,10 +76,16 @@ func New(deps Dependencies) *cobra.Command { return exitcode.New(exitcode.Usage, err) }) cmd.AddCommand(configcmd.New(configcmd.Dependencies{ - Scope: deps.Scope, Cache: deps.Cache, Data: deps.Data, OpenStore: deps.OpenConfigStore, + Scope: deps.Scope, Cache: deps.Cache, Data: deps.Data, + OpenStore: func(request credentials.OpenRequest) (configcmd.CredentialStore, error) { + return deps.OpenStore(request) + }, })) cmd.AddCommand(setcredential.New(setcredential.Dependencies{ - Scope: deps.Scope, OpenStore: deps.OpenSetCredentialStore, Now: deps.Now, + Scope: deps.Scope, Now: deps.Now, + OpenStore: func(request credentials.OpenRequest) (setcredential.CredentialStore, error) { + return deps.OpenStore(request) + }, })) authorizer := auth.Authorizer{ HTTPClient: deps.HTTPClient, Endpoints: deps.OAuthEndpoints, OpenBrowser: deps.OpenBrowser, @@ -90,7 +93,10 @@ func New(deps Dependencies) *cobra.Command { cmd.AddCommand(initcmd.New(initcmd.Dependencies{ Scope: deps.Scope, Interactive: deps.Interactive, Initializer: initcmd.Initializer{ - OpenStore: deps.OpenInitStore, Now: deps.Now, Authorize: authorizer.Authorize, + OpenStore: func(request credentials.OpenRequest) (initcmd.CredentialStore, error) { + return deps.OpenStore(request) + }, + Now: deps.Now, Authorize: authorizer.Authorize, Verify: func(ctx context.Context, _ config.Config, envelope token.Envelope) (client.User, error) { oauthContext := ctx if deps.HTTPClient != nil { @@ -106,7 +112,10 @@ func New(deps Dependencies) *cobra.Command { }, })) sessionOpener := session.Opener{ - Scope: deps.Scope, OpenStore: deps.OpenSessionStore, + Scope: deps.Scope, + OpenStore: func(request credentials.OpenRequest) (session.CredentialStore, error) { + return deps.OpenStore(request) + }, Now: deps.Now, HTTPClient: deps.HTTPClient, TokenURL: deps.OAuthEndpoints.TokenURL, APIBaseURL: deps.APIBaseURL, } diff --git a/internal/cmd/root/root_test.go b/internal/cmd/root/root_test.go index 14284da..d388bcd 100644 --- a/internal/cmd/root/root_test.go +++ b/internal/cmd/root/root_test.go @@ -19,13 +19,9 @@ import ( "github.com/open-cli-collective/cli-common/statedirtest" "github.com/open-cli-collective/spotify-cli/internal/auth" - "github.com/open-cli-collective/spotify-cli/internal/cmd/configcmd" - "github.com/open-cli-collective/spotify-cli/internal/cmd/initcmd" - "github.com/open-cli-collective/spotify-cli/internal/cmd/setcredential" "github.com/open-cli-collective/spotify-cli/internal/config" "github.com/open-cli-collective/spotify-cli/internal/credentials" "github.com/open-cli-collective/spotify-cli/internal/exitcode" - "github.com/open-cli-collective/spotify-cli/internal/session" "github.com/open-cli-collective/spotify-cli/internal/token" ) @@ -95,30 +91,19 @@ func newHarness(t *testing.T) *harness { source: credstore.SourceExplicit, }, } - openStore := func(request credentials.OpenRequest) (*fakeStore, error) { + openStore := func(request credentials.OpenRequest) (credentials.Store, error) { h.requests = append(h.requests, request) return h.store, nil } h.deps = Dependencies{ - In: h.in, - Out: h.out, - ErrOut: h.errOut, - Scope: statedir.Scope{Name: config.Service}, - Cache: statedir.Cache{Tool: config.Tool}, - Data: statedir.Data{Tool: config.Tool}, - Now: func() time.Time { return time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC) }, - OpenConfigStore: func(request credentials.OpenRequest) (configcmd.CredentialStore, error) { - return openStore(request) - }, - OpenInitStore: func(request credentials.OpenRequest) (initcmd.CredentialStore, error) { - return openStore(request) - }, - OpenSessionStore: func(request credentials.OpenRequest) (session.CredentialStore, error) { - return openStore(request) - }, - OpenSetCredentialStore: func(request credentials.OpenRequest) (setcredential.CredentialStore, error) { - return openStore(request) - }, + In: h.in, + Out: h.out, + ErrOut: h.errOut, + Scope: statedir.Scope{Name: config.Service}, + Cache: statedir.Cache{Tool: config.Tool}, + Data: statedir.Data{Tool: config.Tool}, + Now: func() time.Time { return time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC) }, + OpenStore: openStore, } return h } @@ -571,7 +556,7 @@ func TestBackendFlagIsPassedToAuthenticatedSession(t *testing.T) { func TestSetCredentialInvalidBackendJSONIsStructured(t *testing.T) { h := newHarness(t) openStore := credentials.ProductionOpener(nil) - h.deps.OpenSetCredentialStore = func(request credentials.OpenRequest) (setcredential.CredentialStore, error) { + h.deps.OpenStore = func(request credentials.OpenRequest) (credentials.Store, error) { return openStore(request) } h.in.WriteString(`{"version":1,"access_token":"access","token_type":"Bearer","expires_at":"2026-07-22T13:00:00Z","scopes":["user-read-private"]}`) @@ -589,7 +574,7 @@ func TestSetCredentialInvalidBackendJSONIsStructured(t *testing.T) { func TestSupportedButUnavailableBackendIsConfigError(t *testing.T) { h := newHarness(t) - h.deps.OpenSetCredentialStore = func(credentials.OpenRequest) (setcredential.CredentialStore, error) { + h.deps.OpenStore = func(credentials.OpenRequest) (credentials.Store, error) { return nil, fmt.Errorf("%w: unavailable on this platform", credstore.ErrBackendNotImplemented) } h.in.WriteString(`{"version":1,"access_token":"access","token_type":"Bearer","expires_at":"2026-07-22T13:00:00Z","scopes":["user-read-private"]}`) diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index 3db2c90..a07301e 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -39,9 +39,22 @@ type OpenRequest struct { BackendSet bool } -// ProductionOpener returns the concrete cli-common store opener. -func ProductionOpener(filePassphrase func() (string, error)) func(OpenRequest) (*credstore.Store, error) { - return func(request OpenRequest) (*credstore.Store, error) { +// Store is the complete credential capability available at the composition root. +type Store interface { + Backend() (credstore.Backend, credstore.Source) + Close() error + Get(profile, key string) (string, error) + Set(profile, key, value string, opts ...credstore.SetOpt) error + Delete(profile, key string) error + Exists(profile, key string) (bool, error) +} + +// StoreOpener opens the complete credential capability. +type StoreOpener func(OpenRequest) (Store, error) + +// ProductionOpener returns the production credential store opener. +func ProductionOpener(filePassphrase func() (string, error)) StoreOpener { + return func(request OpenRequest) (Store, error) { opts, err := buildOptions(request, filePassphrase) if err != nil { return nil, err