diff --git a/internal/cmd/catalogcmd/catalog.go b/internal/cmd/catalogcmd/catalog.go index c7950be..1caaf28 100644 --- a/internal/cmd/catalogcmd/catalog.go +++ b/internal/cmd/catalogcmd/catalog.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "math" "github.com/spf13/cobra" @@ -105,9 +104,11 @@ func newTrack(deps Dependencies) *cobra.Command { if err != nil { return exitcode.New(cmdutil.Classify(err), err) } - rendered := output.RenderTrack(track, fields) + var rendered string if opts.id { rendered = output.RenderTrackIDs([]client.Track{track}) + } else { + rendered = output.RenderTrack(track, fields) } return writeOutput(command, rendered) }) @@ -137,9 +138,11 @@ func newAlbum(deps Dependencies) *cobra.Command { if err != nil { return exitcode.New(cmdutil.Classify(err), err) } - rendered := output.RenderAlbum(album, fields) + var rendered string if opts.id { rendered = output.RenderAlbumIDs([]client.Album{album}) + } else { + rendered = output.RenderAlbum(album, fields) } return writeOutput(command, rendered) }) @@ -168,9 +171,11 @@ func newArtist(deps Dependencies) *cobra.Command { if err != nil { return exitcode.New(cmdutil.Classify(err), err) } - rendered := output.RenderArtist(artist, fields) + var rendered string if opts.id { rendered = output.RenderArtistIDs([]client.Artist{artist}) + } else { + rendered = output.RenderArtist(artist, fields) } return writeOutput(command, rendered) }) @@ -194,7 +199,7 @@ func newAlbumTracks(deps Dependencies) *cobra.Command { } } scope := "album-tracks:" + id - offset, err := decodeTraversalToken(scope, opts.nextPageToken, 50) + offset, err := decodeTraversalToken(scope, opts.nextPageToken) if err != nil { return err } @@ -207,11 +212,11 @@ func newAlbumTracks(deps Dependencies) *cobra.Command { if err != nil { return exitcode.New(cmdutil.Classify(err), err) } - rendered := output.RenderTracks(page.Items, fields) + var rendered string if opts.id { rendered = output.RenderTrackIDs(page.Items) } else { - rendered = "Album ID: " + id + "\n" + rendered + rendered = "Album ID: " + id + "\n" + output.RenderTracks(page.Items, fields) } return writeListOutput(command, rendered, scope, page.Offset, page.Limit, page.HasNext) }) @@ -235,7 +240,7 @@ func newArtistAlbums(deps Dependencies) *cobra.Command { } } scope := "artist-albums:" + id - offset, err := decodeTraversalToken(scope, opts.nextPageToken, 10) + offset, err := decodeTraversalToken(scope, opts.nextPageToken) if err != nil { return err } @@ -248,11 +253,11 @@ func newArtistAlbums(deps Dependencies) *cobra.Command { if err != nil { return exitcode.New(cmdutil.Classify(err), err) } - rendered := output.RenderAlbums(page.Items, fields) + var rendered string if opts.id { rendered = output.RenderAlbumIDs(page.Items) } else { - rendered = "Artist ID: " + id + "\n" + rendered + rendered = "Artist ID: " + id + "\n" + output.RenderAlbums(page.Items, fields) } return writeListOutput(command, rendered, scope, page.Offset, page.Limit, page.HasNext) }) @@ -276,8 +281,8 @@ func listCommand(parent, resource string, maxResults int, artwork bool, opts *li return command } -func decodeTraversalToken(scope, value string, pageLimit int) (int, error) { - offset, err := pagetoken.Decode(scope, value, math.MaxInt-pageLimit) +func decodeTraversalToken(scope, value string) (int, error) { + offset, err := pagetoken.Decode(scope, value) if err != nil { return 0, exitcode.New(exitcode.Usage, errors.New("invalid --next-page-token")) } diff --git a/internal/cmd/catalogcmd/catalog_test.go b/internal/cmd/catalogcmd/catalog_test.go index 47435b9..5489ef9 100644 --- a/internal/cmd/catalogcmd/catalog_test.go +++ b/internal/cmd/catalogcmd/catalog_test.go @@ -124,6 +124,14 @@ func TestCatalogGetOutputFlags(t *testing.T) { if err != nil || stdout != id+"\n" { t.Fatalf("stdout=%q error=%v", stdout, err) } + stdout, _, _, _, err = execute("albums", "get", id, "--id") + if err != nil || stdout != id+"\n" { + t.Fatalf("album stdout=%q error=%v", stdout, err) + } + stdout, _, _, _, err = execute("artists", "get", id, "--id") + if err != nil || stdout != id+"\n" { + t.Fatalf("artist stdout=%q error=%v", stdout, err) + } stdout, _, _, _, err = execute("tracks", "get", id, "--fields", "track,album_id") if err != nil || stdout != id+" Song\nAlbum ID: album-1\n" { t.Fatalf("stdout=%q error=%v", stdout, err) diff --git a/internal/cmd/librarycmd/library.go b/internal/cmd/librarycmd/library.go index 1851009..eafddaf 100644 --- a/internal/cmd/librarycmd/library.go +++ b/internal/cmd/librarycmd/library.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "math" "slices" "github.com/spf13/cobra" @@ -92,9 +91,11 @@ func newTrackList(deps Dependencies) *cobra.Command { if err != nil { return exitcode.New(cmdutil.Classify(err), err) } - rendered := output.RenderSavedTracks(page.Items, fields) + var rendered string if opts.id { rendered = output.RenderSavedTrackIDs(page.Items) + } else { + rendered = output.RenderSavedTracks(page.Items, fields) } return writeListOutput(command, rendered, "tracks", trackPageScope, page.Offset, page.Limit, page.HasNext) }) @@ -123,9 +124,11 @@ func newAlbumList(deps Dependencies) *cobra.Command { if err != nil { return exitcode.New(cmdutil.Classify(err), err) } - rendered := output.RenderSavedAlbums(page.Items, fields) + var rendered string if opts.id { rendered = output.RenderSavedAlbumIDs(page.Items) + } else { + rendered = output.RenderSavedAlbums(page.Items, fields) } return writeListOutput(command, rendered, "albums", albumPageScope, page.Offset, page.Limit, page.HasNext) }) @@ -141,7 +144,7 @@ func listCommand(short, resource, pageScope string, opts *listOptions, prepare f if err := prepare(); err != nil { return err } - offset, err := pagetoken.Decode(pageScope, opts.nextPageToken, math.MaxInt-50) + offset, err := pagetoken.Decode(pageScope, opts.nextPageToken) if err != nil { return exitcode.New(exitcode.Usage, errors.New("invalid --next-page-token")) } diff --git a/internal/cmd/playlistcmd/playlist.go b/internal/cmd/playlistcmd/playlist.go index 8ab48f2..1d17699 100644 --- a/internal/cmd/playlistcmd/playlist.go +++ b/internal/cmd/playlistcmd/playlist.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "math" "slices" "strconv" "strings" @@ -611,7 +610,7 @@ func newItemsList(deps Dependencies) *cobra.Command { } } scope := playlistItemPageScope(id) - offset, err := pagetoken.Decode(scope, opts.nextPageToken, math.MaxInt-50) + offset, err := pagetoken.Decode(scope, opts.nextPageToken) if err != nil { return exitcode.New(exitcode.Usage, errors.New("invalid --next-page-token")) } @@ -666,7 +665,7 @@ func newList(deps Dependencies) *cobra.Command { return exitcode.New(exitcode.Usage, err) } } - offset, err := pagetoken.Decode(playlistPageScope, opts.nextPageToken, math.MaxInt-50) + offset, err := pagetoken.Decode(playlistPageScope, opts.nextPageToken) if err != nil { return exitcode.New(exitcode.Usage, errors.New("invalid --next-page-token")) } @@ -679,9 +678,11 @@ func newList(deps Dependencies) *cobra.Command { if err != nil { return exitcode.New(cmdutil.Classify(err), err) } - rendered := output.RenderPlaylists(page.Items, fields) + var rendered string if opts.id { rendered = output.RenderPlaylistIDs(page.Items) + } else { + rendered = output.RenderPlaylists(page.Items, fields) } if err := writeOutput(command, rendered); err != nil { return err @@ -727,9 +728,11 @@ func newGet(deps Dependencies) *cobra.Command { if err != nil { return exitcode.New(cmdutil.Classify(err), err) } - rendered := output.RenderPlaylist(playlist, fields) + var rendered string if opts.id { rendered = output.RenderPlaylistIDs([]client.Playlist{playlist}) + } else { + rendered = output.RenderPlaylist(playlist, fields) } return writeOutput(command, rendered) }, diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 3040f92..560e497 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -45,12 +45,10 @@ type Dependencies struct { OpenSetCredentialStore setcredential.StoreOpener Now func() time.Time Interactive bool - Prompt func(*initcmd.Setup) error OpenBrowser func(string) error HTTPClient *http.Client OAuthEndpoints auth.Endpoints APIBaseURL string - SaveConfig func(config.Config) error } // New constructs the top-level command from its runtime effects. @@ -89,12 +87,8 @@ func New(deps Dependencies) *cobra.Command { authorizer := auth.Authorizer{ HTTPClient: deps.HTTPClient, Endpoints: deps.OAuthEndpoints, OpenBrowser: deps.OpenBrowser, } - saveConfig := deps.SaveConfig - if saveConfig == nil { - saveConfig = func(value config.Config) error { return config.Save(deps.Scope, value) } - } cmd.AddCommand(initcmd.New(initcmd.Dependencies{ - Scope: deps.Scope, Interactive: deps.Interactive, Prompt: deps.Prompt, + Scope: deps.Scope, Interactive: deps.Interactive, Initializer: initcmd.Initializer{ OpenStore: deps.OpenInitStore, Now: deps.Now, Authorize: authorizer.Authorize, Verify: func(ctx context.Context, _ config.Config, envelope token.Envelope) (client.User, error) { @@ -108,7 +102,7 @@ func New(deps Dependencies) *cobra.Command { })) return (client.Client{HTTPClient: httpClient, BaseURL: deps.APIBaseURL}).Me(ctx) }, - SaveConfig: saveConfig, + SaveConfig: func(value config.Config) error { return config.Save(deps.Scope, value) }, }, })) sessionOpener := session.Opener{ diff --git a/internal/cmd/root/root_test.go b/internal/cmd/root/root_test.go index 3fda0e0..14284da 100644 --- a/internal/cmd/root/root_test.go +++ b/internal/cmd/root/root_test.go @@ -542,7 +542,7 @@ func TestInitRequiresStdinModeForHTTPSCallback(t *testing.T) { func TestBackendValidationRunsForStoreFreeCommands(t *testing.T) { for _, backend := range []string{"definitely-invalid", "memory"} { - for _, args := range [][]string{{"config", "path"}, {"search", "track", "query"}} { + for _, args := range [][]string{nil, {"config", "path"}, {"search", "track", "query"}} { h := newHarness(t) err := h.execute(append([]string{"--backend", backend}, args...)...) if exitcode.Code(err) != exitcode.Usage { diff --git a/internal/cmd/searchcmd/search.go b/internal/cmd/searchcmd/search.go index 79dd7e8..ab93171 100644 --- a/internal/cmd/searchcmd/search.go +++ b/internal/cmd/searchcmd/search.go @@ -86,9 +86,11 @@ func runTrack(command *cobra.Command, deps Dependencies, query string, options s if err != nil { return exitcode.New(cmdutil.Classify(err), err) } - rendered := output.RenderTracks(page.Items, fields) + var rendered string if options.id { rendered = output.RenderTrackIDs(page.Items) + } else { + rendered = output.RenderTracks(page.Items, fields) } return writeSearchOutput(command, rendered, "track", page.Offset, page.Limit, page.HasNext) } @@ -121,9 +123,11 @@ func runAlbum(command *cobra.Command, deps Dependencies, query string, options s if err != nil { return exitcode.New(cmdutil.Classify(err), err) } - rendered := output.RenderAlbums(page.Items, fields) + var rendered string if options.id { rendered = output.RenderAlbumIDs(page.Items) + } else { + rendered = output.RenderAlbums(page.Items, fields) } return writeSearchOutput(command, rendered, "album", page.Offset, page.Limit, page.HasNext) } @@ -173,9 +177,11 @@ func runArtist(command *cobra.Command, deps Dependencies, query string, options if err != nil { return exitcode.New(cmdutil.Classify(err), err) } - rendered := output.RenderArtists(page.Items, fields) + var rendered string if options.id { rendered = output.RenderArtistIDs(page.Items) + } else { + rendered = output.RenderArtists(page.Items, fields) } return writeSearchOutput(command, rendered, "artist", page.Offset, page.Limit, page.HasNext) } @@ -208,8 +214,8 @@ func writeSearchOutput(command *cobra.Command, rendered, surface string, offset, } func decodePageToken(surface, value string) (int, error) { - offset, err := pagetoken.Decode(surface, value, maxOffset) - if err != nil { + offset, err := pagetoken.Decode(surface, value) + if err != nil || offset > maxOffset { return 0, errors.New("invalid --next-page-token") } return offset, nil diff --git a/internal/exitcode/exitcode.go b/internal/exitcode/exitcode.go index f507fa3..95eed20 100644 --- a/internal/exitcode/exitcode.go +++ b/internal/exitcode/exitcode.go @@ -9,7 +9,6 @@ const ( Generic = 1 Usage = 2 Config = 3 - NotFound = 4 Upstream = 5 ) diff --git a/internal/pagetoken/pagetoken.go b/internal/pagetoken/pagetoken.go index cf3e259..88e2a44 100644 --- a/internal/pagetoken/pagetoken.go +++ b/internal/pagetoken/pagetoken.go @@ -17,8 +17,8 @@ func Encode(scope string, offset int) string { return base64.RawURLEncoding.EncodeToString([]byte("v1:" + scope + ":" + strconv.Itoa(offset))) } -// Decode validates a token for one command scope and offset ceiling. -func Decode(scope, value string, maxOffset int) (int, error) { +// Decode validates a token for one command scope. +func Decode(scope, value string) (int, error) { if value == "" { return 0, nil } @@ -31,7 +31,7 @@ func Decode(scope, value string, maxOffset int) (int, error) { return 0, errInvalid } offset, err := strconv.Atoi(strings.TrimPrefix(string(decoded), prefix)) - if err != nil || offset < 0 || offset > maxOffset { + if err != nil || offset < 0 { return 0, errInvalid } return offset, nil diff --git a/internal/pagetoken/pagetoken_test.go b/internal/pagetoken/pagetoken_test.go index 6ed3ec0..8628128 100644 --- a/internal/pagetoken/pagetoken_test.go +++ b/internal/pagetoken/pagetoken_test.go @@ -12,7 +12,7 @@ func TestRoundTripAndStableEncoding(t *testing.T) { if token != base64.RawURLEncoding.EncodeToString([]byte("v1:"+scope+":50")) { t.Fatalf("token=%q", token) } - offset, err := Decode(scope, token, 1000) + offset, err := Decode(scope, token) if err != nil || offset != 50 { t.Fatalf("offset=%d error=%v", offset, err) } @@ -28,17 +28,16 @@ func TestDecodeRejectsInvalidTokens(t *testing.T) { encoded("v1:album:1"), encoded("v1:track:-1"), encoded("v1:track:not-a-number"), - encoded("v1:track:1001"), strings.Repeat("a", maxEncodedLength+1), } { - if _, err := Decode("track", token, 1000); err == nil { + if _, err := Decode("track", token); err == nil { t.Fatalf("token %q accepted", token) } } } func TestDecodeEmptyTokenStartsAtZero(t *testing.T) { - offset, err := Decode("track", "", 1000) + offset, err := Decode("track", "") if err != nil || offset != 0 { t.Fatalf("offset=%d error=%v", offset, err) } diff --git a/internal/token/envelope.go b/internal/token/envelope.go index c6c7eb7..5fbaae0 100644 --- a/internal/token/envelope.go +++ b/internal/token/envelope.go @@ -138,21 +138,17 @@ func requireEOF(decoder *json.Decoder) error { } func normalizeScopes(input []string) ([]string, error) { - seen := make(map[string]struct{}, len(input)) - for _, value := range input { + if len(input) == 0 { + return nil, errors.New("oauth token envelope scopes must not be empty") + } + result := make([]string, len(input)) + for i, value := range input { scope := strings.TrimSpace(value) if scope == "" { return nil, errors.New("oauth token envelope scopes must contain only non-empty strings") } - seen[scope] = struct{}{} - } - if len(seen) == 0 { - return nil, errors.New("oauth token envelope scopes must not be empty") - } - result := make([]string, 0, len(seen)) - for scope := range seen { - result = append(result, scope) + result[i] = scope } slices.Sort(result) - return result, nil + return slices.Compact(result), nil } diff --git a/internal/token/envelope_test.go b/internal/token/envelope_test.go index f07ca27..954da95 100644 --- a/internal/token/envelope_test.go +++ b/internal/token/envelope_test.go @@ -68,6 +68,7 @@ func TestDecodeRejectsStrictlyWithoutLeaking(t *testing.T) { tests := []string{ `{"version":2,"access_token":"` + canary + `","token_type":"Bearer","expires_at":"2026-07-22T13:00:00Z","scopes":["user-read-private"]}`, `{"version":1,"access_token":"` + canary + `","token_type":"Basic","expires_at":"2026-07-22T13:00:00Z","scopes":["user-read-private"]}`, + `{"version":1,"access_token":"` + canary + `","token_type":"Bearer","expires_at":"2026-07-22T13:00:00Z","scopes":[]}`, `{"version":1,"access_token":"` + canary + `","token_type":"Bearer","expires_at":"2026-07-22T13:00:00Z","scopes":[""]}`, `{"version":1,"access_token":"` + canary + `","token_type":"Bearer","expires_at":"2026-07-22T13:00:00Z","scopes":["user-read-private"],"extra":"` + canary + `"}`, `{"version":1,"access_token":"` + canary + `","token_type":"Bearer","expires_at":"2026-07-22T13:00:00Z","scopes":["user-read-private"]} trailing`,