From 5a00579033a9a8a0663ddc385c081b3f9ac85c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Michon?= Date: Fri, 6 Mar 2026 14:45:25 +0100 Subject: [PATCH 1/2] feat: add JSON output for the `apps` command --- CHANGELOG.md | 1 + apps/list.go | 42 +++++++--------- cmd/apps.go | 31 ++++++++++-- internal/boundaries/out/renderer/format.go | 8 +++ .../boundaries/out/renderer/json/apps_list.go | 35 +++++++++++++ internal/boundaries/out/renderer/renderer.go | 8 +++ .../out/renderer/table/apps_list.go | 50 +++++++++++++++++++ scalingo/main.go | 2 + 8 files changed, 150 insertions(+), 27 deletions(-) create mode 100644 internal/boundaries/out/renderer/format.go create mode 100644 internal/boundaries/out/renderer/json/apps_list.go create mode 100644 internal/boundaries/out/renderer/renderer.go create mode 100644 internal/boundaries/out/renderer/table/apps_list.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb2b036..b4bd9e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * fix(addons): parse `maintenance-window-hour` as an int * feat(session): remove region cache on logout +* feat: add JSON output for the `apps` command ## 1.44.1 diff --git a/apps/list.go b/apps/list.go index 0467ea49..76e30299 100644 --- a/apps/list.go +++ b/apps/list.go @@ -2,18 +2,14 @@ package apps import ( "context" - "fmt" - "os" - - "github.com/olekukonko/tablewriter" "github.com/Scalingo/cli/config" - "github.com/Scalingo/cli/io" - "github.com/Scalingo/cli/utils" + "github.com/Scalingo/cli/internal/boundaries/out/renderer" + "github.com/Scalingo/go-scalingo/v11" "github.com/Scalingo/go-utils/errors/v3" ) -func List(ctx context.Context, projectSlug string) error { +func List(ctx context.Context, renderer renderer.Renderer[[]*scalingo.App], projectSlug string) error { c, err := config.ScalingoClient(ctx) if err != nil { return errors.Wrap(ctx, err, "get Scalingo client") @@ -24,30 +20,28 @@ func List(ctx context.Context, projectSlug string) error { return errors.Wrap(ctx, err, "list apps") } - if len(apps) == 0 { - fmt.Println(io.Indent("\nYou haven't created any app yet, create your first application using:\n→ scalingo create \n", 2)) - return nil + filteredApps := filterAppsByProject(apps, projectSlug) + renderer.SetData(ctx, filteredApps) + + err = renderer.Render(ctx) + if err != nil { + return errors.Wrap(ctx, err, "render apps list") } - t := tablewriter.NewWriter(os.Stdout) - t.Header([]string{"Name", "Role", "Status", "Project"}) + return nil +} - currentUser, err := config.C.CurrentUser(ctx) - if err != nil { - return errors.Wrap(ctx, err, "fail to get current user") +func filterAppsByProject(apps []*scalingo.App, projectSlug string) []*scalingo.App { + if projectSlug == "" { + return apps } + filteredApps := make([]*scalingo.App, 0, len(apps)) for _, app := range apps { - // If a filter was set but the app is not in the project, skip to the next one. - if projectSlug != "" && projectSlug != app.ProjectSlug() { - continue + if app.ProjectSlug() == projectSlug { + filteredApps = append(filteredApps, app) } - - role := utils.AppRole(currentUser, app) - - _ = t.Append([]string{app.Name, string(role), string(app.Status), app.ProjectSlug()}) } - _ = t.Render() - return nil + return filteredApps } diff --git a/cmd/apps.go b/cmd/apps.go index 753de9e9..fb02a818 100644 --- a/cmd/apps.go +++ b/cmd/apps.go @@ -8,9 +8,14 @@ import ( "github.com/Scalingo/cli/apps" "github.com/Scalingo/cli/cmd/autocomplete" + "github.com/Scalingo/cli/config" "github.com/Scalingo/cli/detect" + "github.com/Scalingo/cli/internal/boundaries/out/renderer" + rendererjson "github.com/Scalingo/cli/internal/boundaries/out/renderer/json" + renderertable "github.com/Scalingo/cli/internal/boundaries/out/renderer/table" "github.com/Scalingo/cli/io" "github.com/Scalingo/cli/utils" + "github.com/Scalingo/go-scalingo/v11" "github.com/Scalingo/go-utils/errors/v3" ) @@ -19,17 +24,37 @@ var ( Name: "apps", Category: "Global", Description: "List your apps and give some details about them", - Flags: []cli.Flag{&cli.StringFlag{Name: "project", Usage: "Filter apps by project. The filter uses the format /"}}, - Usage: "List your apps", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "project", Usage: "Filter apps by project. The filter uses the format /"}, + }, + Usage: "List your apps", Action: func(ctx context.Context, c *cli.Command) error { projectSlug := c.String("project") + format := renderer.Format(c.String("format")) + if projectSlug != "" { projectSlugSplit := strings.Split(projectSlug, "/") if len(projectSlugSplit) != 2 || (len(projectSlugSplit) == 2 && (projectSlugSplit[0] == "" || projectSlugSplit[1] == "")) { errorQuitWithHelpMessage(ctx, errors.New(ctx, "project filter doesn't respect the expected format"), c, "apps") } } - err := apps.List(ctx, projectSlug) + + var appsRenderer renderer.Renderer[[]*scalingo.App] + switch format { + case renderer.FormatTable: + currentUser, err := config.C.CurrentUser(ctx) + if err != nil { + errorQuit(ctx, errors.Wrap(ctx, err, "get current user")) + } + + appsRenderer = renderertable.NewAppsList(currentUser) + case renderer.FormatJSON: + appsRenderer = rendererjson.NewAppsList() + default: + errorQuitWithHelpMessage(ctx, errors.Newf(ctx, "invalid format '%v'", format), c, "apps") + } + + err := apps.List(ctx, appsRenderer, projectSlug) if err != nil { errorQuit(ctx, err) } diff --git a/internal/boundaries/out/renderer/format.go b/internal/boundaries/out/renderer/format.go new file mode 100644 index 00000000..9b06525d --- /dev/null +++ b/internal/boundaries/out/renderer/format.go @@ -0,0 +1,8 @@ +package renderer + +type Format string + +const ( + FormatJSON Format = "json" + FormatTable Format = "table" +) diff --git a/internal/boundaries/out/renderer/json/apps_list.go b/internal/boundaries/out/renderer/json/apps_list.go new file mode 100644 index 00000000..a8398b6f --- /dev/null +++ b/internal/boundaries/out/renderer/json/apps_list.go @@ -0,0 +1,35 @@ +package json + +import ( + "context" + "encoding/json" + "os" + + "github.com/Scalingo/cli/internal/boundaries/out/renderer" + "github.com/Scalingo/go-scalingo/v11" + "github.com/Scalingo/go-utils/errors/v3" +) + +type appsListRenderer struct { + apps []*scalingo.App +} + +type appsListResponse struct { + Apps []*scalingo.App `json:"apps"` +} + +func NewAppsList() renderer.Renderer[[]*scalingo.App] { + return &appsListRenderer{} +} + +func (r *appsListRenderer) Render(ctx context.Context) error { + err := json.NewEncoder(os.Stdout).Encode(appsListResponse{Apps: r.apps}) + if err != nil { + return errors.Wrap(ctx, err, "encode apps list to JSON") + } + return nil +} + +func (r *appsListRenderer) SetData(ctx context.Context, apps []*scalingo.App) { + r.apps = apps +} diff --git a/internal/boundaries/out/renderer/renderer.go b/internal/boundaries/out/renderer/renderer.go new file mode 100644 index 00000000..69d45649 --- /dev/null +++ b/internal/boundaries/out/renderer/renderer.go @@ -0,0 +1,8 @@ +package renderer + +import "context" + +type Renderer[D any] interface { + Render(ctx context.Context) error + SetData(ctx context.Context, data D) +} diff --git a/internal/boundaries/out/renderer/table/apps_list.go b/internal/boundaries/out/renderer/table/apps_list.go new file mode 100644 index 00000000..ab1cf340 --- /dev/null +++ b/internal/boundaries/out/renderer/table/apps_list.go @@ -0,0 +1,50 @@ +package table + +import ( + "context" + "fmt" + "os" + + "github.com/olekukonko/tablewriter" + + "github.com/Scalingo/cli/internal/boundaries/out/renderer" + "github.com/Scalingo/cli/io" + "github.com/Scalingo/cli/utils" + "github.com/Scalingo/go-scalingo/v11" + "github.com/Scalingo/go-utils/errors/v3" +) + +type appsListRenderer struct { + currentUser *scalingo.User + apps []*scalingo.App +} + +func NewAppsList(currentUser *scalingo.User) renderer.Renderer[[]*scalingo.App] { + return &appsListRenderer{ + currentUser: currentUser, + } +} + +func (r *appsListRenderer) Render(ctx context.Context) error { + if len(r.apps) == 0 { + fmt.Println(io.Indent("\nYou haven't created any app yet, create your first application using:\n→ scalingo create \n", 2)) + return nil + } + + t := tablewriter.NewWriter(os.Stdout) + t.Header([]string{"Name", "Role", "Status", "Project"}) + + for _, app := range r.apps { + role := utils.AppRole(r.currentUser, app) + err := t.Append([]string{app.Name, string(role), string(app.Status), app.ProjectSlug()}) + if err != nil { + return errors.Wrap(ctx, err, "append app to table") + } + } + + return t.Render() +} + +func (r *appsListRenderer) SetData(ctx context.Context, apps []*scalingo.App) { + r.apps = apps +} diff --git a/scalingo/main.go b/scalingo/main.go index b357db76..67703f6a 100644 --- a/scalingo/main.go +++ b/scalingo/main.go @@ -14,6 +14,7 @@ import ( "github.com/Scalingo/cli/cmd" "github.com/Scalingo/cli/cmd/autocomplete" "github.com/Scalingo/cli/config" + "github.com/Scalingo/cli/internal/boundaries/out/renderer" "github.com/Scalingo/cli/signals" "github.com/Scalingo/cli/update" "github.com/Scalingo/go-scalingo/v11/debug" @@ -88,6 +89,7 @@ func main() { app.Flags = []cli.Flag{ &cli.StringFlag{Name: "addon", Value: "", Usage: "ID of the current addon", Sources: cli.EnvVars("SCALINGO_ADDON")}, &cli.StringFlag{Name: "app", Aliases: []string{"a"}, Value: "", Usage: "Name of the app", Sources: cli.EnvVars("SCALINGO_APP")}, + &cli.StringFlag{Name: "format", Value: string(renderer.FormatTable), Usage: "[" + string(renderer.FormatJSON) + "|" + string(renderer.FormatTable) + "]"}, &cli.StringFlag{Name: "remote", Aliases: []string{"r"}, Value: "scalingo", Usage: "Name of the remote"}, &cli.StringFlag{Name: "region", Value: "", Usage: "Name of the region to use"}, } From 56323096a1f4607749cd536ae5024a8c14330ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Michon?= Date: Wed, 13 May 2026 15:57:50 +0200 Subject: [PATCH 2/2] fix(apps_list): better phrasing --- internal/boundaries/out/renderer/table/apps_list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/boundaries/out/renderer/table/apps_list.go b/internal/boundaries/out/renderer/table/apps_list.go index ab1cf340..bc7bc883 100644 --- a/internal/boundaries/out/renderer/table/apps_list.go +++ b/internal/boundaries/out/renderer/table/apps_list.go @@ -27,7 +27,7 @@ func NewAppsList(currentUser *scalingo.User) renderer.Renderer[[]*scalingo.App] func (r *appsListRenderer) Render(ctx context.Context) error { if len(r.apps) == 0 { - fmt.Println(io.Indent("\nYou haven't created any app yet, create your first application using:\n→ scalingo create \n", 2)) + fmt.Println(io.Indent("\nYou haven't created any app yet in this project, create your first application using:\n→ scalingo create \n", 2)) return nil }