Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 7 additions & 23 deletions cmd/sptfy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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(),
Expand Down
6 changes: 4 additions & 2 deletions cmd/sptfy/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"}} {
Expand Down Expand Up @@ -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
},
})
Expand Down
49 changes: 29 additions & 20 deletions internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -79,18 +76,27 @@ 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,
}
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 {
Expand All @@ -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,
}
Expand Down
37 changes: 11 additions & 26 deletions internal/cmd/root/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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"]}`)
Expand All @@ -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"]}`)
Expand Down
19 changes: 16 additions & 3 deletions internal/credentials/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading