diff --git a/src/Designer/backend/src/Designer/Controllers/RepositoryController.cs b/src/Designer/backend/src/Designer/Controllers/RepositoryController.cs
index 777acf1903c..df5422a1bb5 100644
--- a/src/Designer/backend/src/Designer/Controllers/RepositoryController.cs
+++ b/src/Designer/backend/src/Designer/Controllers/RepositoryController.cs
@@ -11,6 +11,7 @@
using Altinn.Studio.Designer.Exceptions.CustomTemplate;
using Altinn.Studio.Designer.Helpers;
using Altinn.Studio.Designer.Hubs.Sync;
+using Altinn.Studio.Designer.Infrastructure.ApiKeyAuth;
using Altinn.Studio.Designer.Models;
using Altinn.Studio.Designer.Models.Dto;
using Altinn.Studio.Designer.RepositoryClient.Model;
@@ -74,6 +75,7 @@ IBranchService branchService
/// All parameters create the search parameters
///
/// List of filtered repositories that user has access to.
+ [AllowApiKey]
[HttpGet]
[Route("search")]
public async Task Search(
diff --git a/src/Designer/backend/tests/Designer.Tests/Controllers/RepositoryController/SearchAuthMetadataTests.cs b/src/Designer/backend/tests/Designer.Tests/Controllers/RepositoryController/SearchAuthMetadataTests.cs
new file mode 100644
index 00000000000..d88f343b2ae
--- /dev/null
+++ b/src/Designer/backend/tests/Designer.Tests/Controllers/RepositoryController/SearchAuthMetadataTests.cs
@@ -0,0 +1,18 @@
+using System.Reflection;
+using Altinn.Studio.Designer.Infrastructure.ApiKeyAuth;
+using Xunit;
+using RepositoryControllerType = Altinn.Studio.Designer.Controllers.RepositoryController;
+
+namespace Designer.Tests.Controllers.RepositoryController;
+
+public class SearchAuthMetadataTests
+{
+ [Fact]
+ public void Search_AllowsApiKeyAuthentication()
+ {
+ MethodInfo method = typeof(RepositoryControllerType).GetMethod(nameof(RepositoryControllerType.Search));
+
+ Assert.NotNull(method);
+ Assert.NotNull(method.GetCustomAttribute());
+ }
+}
diff --git a/src/Designer/backend/tests/Designer.Tests/Services/RepositoryServiceTests.cs b/src/Designer/backend/tests/Designer.Tests/Services/RepositoryServiceTests.cs
index b8a51257cf8..48da9dcaf72 100644
--- a/src/Designer/backend/tests/Designer.Tests/Services/RepositoryServiceTests.cs
+++ b/src/Designer/backend/tests/Designer.Tests/Services/RepositoryServiceTests.cs
@@ -216,7 +216,7 @@ public async Task CopyRepository_TargetExistsLocally_InitialCloneMoved()
);
int actualCloneCount = Directory
.GetDirectories(developerClonePath)
- .Count(d => Path.GetFileName(d) == targetRepositoryName);
+ .Count(d => Path.GetFileName(d).Equals(targetRepositoryName, StringComparison.Ordinal));
Assert.Equal(1, actualCloneCount);
}
finally
diff --git a/src/cli/CHANGELOG.md b/src/cli/CHANGELOG.md
index 1cc64987a19..85b116a039c 100644
--- a/src/cli/CHANGELOG.md
+++ b/src/cli/CHANGELOG.md
@@ -8,6 +8,10 @@ Section ordering: Added, Changed, Fixed, Removed, Security, Deprecated.
## [Unreleased]
+### Added
+
+- Add `apps search` for discovering app repositories in Altinn Studio.
+
## [0.1.0-preview.8] - 2026-05-13
### Changed
diff --git a/src/cli/README.md b/src/cli/README.md
index 2466d877ff7..2bd3221fe7d 100644
--- a/src/cli/README.md
+++ b/src/cli/README.md
@@ -60,6 +60,7 @@ studioctl auth login --env dev --with-token < token.txt
## Core commands
- `studioctl auth login`: login with Ansattporten or `--with-token` for `prod`, `dev`, `staging`, or `local`
+- `studioctl apps search`: search app repositories in Altinn Studio
- `studioctl app clone`: clone `org/repo` from the selected Altinn Studio environment
- `studioctl app run`: run app locally
- `studioctl env up`: start localtest
diff --git a/src/cli/internal/cmd/app/service.go b/src/cli/internal/cmd/app/service.go
index 3187e681d9f..0feaf46642f 100644
--- a/src/cli/internal/cmd/app/service.go
+++ b/src/cli/internal/cmd/app/service.go
@@ -66,6 +66,23 @@ type CloneResult struct {
AbsPath string
}
+// SearchRequest contains repository search inputs.
+type SearchRequest struct {
+ Env string
+ Query string
+ Sort string
+ Order string
+ Page int
+ Limit int
+}
+
+// SearchResult contains repositories found by app search.
+type SearchResult struct {
+ Repositories []studio.Repository
+ TotalCount int
+ TotalPages int
+}
+
// ResolveHost resolves the configured host for an environment.
func (s *Service) ResolveHost(env string) (string, error) {
creds, err := auth.LoadCredentials(s.cfg.Home)
@@ -114,3 +131,37 @@ func (s *Service) Clone(ctx context.Context, req CloneRequest) (CloneResult, err
AbsPath: absPath,
}, nil
}
+
+// Search searches app repositories visible in the selected environment.
+func (s *Service) Search(ctx context.Context, req SearchRequest) (SearchResult, error) {
+ creds, err := auth.LoadCredentials(s.cfg.Home)
+ if err != nil {
+ return SearchResult{}, fmt.Errorf("load credentials: %w", err)
+ }
+
+ envCreds, err := creds.Get(req.Env)
+ if err != nil {
+ if errors.Is(err, auth.ErrNotLoggedIn) {
+ return SearchResult{}, fmt.Errorf("%w: %s", ErrNotLoggedIn, req.Env)
+ }
+ return SearchResult{}, fmt.Errorf("get credentials for %s: %w", req.Env, err)
+ }
+
+ client := studio.NewClientForEnv(req.Env, s.cfg.Home, envCreds, s.cfg.Version)
+ result, err := client.SearchApps(ctx, studio.SearchAppsRequest{
+ Query: req.Query,
+ Sort: req.Sort,
+ Order: req.Order,
+ Page: req.Page,
+ Limit: req.Limit,
+ })
+ if err != nil {
+ return SearchResult{}, fmt.Errorf("search repos: %w", err)
+ }
+
+ return SearchResult{
+ Repositories: result.Data,
+ TotalCount: result.TotalCount,
+ TotalPages: result.TotalPages,
+ }, nil
+}
diff --git a/src/cli/internal/cmd/apps.go b/src/cli/internal/cmd/apps.go
new file mode 100644
index 00000000000..8f031ee17b3
--- /dev/null
+++ b/src/cli/internal/cmd/apps.go
@@ -0,0 +1,283 @@
+package cmd
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "strings"
+
+ "altinn.studio/studioctl/internal/auth"
+ appsvc "altinn.studio/studioctl/internal/cmd/app"
+ "altinn.studio/studioctl/internal/config"
+ "altinn.studio/studioctl/internal/osutil"
+ "altinn.studio/studioctl/internal/studio"
+ "altinn.studio/studioctl/internal/ui"
+)
+
+const (
+ defaultAppsSearchLimit = 20
+ defaultAppsSearchPage = 1
+ defaultAppsSearchSort = "alpha"
+ defaultAppsSearchOrder = "asc"
+)
+
+// AppsCommand implements the 'apps' command.
+type AppsCommand struct {
+ out *ui.Output
+ service *appsvc.Service
+}
+
+type appsSearchFlags struct {
+ env string
+ sort string
+ order string
+ page int
+ limit int
+ jsonOutput bool
+}
+
+type appsSearchOutput struct {
+ Query string `json:"query"`
+ Apps []appsSearchAppOutput `json:"apps"`
+ TotalCount int `json:"totalCount"`
+ TotalPages int `json:"totalPages"`
+ Page int `json:"page"`
+ Limit int `json:"limit"`
+ JSONOutput bool `json:"-"`
+}
+
+type appsSearchAppOutput struct {
+ AppID string `json:"appId"`
+ Description string `json:"description,omitempty"`
+ CloneURL string `json:"cloneUrl,omitempty"`
+ HTMLURL string `json:"htmlUrl,omitempty"`
+}
+
+// NewAppsCommand creates a new apps command.
+func NewAppsCommand(cfg *config.Config, out *ui.Output) *AppsCommand {
+ return &AppsCommand{
+ out: out,
+ service: appsvc.NewService(cfg),
+ }
+}
+
+// Name returns the command name.
+func (c *AppsCommand) Name() string { return "apps" }
+
+// Synopsis returns a short description.
+func (c *AppsCommand) Synopsis() string { return "Search Altinn apps" }
+
+// Usage returns the full help text.
+func (c *AppsCommand) Usage() string {
+ return joinLines(
+ fmt.Sprintf("Usage: %s apps [options]", osutil.CurrentBin()),
+ "",
+ "Search Altinn apps.",
+ "",
+ "Subcommands:",
+ " search Search app repositories in Altinn Studio",
+ "",
+ fmt.Sprintf("Run '%s apps --help' for more information.", osutil.CurrentBin()),
+ )
+}
+
+// Run executes the command.
+func (c *AppsCommand) Run(ctx context.Context, args []string) error {
+ if len(args) == 0 {
+ c.out.Print(c.Usage())
+ return nil
+ }
+
+ subCmd := args[0]
+ subArgs := args[1:]
+
+ switch subCmd {
+ case "search":
+ return c.runSearch(ctx, subArgs)
+ case "-h", flagHelp, helpSubcmd:
+ c.out.Print(c.Usage())
+ return nil
+ default:
+ return fmt.Errorf("%w: %s", ErrUnknownSubcommand, subCmd)
+ }
+}
+
+func (c *AppsCommand) runSearch(ctx context.Context, args []string) error {
+ flags, query, help, err := c.parseSearchFlags(args)
+ if err != nil {
+ return err
+ }
+ if help {
+ c.out.Print(c.searchUsage())
+ return nil
+ }
+ if query == "" {
+ return fmt.Errorf(
+ "%w: usage: %s apps search [--env ENV] ",
+ ErrMissingArgument,
+ osutil.CurrentBin(),
+ )
+ }
+
+ result, err := c.service.Search(ctx, appsvc.SearchRequest{
+ Env: flags.env,
+ Query: query,
+ Sort: flags.sort,
+ Order: flags.order,
+ Page: flags.page,
+ Limit: flags.limit,
+ })
+ if err != nil {
+ return mapAppsSearchError(err, flags.env)
+ }
+
+ return appsSearchOutput{
+ Apps: appsSearchAppsOutput(result.Repositories),
+ Query: query,
+ TotalCount: result.TotalCount,
+ TotalPages: result.TotalPages,
+ Page: flags.page,
+ Limit: flags.limit,
+ JSONOutput: flags.jsonOutput,
+ }.Print(c.out)
+}
+
+func (c *AppsCommand) parseSearchFlags(args []string) (appsSearchFlags, string, bool, error) {
+ fs := flag.NewFlagSet("apps search", flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ flags := appsSearchFlags{
+ env: auth.DefaultEnv,
+ sort: defaultAppsSearchSort,
+ order: defaultAppsSearchOrder,
+ page: defaultAppsSearchPage,
+ limit: defaultAppsSearchLimit,
+ jsonOutput: false,
+ }
+ fs.StringVar(&flags.env, "env", auth.DefaultEnv, "Environment name (prod, dev, staging, local)")
+ fs.IntVar(&flags.limit, "limit", defaultAppsSearchLimit, "Maximum number of apps to return")
+ fs.IntVar(&flags.page, "page", defaultAppsSearchPage, "Result page")
+ fs.StringVar(&flags.sort, "sort", defaultAppsSearchSort, "Sort by alpha, created, updated, size, or id")
+ fs.StringVar(&flags.order, "order", defaultAppsSearchOrder, "Sort order: asc or desc")
+ fs.BoolVar(&flags.jsonOutput, "json", false, "Output as JSON")
+
+ if err := fs.Parse(args); err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ return flags, "", true, nil
+ }
+ return flags, "", false, fmt.Errorf("parsing flags: %w", err)
+ }
+ if flags.limit < 1 {
+ return flags, "", false, fmt.Errorf("%w: --limit must be greater than 0", ErrInvalidFlagValue)
+ }
+ if flags.page < 1 {
+ return flags, "", false, fmt.Errorf("%w: --page must be greater than 0", ErrInvalidFlagValue)
+ }
+ if !isSupportedAppsSearchSort(flags.sort) {
+ return flags, "", false, fmt.Errorf(
+ "%w: --sort must be one of alpha, created, updated, size, or id",
+ ErrInvalidFlagValue,
+ )
+ }
+ if !isSupportedAppsSearchOrder(flags.order) {
+ return flags, "", false, fmt.Errorf("%w: --order must be asc or desc", ErrInvalidFlagValue)
+ }
+ flags.sort = strings.ToLower(flags.sort)
+ flags.order = strings.ToLower(flags.order)
+
+ query := strings.TrimSpace(strings.Join(fs.Args(), " "))
+ return flags, query, false, nil
+}
+
+func (c *AppsCommand) searchUsage() string {
+ return joinLines(
+ fmt.Sprintf("Usage: %s apps search [options] ", osutil.CurrentBin()),
+ "",
+ "Searches app repositories in Altinn Studio.",
+ "",
+ "Options:",
+ " --env ENV Environment name: prod, dev, staging, or local (default: prod)",
+ fmt.Sprintf(" --limit N Maximum number of apps to return (default: %d)", defaultAppsSearchLimit),
+ fmt.Sprintf(" --page N Result page (default: %d)", defaultAppsSearchPage),
+ fmt.Sprintf(
+ " --sort FIELD Sort by alpha, created, updated, size, or id (default: %s)",
+ defaultAppsSearchSort,
+ ),
+ fmt.Sprintf(" --order ORDER Sort order: asc or desc (default: %s)", defaultAppsSearchOrder),
+ " --json Output as JSON",
+ " -h, --help Show this help",
+ )
+}
+
+func mapAppsSearchError(err error, env string) error {
+ switch {
+ case errors.Is(err, appsvc.ErrNotLoggedIn):
+ return fmt.Errorf("%w: %s (run '%s auth login --env %s')", ErrNotLoggedIn, env, osutil.CurrentBin(), env)
+ case errors.Is(err, studio.ErrUnauthorized):
+ return fmt.Errorf("%w (run '%s auth login --env %s')", ErrInvalidToken, osutil.CurrentBin(), env)
+ default:
+ return fmt.Errorf("search failed: %w", err)
+ }
+}
+
+func appsSearchAppsOutput(repositories []studio.Repository) []appsSearchAppOutput {
+ output := make([]appsSearchAppOutput, 0, len(repositories))
+ for _, repo := range repositories {
+ output = append(output, appsSearchAppOutput{
+ AppID: repoAppID(repo),
+ Description: repo.Description,
+ CloneURL: repo.CloneURL,
+ HTMLURL: repo.HTMLURL,
+ })
+ }
+ return output
+}
+
+func repoAppID(repo studio.Repository) string {
+ if repo.FullName != "" {
+ return repo.FullName
+ }
+ if repo.Owner != nil && repo.Owner.Login != "" && repo.Name != "" {
+ return repo.Owner.Login + "/" + repo.Name
+ }
+ return repo.Name
+}
+
+func (o appsSearchOutput) Print(out *ui.Output) error {
+ if o.JSONOutput {
+ return printJSONOutput(out, "apps search", o)
+ }
+ if len(o.Apps) == 0 {
+ out.Println("No apps found.")
+ return nil
+ }
+
+ table := ui.NewTable(
+ ui.NewColumn("APP ID"),
+ ui.NewColumn("DESCRIPTION"),
+ )
+ for _, app := range o.Apps {
+ table.TextRow(app.AppID, tableDash(app.Description))
+ }
+ out.RenderTable(table)
+ return nil
+}
+
+func isSupportedAppsSearchSort(value string) bool {
+ switch strings.ToLower(value) {
+ case "alpha", "created", "updated", "size", "id":
+ return true
+ default:
+ return false
+ }
+}
+
+func isSupportedAppsSearchOrder(value string) bool {
+ switch strings.ToLower(value) {
+ case "asc", "desc":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/src/cli/internal/cmd/apps_internal_test.go b/src/cli/internal/cmd/apps_internal_test.go
new file mode 100644
index 00000000000..6b7b2933b71
--- /dev/null
+++ b/src/cli/internal/cmd/apps_internal_test.go
@@ -0,0 +1,177 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ authstore "altinn.studio/studioctl/internal/auth"
+ "altinn.studio/studioctl/internal/config"
+ "altinn.studio/studioctl/internal/studio"
+ "altinn.studio/studioctl/internal/ui"
+)
+
+const appsSearchTestQuery = "apps"
+
+func TestAppsSearchCommandPrintsResults(t *testing.T) {
+ t.Parallel()
+
+ reqErrCh := make(chan string, 1)
+ failRequest := func(w http.ResponseWriter, format string, args ...any) {
+ select {
+ case reqErrCh <- fmt.Sprintf(format, args...):
+ default:
+ }
+ http.Error(w, "unexpected request", http.StatusBadRequest)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/designer/api/repos/search" {
+ failRequest(w, "path = %s, want /designer/api/repos/search", r.URL.Path)
+ return
+ }
+ query := r.URL.Query()
+ if got := query.Get("keyword"); got != appsSearchTestQuery {
+ failRequest(w, "keyword = %q, want %s", got, appsSearchTestQuery)
+ return
+ }
+ if got := query.Get("limit"); got != "1" {
+ failRequest(w, "limit = %q, want 1", got)
+ return
+ }
+ if got := query.Get("page"); got != "1" {
+ failRequest(w, "page = %q, want 1", got)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("X-Total-Count", "1")
+ if err := json.NewEncoder(w).Encode(studio.SearchAppsResult{
+ Ok: true,
+ TotalCount: 1,
+ TotalPages: 1,
+ Data: []studio.Repository{
+ {
+ Name: "apps-test",
+ FullName: "ttd/apps-test",
+ Description: "Test app",
+ },
+ },
+ }); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }))
+ defer server.Close()
+
+ cfg := appsSearchTestConfig(t, server)
+ var out bytes.Buffer
+ command := NewAppsCommand(cfg, ui.NewOutput(&out, io.Discard, false))
+
+ err := command.Run(
+ t.Context(),
+ []string{"search", "--env", "dev", "--limit", "1", appsSearchTestQuery},
+ )
+ select {
+ case reqErr := <-reqErrCh:
+ t.Fatal(reqErr)
+ default:
+ }
+ if err != nil {
+ t.Fatalf("Run() error = %v", err)
+ }
+
+ got := out.String()
+ if !strings.Contains(got, "APP ID") || !strings.Contains(got, "DESCRIPTION") {
+ t.Fatalf("output = %q, want table headers", got)
+ }
+ if !strings.Contains(got, "ttd/apps-test") || !strings.Contains(got, "Test app") {
+ t.Fatalf("output = %q, want app row", got)
+ }
+}
+
+func TestAppsSearchCommandPrintsJSON(t *testing.T) {
+ t.Parallel()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(studio.SearchAppsResult{
+ Ok: true,
+ TotalCount: 1,
+ TotalPages: 1,
+ Data: []studio.Repository{
+ {
+ Name: "apps-test",
+ FullName: "ttd/apps-test",
+ CloneURL: "https://altinn.studio/repos/ttd/apps-test.git",
+ HTMLURL: "https://altinn.studio/repos/ttd/apps-test",
+ },
+ },
+ }); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }))
+ defer server.Close()
+
+ cfg := appsSearchTestConfig(t, server)
+ var out bytes.Buffer
+ command := NewAppsCommand(cfg, ui.NewOutput(&out, io.Discard, false))
+
+ if err := command.Run(t.Context(), []string{"search", "--env", "dev", "--json", appsSearchTestQuery}); err != nil {
+ t.Fatalf("Run() error = %v", err)
+ }
+
+ var got appsSearchOutput
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out.String())), &got); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+ if got.Query != appsSearchTestQuery {
+ t.Fatalf("Query = %q, want %s", got.Query, appsSearchTestQuery)
+ }
+ if len(got.Apps) != 1 || got.Apps[0].AppID != "ttd/apps-test" {
+ t.Fatalf("Apps = %+v, want one ttd/apps-test app", got.Apps)
+ }
+ if got.TotalCount != 1 {
+ t.Fatalf("TotalCount = %d, want 1", got.TotalCount)
+ }
+}
+
+func TestAppsSearchCommandRequiresQuery(t *testing.T) {
+ t.Parallel()
+
+ cfg := &config.Config{}
+ command := NewAppsCommand(cfg, ui.NewOutput(io.Discard, io.Discard, false))
+
+ err := command.Run(t.Context(), []string{"search"})
+ if err == nil {
+ t.Fatal("Run() error = nil, want missing argument")
+ }
+ if !strings.Contains(err.Error(), ErrMissingArgument.Error()) {
+ t.Fatalf("Run() error = %v, want missing argument", err)
+ }
+}
+
+func appsSearchTestConfig(t *testing.T, server *httptest.Server) *config.Config {
+ t.Helper()
+
+ cfg, err := config.New(config.Flags{Home: t.TempDir()}, "test-version")
+ if err != nil {
+ t.Fatalf("config.New() error = %v", err)
+ }
+ if err := authstore.SaveCredentials(cfg.Home, &authstore.Credentials{
+ Envs: map[string]authstore.EnvCredentials{
+ "dev": {
+ Host: strings.TrimPrefix(server.URL, "http://"),
+ Scheme: "http",
+ ApiKey: "test-api-key",
+ },
+ },
+ }); err != nil {
+ t.Fatalf("SaveCredentials() error = %v", err)
+ }
+ return cfg
+}
diff --git a/src/cli/internal/cmd/root.go b/src/cli/internal/cmd/root.go
index 4f2729e43b7..d45a59ae3b2 100644
--- a/src/cli/internal/cmd/root.go
+++ b/src/cli/internal/cmd/root.go
@@ -63,6 +63,7 @@ func NewCLI(cfg *config.Config) *CLI {
cli.Register(NewDoctorCommand(cfg, out))
cli.Register(NewSelfCommand(cfg, out))
cli.Register(NewAppCommand(cfg, out))
+ cli.Register(NewAppsCommand(cfg, out))
cli.Register(NewServerCommand(cfg, out))
cli.Register(NewShellCommand(cfg, out))
cli.Register(NewAppContainersCommand(cfg, out))
@@ -124,7 +125,7 @@ func (c *CLI) printUsage() {
}
}
- order := []string{"run", "stop", "env", "auth", "app", "install", "doctor", "self", "server", "shell"}
+ order := []string{"run", "stop", "env", "auth", "app", "apps", "install", "doctor", "self", "server", "shell"}
for _, name := range order {
if cmd, ok := c.commands[name]; ok {
c.out.Printlnf(" %-*s %s", maxLen+2, name, cmd.Synopsis())
diff --git a/src/cli/internal/cmd/root_test.go b/src/cli/internal/cmd/root_test.go
index 9dee7ee0588..4c21ec1193e 100644
--- a/src/cli/internal/cmd/root_test.go
+++ b/src/cli/internal/cmd/root_test.go
@@ -227,6 +227,11 @@ func TestCLI_Run(t *testing.T) {
args: []string{"app", "stop", "--help"},
wantCode: 0,
},
+ {
+ name: "apps search command exists",
+ args: []string{"apps", "search", "--help"},
+ wantCode: 0,
+ },
}
for _, tt := range tests {
diff --git a/src/cli/internal/studio/client.go b/src/cli/internal/studio/client.go
index 3174179aa7a..e893d92652f 100644
--- a/src/cli/internal/studio/client.go
+++ b/src/cli/internal/studio/client.go
@@ -12,6 +12,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "strconv"
"strings"
"time"
@@ -75,6 +76,23 @@ type Repository struct {
HTMLURL string `json:"html_url"`
}
+// SearchAppsRequest contains app search filters.
+type SearchAppsRequest struct {
+ Query string
+ Sort string
+ Order string
+ Page int
+ Limit int
+}
+
+// SearchAppsResult contains app search results and pagination metadata.
+type SearchAppsResult struct {
+ Data []Repository `json:"data"`
+ Ok bool `json:"ok"`
+ TotalCount int `json:"totalCount"`
+ TotalPages int `json:"totalPages"`
+}
+
// Client is an API client for Altinn Studio.
type Client struct {
env string
@@ -196,6 +214,59 @@ func (c *Client) GetRepo(ctx context.Context, org, repo string) (*Repository, er
return &repository, nil
}
+// SearchApps searches app repositories visible to the authenticated user through the Designer API.
+func (c *Client) SearchApps(ctx context.Context, search SearchAppsRequest) (*SearchAppsResult, error) {
+ endpoint := fmt.Sprintf("%s://%s/designer/api/repos/search", c.scheme, c.host)
+ query := url.Values{}
+ if search.Query != "" {
+ query.Set("keyword", search.Query)
+ }
+ if search.Sort != "" {
+ query.Set("sortBy", search.Sort)
+ }
+ if search.Order != "" {
+ query.Set("order", search.Order)
+ }
+ if search.Page > 0 {
+ query.Set("page", strconv.Itoa(search.Page))
+ }
+ if search.Limit > 0 {
+ query.Set("limit", strconv.Itoa(search.Limit))
+ }
+ if encoded := query.Encode(); encoded != "" {
+ endpoint += "?" + encoded
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ c.setRequestHeaders(req)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("execute request: %w", err)
+ }
+ defer closeResponseBody(resp.Body)
+
+ if resp.StatusCode == http.StatusUnauthorized {
+ return nil, ErrUnauthorized
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body) //nolint:errcheck // Best effort read for error message
+ return nil, fmt.Errorf("%w %d: %s", ErrUnexpectedStatus, resp.StatusCode, string(body))
+ }
+
+ var result SearchAppsResult
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("decode response: %w", err)
+ }
+
+ return &result, nil
+}
+
// CloneRepo clones a repository to the specified destination.
func (c *Client) CloneRepo(ctx context.Context, org, repo, destPath string) error {
absPath, err := filepath.Abs(destPath)
diff --git a/src/cli/internal/studio/client_test.go b/src/cli/internal/studio/client_test.go
index 1a0f4802ea8..3006a85b7d1 100644
--- a/src/cli/internal/studio/client_test.go
+++ b/src/cli/internal/studio/client_test.go
@@ -164,6 +164,119 @@ func TestClient_GetRepo_NotFound(t *testing.T) {
}
}
+func TestClient_SearchApps_Success(t *testing.T) {
+ t.Parallel()
+ server := httptest.NewServer(searchAppsSuccessHandler(t))
+ defer server.Close()
+
+ host := server.URL[7:]
+ client := &Client{
+ host: host,
+ apiKey: "test-api-key",
+ version: config.NewVersion("test-version"),
+ scheme: "http",
+ httpClient: server.Client(),
+ }
+
+ result, err := client.SearchApps(context.Background(), SearchAppsRequest{
+ Query: "apps",
+ Sort: "updated",
+ Order: "desc",
+ Page: 2,
+ Limit: 10,
+ })
+ if err != nil {
+ t.Fatalf("SearchApps failed: %v", err)
+ }
+ if result.TotalCount != 27 {
+ t.Fatalf("TotalCount = %d, want 27", result.TotalCount)
+ }
+ if result.TotalPages != 3 {
+ t.Fatalf("TotalPages = %d, want 3", result.TotalPages)
+ }
+ if len(result.Data) != 1 || result.Data[0].FullName != "ttd/apps-test" {
+ t.Fatalf("Data = %+v, want one ttd/apps-test repo", result.Data)
+ }
+}
+
+func searchAppsSuccessHandler(t *testing.T) http.HandlerFunc {
+ t.Helper()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ assertSearchAppsRequest(t, r)
+
+ result := SearchAppsResult{
+ Ok: true,
+ TotalCount: 27,
+ TotalPages: 3,
+ Data: []Repository{
+ {
+ ID: 1,
+ Name: "apps-test",
+ FullName: "ttd/apps-test",
+ Description: "Test app",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(result); err != nil {
+ t.Errorf("encode response: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ }
+}
+
+func assertSearchAppsRequest(t *testing.T, r *http.Request) {
+ t.Helper()
+
+ if apiKey := r.Header.Get("X-Api-Key"); apiKey != "test-api-key" {
+ t.Errorf("expected X-Api-Key header, got %q", apiKey)
+ }
+ if userAgent := r.Header.Get("User-Agent"); userAgent != "studioctl/test-version" {
+ t.Errorf("expected User-Agent 'studioctl/test-version', got %q", userAgent)
+ }
+ if r.URL.Path != "/designer/api/repos/search" {
+ t.Errorf("expected path /designer/api/repos/search, got %s", r.URL.Path)
+ }
+
+ query := r.URL.Query()
+ wantQuery := map[string]string{
+ "keyword": "apps",
+ "limit": "10",
+ "order": "desc",
+ "page": "2",
+ "sortBy": "updated",
+ }
+ for key, want := range wantQuery {
+ if got := query.Get(key); got != want {
+ t.Errorf("%s = %q, want %q", key, got, want)
+ }
+ }
+}
+
+func TestClient_SearchApps_Unauthorized(t *testing.T) {
+ t.Parallel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ }))
+ defer server.Close()
+
+ host := server.URL[7:]
+ client := &Client{
+ host: host,
+ apiKey: "bad-api-key",
+ version: config.NewVersion("test-version"),
+ scheme: "http",
+ httpClient: server.Client(),
+ }
+
+ _, err := client.SearchApps(context.Background(), SearchAppsRequest{Query: "apps"})
+ if !errors.Is(err, ErrUnauthorized) {
+ t.Errorf("expected ErrUnauthorized, got %v", err)
+ }
+}
+
func TestClient_buildCloneURL_DoesNotEmbedCredentials(t *testing.T) {
t.Parallel()
client := &Client{