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
2 changes: 1 addition & 1 deletion docs/command-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ rc profiles use <name> # switch the active pro
rc profiles delete <name> # remove a profile

# Apps (per-project)
rc apps list
rc apps list [--bundle-id <id>] [--package-name <name>] [--all-projects] # --all-projects + a filter: locate an app org-wide by store identifier
rc apps show <id>
rc apps create
rc apps update <id>
Expand Down
21 changes: 21 additions & 0 deletions docs/previews/apps-list-all-projects.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 22 additions & 2 deletions internal/api/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"context"
"net/http"
"net/url"
)

type AppsService struct{ c *Client }
Expand Down Expand Up @@ -106,10 +107,29 @@ type StripeAppConfig struct {
StripeAccountID *string `json:"stripe_account_id,omitempty"`
}

// List follows next_page until the project's apps are drained, like
// Projects.List — callers (pickers, org-wide identifier lookups) treat the
// result as the complete set, so a single-page fetch would silently truncate.
// Object/URL come from the first page; NextPage is always empty on return.
func (s *AppsService) List(ctx context.Context, projectID string) (*Page[App], error) {
var out Page[App]
err := s.c.do(ctx, http.MethodGet, pathApps(projectID), nil, &out)
return &out, err
path := pathApps(projectID)
for {
var page Page[App]
if err := s.c.do(ctx, http.MethodGet, path, nil, &page); err != nil {
return nil, err
}
if out.Object == "" {
out.Object, out.URL = page.Object, page.URL
}
out.Items = append(out.Items, page.Items...)
cursor := page.NextCursor()
if cursor == "" {
break
}
path = pathApps(projectID) + "?starting_after=" + url.QueryEscape(cursor)
}
return &out, nil
}

func (s *AppsService) Get(ctx context.Context, projectID, id string) (*App, error) {
Expand Down
150 changes: 130 additions & 20 deletions internal/cli/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,53 +52,163 @@ client apps configure with.`,
}

func newAppsListCmd() *cobra.Command {
return &cobra.Command{
var bundleID, packageName string
var allProjects bool
cmd := &cobra.Command{
Use: "list",
Short: "List apps",
Long: `Lists the apps in the active Project with their platform type and store-credential
status. App Store apps show whether Apple credentials are configured.`,
status. App Store apps show whether Apple credentials are configured.

Pass --all-projects to span every project the credential can access and locate
an app org-wide by store identifier. --bundle-id matches App Store and legacy
Mac App Store bundle IDs; --package-name matches Play Store and Amazon package
names.`,
Example: ` rc apps list
rc apps list --json | jq '.data.items[].id'`,
rc apps list --json | jq '.data.items[].id'
rc apps list --all-projects --bundle-id com.acme.app # locate an app org-wide`,
RunE: func(cmd *cobra.Command, _ []string) error {
rt := RuntimeFrom(cmd.Context())
projectID, err := requireProject(rt)
if err != nil {
return err
}
client, err := rt.API()
if err != nil {
return err
}
page, err := client.Apps.List(cmd.Context(), projectID)
if err != nil {
return err
filtered := bundleID != "" || packageName != ""

// The single-project path keeps the server's page envelope for
// --json consumers; the cross-project sweep has no single server
// page, so it renders a synthetic one.
var page *api.Page[api.App]
if allProjects {
projects, err := client.Projects.List(cmd.Context())
if err != nil {
return err
}
page = &api.Page[api.App]{Object: "list"}
for _, p := range projects.Items {
apps, err := client.Apps.List(cmd.Context(), p.ID)
if err != nil {
return err
}
for _, a := range apps.Items {
if a.ProjectID == "" {
a.ProjectID = p.ID
}
page.Items = append(page.Items, a)
}
}
} else {
projectID, err := requireProject(rt)
if err != nil {
return err
}
page, err = client.Apps.List(cmd.Context(), projectID)
if err != nil {
return err
}
for i := range page.Items {
if page.Items[i].ProjectID == "" {
page.Items[i].ProjectID = projectID
}
}
}
page.Items = filterAppsByStoreIdentifier(page.Items, bundleID, packageName)
apps := page.Items

columns := []string{"ID", "NAME", "TYPE", "CREATED", "CREDENTIALS"}
if allProjects {
columns = append([]string{"PROJECT"}, columns...)
}

if rt.CanPrompt() {
items := make([]tui.BrowserItem, len(page.Items))
for i, a := range page.Items {
items[i] = appToItem(projectID, a)
if len(apps) == 0 && filtered {
rt.Out.Info("No apps matched.")
return nil
}
items := make([]tui.BrowserItem, len(apps))
for i, a := range apps {
item := appToItem(a.ProjectID, a)
if allProjects {
item.Row = append([]string{a.ProjectID}, item.Row...)
item.Fields = append([]tui.BrowserField{{Key: "Project", Value: a.ProjectID}}, item.Fields...)
}
items[i] = item
}
err := tui.RunBrowserTable("Apps", []string{"ID", "NAME", "TYPE", "CREATED", "CREDENTIALS"}, items)
appleSetupHintForApps(rt, page.Items)
err := tui.RunBrowserTable("Apps", columns, items)
appleHintForListedApps(rt, apps, allProjects, filtered)
return err
}

rows := make([][]string, 0, len(page.Items))
for _, a := range page.Items {
rows = append(rows, []string{a.ID, a.Name, string(a.Type), formatMillis(a.CreatedAt), appCredentialStatus(a)})
rows := make([][]string, 0, len(apps))
for _, a := range apps {
row := []string{a.ID, a.Name, string(a.Type), formatMillis(a.CreatedAt), appCredentialStatus(a)}
if allProjects {
row = append([]string{a.ProjectID}, row...)
}
rows = append(rows, row)
}
if err := rt.Out.RenderTable(output.Table{
Columns: []string{"ID", "NAME", "TYPE", "CREATED", "CREDENTIALS"},
Columns: columns,
Rows: rows,
Raw: page,
}); err != nil {
return err
}
appleSetupHintForApps(rt, page.Items)
appleHintForListedApps(rt, apps, allProjects, filtered)
return nil
},
}
cmd.Flags().StringVar(&bundleID, "bundle-id", "", "only apps with this App Store / Mac App Store bundle ID")
cmd.Flags().StringVar(&packageName, "package-name", "", "only apps with this Play Store / Amazon package name")
cmd.Flags().BoolVar(&allProjects, "all-projects", false, "list apps across every project, not just the active one")
cmd.MarkFlagsMutuallyExclusive("bundle-id", "package-name")
return cmd
}

// appleHintForListedApps scopes the Apple-credentials nudge to where it's
// actionable: in a single project the plain hint works as-is; org-wide the
// suggested command needs the app's own project, and an unfiltered sweep
// would repeat it for every half-configured app in the org, so it's dropped.
func appleHintForListedApps(rt *Runtime, apps []api.App, allProjects, filtered bool) {
if !allProjects {
appleSetupHintForApps(rt, apps)
return
}
if !filtered {
return
}
for _, a := range apps {
if string(a.Type) != "app_store" || a.AppStore == nil {
continue
}
if !a.AppStore.SubscriptionKeyConfigured || !a.AppStore.AppStoreConnectAPIKeyConfigured {
rt.Out.Warn(fmt.Sprintf("%s is missing Apple credentials — App Store purchases can't be validated until they're set.", a.ID))
rt.Out.Hint(fmt.Sprintf("Fix it: rc setup apple %s --project-id %s (interactive Apple sign-in with 2FA)", a.ID, a.ProjectID))
}
}
}

// filterAppsByStoreIdentifier keeps apps matching the given store identifier
// (the flags are mutually exclusive). Bundle IDs compare case-insensitively
// (Apple treats them that way); package names are case-sensitive.
func filterAppsByStoreIdentifier(apps []api.App, bundleID, packageName string) []api.App {
if bundleID == "" && packageName == "" {
return apps
}
var out []api.App
for _, a := range apps {
switch {
case bundleID != "" && a.AppStore != nil && strings.EqualFold(a.AppStore.BundleID, bundleID):
out = append(out, a)
case bundleID != "" && a.MacAppStore != nil && strings.EqualFold(a.MacAppStore.BundleID, bundleID):
out = append(out, a)
case packageName != "" && a.PlayStore != nil && a.PlayStore.PackageName == packageName:
out = append(out, a)
case packageName != "" && a.Amazon != nil && a.Amazon.PackageName == packageName:
out = append(out, a)
}
}
return out
}

func newAppsShowCmd() *cobra.Command {
Expand Down
106 changes: 106 additions & 0 deletions internal/cli/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,112 @@ func TestAppsCreate_NoInputWithoutTypeErrorsBeforeCreating(t *testing.T) {
}
}

func TestAppsList_AllProjectsFindsBundleIDAcrossProjects(t *testing.T) {
configDir := t.TempDir()
t.Setenv("RC_CONFIG_DIR", configDir)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path + "|" + r.URL.Query().Get("starting_after") {
case "/projects|":
_, _ = io.WriteString(w, `{"object":"list","items":[{"id":"proj_a","name":"Alpha"},{"id":"proj_b","name":"Beta"}],"next_page":null}`)
case "/projects/proj_a/apps|":
_, _ = io.WriteString(w, `{"object":"list","items":[{"object":"app","id":"app_a1","name":"Alpha iOS","type":"app_store","project_id":"proj_a","app_store":{"bundle_id":"com.alpha.app"}}],"next_page":null}`)
// proj_b's apps span two pages: the match only appears when the CLI
// follows next_page, so a single-page fetch fails this test.
case "/projects/proj_b/apps|":
_, _ = io.WriteString(w, `{"object":"list","items":[{"object":"app","id":"app_b0","name":"Beta Legacy","type":"app_store","project_id":"proj_b","app_store":{"bundle_id":"com.beta.legacy"}}],"next_page":"/projects/proj_b/apps?starting_after=app_b0"}`)
case "/projects/proj_b/apps|app_b0":
_, _ = io.WriteString(w, `{"object":"list","items":[{"object":"app","id":"app_b1","name":"Beta iOS","type":"app_store","project_id":"proj_b","app_store":{"bundle_id":"com.beta.app"}},{"object":"app","id":"app_b2","name":"Beta Android","type":"play_store","project_id":"proj_b","play_store":{"package_name":"com.beta.app"}}],"next_page":null}`)
default:
http.Error(w, "unexpected "+r.Method+" "+r.URL.String(), http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
// No ProjectID on purpose: --all-projects must not require one.
if err := config.Save("", &config.Config{APIKey: "sk_test", BaseURL: server.URL}); err != nil {
t.Fatal(err)
}

out, _, err := runCmdInConfigDir(t, configDir,
"apps", "list", "--all-projects", "--bundle-id", "com.beta.app", "--json", "--no-input")
if err != nil {
t.Fatalf("apps list --all-projects failed: %v", err)
}
var env struct {
Data struct {
Items []struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("output not JSON: %v\n%s", err, out)
}
if len(env.Data.Items) != 1 {
t.Fatalf("want exactly the bundle-id match, got %+v", env.Data.Items)
}
if env.Data.Items[0].ID != "app_b1" || env.Data.Items[0].ProjectID != "proj_b" {
t.Errorf("want app_b1 in proj_b, got %+v", env.Data.Items[0])
}

out, _, err = runCmdInConfigDir(t, configDir,
"apps", "list", "--all-projects", "--package-name", "com.beta.app", "--json", "--no-input")
if err != nil {
t.Fatalf("apps list --package-name failed: %v", err)
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("output not JSON: %v\n%s", err, out)
}
if len(env.Data.Items) != 1 || env.Data.Items[0].ID != "app_b2" {
t.Errorf("want app_b2 for the package-name match, got %+v", env.Data.Items)
}

_, _, err = runCmdInConfigDir(t, configDir,
"apps", "list", "--all-projects", "--bundle-id", "com.beta.app", "--package-name", "com.beta.app", "--json", "--no-input")
if err == nil || !strings.Contains(err.Error(), "bundle-id") {
t.Errorf("combining both filters should be rejected as mutually exclusive, got: %v", err)
}
}

func TestAppsList_BundleIDFilterWithinProject(t *testing.T) {
configDir := t.TempDir()
t.Setenv("RC_CONFIG_DIR", configDir)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/projects/proj_x/apps" {
http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound)
return
}
_, _ = io.WriteString(w, `{"object":"list","items":[{"object":"app","id":"app_1","name":"iOS","type":"app_store","project_id":"proj_x","app_store":{"bundle_id":"com.acme.app"}},{"object":"app","id":"app_2","name":"Android","type":"play_store","project_id":"proj_x","play_store":{"package_name":"com.acme.app"}}],"next_page":null}`)
}))
t.Cleanup(server.Close)
if err := config.Save("", &config.Config{APIKey: "sk_test", ProjectID: "proj_x", BaseURL: server.URL}); err != nil {
t.Fatal(err)
}

out, _, err := runCmdInConfigDir(t, configDir,
"apps", "list", "--bundle-id", "COM.ACME.APP", "--json", "--no-input")
if err != nil {
t.Fatalf("apps list --bundle-id failed: %v", err)
}
var env struct {
Data struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("output not JSON: %v\n%s", err, out)
}
if len(env.Data.Items) != 1 || env.Data.Items[0].ID != "app_1" {
t.Errorf("want only the App Store app, got %+v", env.Data.Items)
}
}

// Regression: under --json (non-interactive) the command must error on missing
// required input, not attempt a prompt and then fail confusingly.
func TestAppsCreate_JSONErrorsOnMissingInputInsteadOfPrompting(t *testing.T) {
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ func snapshotServer(t *testing.T) *httptest.Server {
io.WriteString(w, `{"object":"offering","id":"ofrng_snap","lookup_key":"default","display_name":"Default","is_current":true,"created_at":1784297950368,"project_id":"proj_snap"}`)
case strings.HasSuffix(r.URL.Path, "/apps/app_snap1"):
io.WriteString(w, `{"object":"app","id":"app_snap1","name":"Moodly (App Store)","type":"app_store","created_at":1784241909459,"project_id":"proj_snap","app_store":{"bundle_id":"com.example.moodly","subscription_key_configured":true,"app_store_connect_api_key_configured":false}}`)
case strings.HasSuffix(r.URL.Path, "/projects"):
io.WriteString(w, `{"object":"list","items":[{"id":"proj_snap","name":"Moodly"},{"id":"proj_snap2","name":"Moodly Staging"}],"next_page":null}`)
case strings.HasSuffix(r.URL.Path, "/proj_snap2/apps"):
io.WriteString(w, `{"object":"list","items":[{"object":"app","id":"app_snap3","name":"Moodly Staging (App Store)","type":"app_store","created_at":1784241909459,"project_id":"proj_snap2","app_store":{"bundle_id":"com.example.moodly","subscription_key_configured":true,"app_store_connect_api_key_configured":true}}],"next_page":null,"url":"/apps"}`)
case strings.HasSuffix(r.URL.Path, "/apps"):
io.WriteString(w, `{"object":"list","items":[{"object":"app","id":"app_snap1","name":"Moodly (App Store)","type":"app_store","created_at":1784241909459,"project_id":"proj_snap","app_store":{"bundle_id":"com.example.moodly","subscription_key_configured":true,"app_store_connect_api_key_configured":false}},{"object":"app","id":"app_snap2","name":"Test Store","type":"test_store","created_at":1784241905823,"project_id":"proj_snap"}],"next_page":null,"url":"/apps"}`)
default:
Expand All @@ -55,6 +59,7 @@ func TestOutputSnapshots(t *testing.T) {
{"auth-status-logged-out", []string{"auth", "status", "--no-input"}},
{"offerings-show", []string{"offerings", "show", "ofrng_snap", "--no-input", "--project-id", "proj_snap", "--api-key", "sk_snap"}},
{"apps-list", []string{"apps", "list", "--no-input", "--project-id", "proj_snap", "--api-key", "sk_snap"}},
{"apps-list-all-projects", []string{"apps", "list", "--all-projects", "--bundle-id", "com.example.moodly", "--no-input", "--api-key", "sk_snap"}},
{"error-not-found", []string{"offerings", "show", "ofrng_missing", "--no-input", "--project-id", "proj_snap", "--api-key", "sk_snap"}},
{"apps-apple-setup", []string{"apps", "apple", "setup", "app_snap1", "--no-input", "--project-id", "proj_snap", "--api-key", "sk_snap"}},
}
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/testdata/snapshots/apps-list-all-projects.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
$ rc apps list --all-projects --bundle-id com.example.moodly --no-input --api-key sk_snap
! app_snap1 is missing Apple credentials — App Store purchases can't be validated until they're set.
Fix it: rc setup apple app_snap1 --project-id proj_snap (interactive Apple sign-in with 2FA)
PROJECT ID NAME TYPE CREATED CREDENTIALS
proj_snap app_snap1 Moodly (App Store) app_store 2026-07-16 partial — run: rc setup apple
proj_snap2 app_snap3 Moodly Staging (App Store) app_store 2026-07-16 ready
Loading