From afbe048dfc1ef018f0a106dcc3505b187f92fd00 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 20 Mar 2025 14:14:31 +0000 Subject: [PATCH 1/5] feat(cli): add open app command --- cli/open.go | 159 ++++++++++++++++++++++ cli/open_internal_test.go | 114 +++++++++++++++- cli/open_test.go | 72 +++++++++- cli/testdata/coder_open_--help.golden | 1 + cli/testdata/coder_open_app_--help.golden | 14 ++ docs/manifest.json | 5 + docs/reference/cli/open.md | 1 + docs/reference/cli/open_app.md | 22 +++ 8 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 cli/testdata/coder_open_app_--help.golden create mode 100644 docs/reference/cli/open_app.md diff --git a/cli/open.go b/cli/open.go index 09883684a7707..2955edfe98bdb 100644 --- a/cli/open.go +++ b/cli/open.go @@ -7,6 +7,7 @@ import ( "path" "path/filepath" "runtime" + "slices" "strings" "github.com/skratchdot/open-golang/open" @@ -26,6 +27,7 @@ func (r *RootCmd) open() *serpent.Command { }, Children: []*serpent.Command{ r.openVSCode(), + r.openApp(), }, } return cmd @@ -211,6 +213,118 @@ func (r *RootCmd) openVSCode() *serpent.Command { return cmd } +func (r *RootCmd) openApp() *serpent.Command { + var ( + preferredRegion string + testOpenError bool + ) + + client := new(codersdk.Client) + cmd := &serpent.Command{ + Annotations: workspaceCommand, + Use: "app ", + Short: "Open a workspace application.", + Middleware: serpent.Chain( + serpent.RequireNArgs(2), + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + ctx, cancel := context.WithCancel(inv.Context()) + defer cancel() + + // Check if we're inside a workspace, and especially inside _this_ + // workspace so we can perform path resolution/expansion. Generally, + // we know that if we're inside a workspace, `open` can't be used. + insideAWorkspace := inv.Environ.Get("CODER") == "true" + + // Fetch the preferred region. + regions, err := client.Regions(ctx) + if err != nil { + return xerrors.Errorf("failed to fetch regions: %w", err) + } + var region codersdk.Region + preferredIdx := slices.IndexFunc(regions, func(r codersdk.Region) bool { + return r.Name == preferredRegion + }) + if preferredIdx == -1 { + allRegions := make([]string, len(regions)) + for i, r := range regions { + allRegions[i] = r.Name + } + cliui.Errorf(inv.Stderr, "Preferred region %q not found!\nAvailable regions: %v", preferredRegion, allRegions) + return xerrors.Errorf("region not found") + } + region = regions[preferredIdx] + + workspaceName := inv.Args[0] + appSlug := inv.Args[1] + + // Fetch the ws and agent + ws, agt, err := getWorkspaceAndAgent(ctx, inv, client, false, workspaceName) + if err != nil { + return xerrors.Errorf("failed to get workspace and agent: %w", err) + } + + // Fetch the app + var app codersdk.WorkspaceApp + appIdx := slices.IndexFunc(agt.Apps, func(a codersdk.WorkspaceApp) bool { + return a.Slug == appSlug + }) + if appIdx == -1 { + appSlugs := make([]string, len(agt.Apps)) + for i, app := range agt.Apps { + appSlugs[i] = app.Slug + } + cliui.Errorf(inv.Stderr, "App %q not found in workspace %q!\nAvailable apps: %v", appSlug, workspaceName, appSlugs) + return xerrors.Errorf("app not found") + } + app = agt.Apps[appIdx] + + // Build the URL + baseURL, err := url.Parse(region.PathAppURL) + if err != nil { + return xerrors.Errorf("failed to parse proxy URL: %w", err) + } + baseURL.Path = "" + pathAppURL := strings.TrimPrefix(region.PathAppURL, baseURL.String()) + appURL := buildAppLinkURL(baseURL, ws, agt, app, region.WildcardHostname, pathAppURL) + + if insideAWorkspace { + _, _ = fmt.Fprintf(inv.Stderr, "Please open the following URI on your local machine:\n\n") + _, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL) + return nil + } + _, _ = fmt.Fprintf(inv.Stderr, "Opening %s\n", appURL) + + if !testOpenError { + err = open.Run(appURL) + } else { + err = xerrors.New("test.open-error") + } + return err + }, + } + + cmd.Options = serpent.OptionSet{ + { + Flag: "preferred-region", + Env: "CODER_OPEN_APP_PREFERRED_REGION", + Description: fmt.Sprintf("Preferred region to use when opening the app." + + " By default, the app will be opened using the main Coder deployment (a.k.a. \"primary\")."), + Value: serpent.StringOf(&preferredRegion), + Default: "primary", + }, + { + Flag: "test.open-error", + Description: "Don't run the open command.", + Value: serpent.BoolOf(&testOpenError), + Hidden: true, // This is for testing! + }, + } + + return cmd +} + // waitForAgentCond uses the watch workspace API to update the agent information // until the condition is met. func waitForAgentCond(ctx context.Context, client *codersdk.Client, workspace codersdk.Workspace, workspaceAgent codersdk.WorkspaceAgent, cond func(codersdk.WorkspaceAgent) bool) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { @@ -337,3 +451,48 @@ func doAsync(f func()) (wait func()) { <-done } } + +// buildAppLinkURL returns the URL to open the app in the browser. +// It follows similar logic to the TypeScript implementation in site/src/utils/app.ts +// except that all URLs returned are absolute and based on the provided base URL. +func buildAppLinkURL(baseURL *url.URL, workspace codersdk.Workspace, agent codersdk.WorkspaceAgent, app codersdk.WorkspaceApp, appsHost, preferredPathBase string) string { + // If app is external, return the URL directly + if app.External { + return app.URL + } + + var u url.URL + u.Scheme = baseURL.Scheme + u.Host = baseURL.Host + // We redirect if we don't include a trailing slash, so we always include one to avoid extra roundtrips. + u.Path = fmt.Sprintf( + "%s/@%s/%s.%s/apps/%s/", + preferredPathBase, + workspace.OwnerName, + workspace.Name, + agent.Name, + url.PathEscape(app.Slug), + ) + // The frontend leaves the returns a relative URL for the terminal, but we don't have that luxury. + if app.Command != "" { + u.Path = fmt.Sprintf( + "%s/@%s/%s.%s/terminal", + preferredPathBase, + workspace.OwnerName, + workspace.Name, + agent.Name, + ) + q := u.Query() + q.Set("command", app.Command) + u.RawQuery = q.Encode() + // encodeURIComponent replaces spaces with %20 but url.QueryEscape replaces them with +. + // We replace them with %20 to match the TypeScript implementation. + u.RawQuery = strings.ReplaceAll(u.RawQuery, "+", "%20") + } + + if appsHost != "" && app.Subdomain && app.SubdomainName != "" { + u.Host = strings.Replace(appsHost, "*", app.SubdomainName, 1) + u.Path = "/" + } + return u.String() +} diff --git a/cli/open_internal_test.go b/cli/open_internal_test.go index 1f550156d43d0..7af4359a56bc2 100644 --- a/cli/open_internal_test.go +++ b/cli/open_internal_test.go @@ -1,6 +1,14 @@ package cli -import "testing" +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) func Test_resolveAgentAbsPath(t *testing.T) { t.Parallel() @@ -54,3 +62,107 @@ func Test_resolveAgentAbsPath(t *testing.T) { }) } } + +func Test_buildAppLinkURL(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + // function arguments + baseURL string + workspace codersdk.Workspace + agent codersdk.WorkspaceAgent + app codersdk.WorkspaceApp + appsHost string + preferredPathBase string + // expected results + expectedLink string + }{ + { + name: "external url", + baseURL: "https://coder.tld", + app: codersdk.WorkspaceApp{ + External: true, + URL: "https://external-url.tld", + }, + expectedLink: "https://external-url.tld", + }, + { + name: "without subdomain", + baseURL: "https://coder.tld", + workspace: codersdk.Workspace{ + Name: "Test-Workspace", + OwnerName: "username", + }, + agent: codersdk.WorkspaceAgent{ + Name: "a-workspace-agent", + }, + app: codersdk.WorkspaceApp{ + Slug: "app-slug", + Subdomain: false, + }, + preferredPathBase: "/path-base", + expectedLink: "https://coder.tld/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/", + }, + { + name: "with command", + baseURL: "https://coder.tld", + workspace: codersdk.Workspace{ + Name: "Test-Workspace", + OwnerName: "username", + }, + agent: codersdk.WorkspaceAgent{ + Name: "a-workspace-agent", + }, + app: codersdk.WorkspaceApp{ + Command: "ls -la", + }, + expectedLink: "https://coder.tld/@username/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la", + }, + { + name: "with subdomain", + baseURL: "ftps://coder.tld", + workspace: codersdk.Workspace{ + Name: "Test-Workspace", + OwnerName: "username", + }, + agent: codersdk.WorkspaceAgent{ + Name: "a-workspace-agent", + }, + app: codersdk.WorkspaceApp{ + Subdomain: true, + SubdomainName: "hellocoder", + }, + preferredPathBase: "/path-base", + appsHost: "*.apps-host.tld", + expectedLink: "ftps://hellocoder.apps-host.tld/", + }, + { + name: "with subdomain, but not apps host", + baseURL: "https://coder.tld", + workspace: codersdk.Workspace{ + Name: "Test-Workspace", + OwnerName: "username", + }, + agent: codersdk.WorkspaceAgent{ + Name: "a-workspace-agent", + }, + app: codersdk.WorkspaceApp{ + Slug: "app-slug", + Subdomain: true, + SubdomainName: "It really doesn't matter what this is without AppsHost.", + }, + preferredPathBase: "/path-base", + expectedLink: "https://coder.tld/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/", + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + baseURL, err := url.Parse(tt.baseURL) + require.NoError(t, err) + actual := buildAppLinkURL(baseURL, tt.workspace, tt.agent, tt.app, tt.appsHost, tt.preferredPathBase) + assert.Equal(t, tt.expectedLink, actual) + }) + } +} diff --git a/cli/open_test.go b/cli/open_test.go index 6e32e8c49fa79..4e3c732dd4d52 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -33,7 +33,7 @@ func TestOpenVSCode(t *testing.T) { }) _ = agenttest.New(t, client.URL, agentToken) - _ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() insideWorkspaceEnv := map[string]string{ "CODER": "true", @@ -168,7 +168,7 @@ func TestOpenVSCode_NoAgentDirectory(t *testing.T) { }) _ = agenttest.New(t, client.URL, agentToken) - _ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() insideWorkspaceEnv := map[string]string{ "CODER": "true", @@ -283,3 +283,71 @@ func TestOpenVSCode_NoAgentDirectory(t *testing.T) { }) } } + +func TestOpenApp(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client, ws, _ := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent { + agents[0].Apps = []*proto.App{ + { + Slug: "app1", + Url: "https://example.com/app1", + }, + } + return agents + }) + + inv, root := clitest.New(t, "open", "app", ws.Name, "app1", "--test.open-error") + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() + + w := clitest.StartWithWaiter(t, inv) + w.RequireError() + w.RequireContains("test.open-error") + }) + + t.Run("AppNotFound", func(t *testing.T) { + t.Parallel() + + client, ws, _ := setupWorkspaceForAgent(t) + + inv, root := clitest.New(t, "open", "app", ws.Name, "app1") + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() + + w := clitest.StartWithWaiter(t, inv) + w.RequireError() + w.RequireContains("app not found") + }) + + t.Run("RegionNotFound", func(t *testing.T) { + t.Parallel() + + client, ws, _ := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent { + agents[0].Apps = []*proto.App{ + { + Slug: "app1", + Url: "https://example.com/app1", + }, + } + return agents + }) + + inv, root := clitest.New(t, "open", "app", ws.Name, "app1", "--preferred-region", "bad-region") + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() + + w := clitest.StartWithWaiter(t, inv) + w.RequireError() + w.RequireContains("region not found") + }) +} diff --git a/cli/testdata/coder_open_--help.golden b/cli/testdata/coder_open_--help.golden index fe7eed1b886a9..b9e0d70906b59 100644 --- a/cli/testdata/coder_open_--help.golden +++ b/cli/testdata/coder_open_--help.golden @@ -6,6 +6,7 @@ USAGE: Open a workspace SUBCOMMANDS: + app Open a workspace application. vscode Open a workspace in VS Code Desktop ——— diff --git a/cli/testdata/coder_open_app_--help.golden b/cli/testdata/coder_open_app_--help.golden new file mode 100644 index 0000000000000..542d931d9568d --- /dev/null +++ b/cli/testdata/coder_open_app_--help.golden @@ -0,0 +1,14 @@ +coder v0.0.0-devel + +USAGE: + coder open app [flags] + + Open a workspace application. + +OPTIONS: + --preferred-region string, $CODER_OPEN_APP_PREFERRED_REGION (default: primary) + Preferred region to use when opening the app. By default, the app will + be opened using the main Coder deployment (a.k.a. "primary"). + +——— +Run `coder --help` for a list of global options. diff --git a/docs/manifest.json b/docs/manifest.json index f37f9a9db67f7..7b15d7ac81754 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1065,6 +1065,11 @@ "description": "Open a workspace", "path": "reference/cli/open.md" }, + { + "title": "open app", + "description": "Open a workspace application.", + "path": "reference/cli/open_app.md" + }, { "title": "open vscode", "description": "Open a workspace in VS Code Desktop", diff --git a/docs/reference/cli/open.md b/docs/reference/cli/open.md index e19bdaeba884d..0f54e4648e872 100644 --- a/docs/reference/cli/open.md +++ b/docs/reference/cli/open.md @@ -14,3 +14,4 @@ coder open | Name | Purpose | |-----------------------------------------|-------------------------------------| | [vscode](./open_vscode.md) | Open a workspace in VS Code Desktop | +| [app](./open_app.md) | Open a workspace application. | diff --git a/docs/reference/cli/open_app.md b/docs/reference/cli/open_app.md new file mode 100644 index 0000000000000..6d1f3b2befd2f --- /dev/null +++ b/docs/reference/cli/open_app.md @@ -0,0 +1,22 @@ + +# open app + +Open a workspace application. + +## Usage + +```console +coder open app [flags] +``` + +## Options + +### --preferred-region + +| | | +|-------------|-----------------------------------------------| +| Type | string | +| Environment | $CODER_OPEN_APP_PREFERRED_REGION | +| Default | primary | + +Preferred region to use when opening the app. By default, the app will be opened using the main Coder deployment (a.k.a. "primary"). From 56fc1ce1055d1c0f1497b113a195a68afc26bcd0 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 21 Mar 2025 13:19:12 +0000 Subject: [PATCH 2/5] Update cli/open.go --- cli/open.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/open.go b/cli/open.go index 2955edfe98bdb..58fc8e841a264 100644 --- a/cli/open.go +++ b/cli/open.go @@ -232,9 +232,8 @@ func (r *RootCmd) openApp() *serpent.Command { ctx, cancel := context.WithCancel(inv.Context()) defer cancel() - // Check if we're inside a workspace, and especially inside _this_ - // workspace so we can perform path resolution/expansion. Generally, - // we know that if we're inside a workspace, `open` can't be used. + // Check if we're inside a workspace. Generally, we know + // that if we're inside a workspace, `open` can't be used. insideAWorkspace := inv.Environ.Get("CODER") == "true" // Fetch the preferred region. From f94b6feacf49105b870c965be243571d0b095a86 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 21 Mar 2025 15:04:23 +0000 Subject: [PATCH 3/5] address PR comments --- cli/open.go | 91 ++++++++++++++++++++++++++++-------------------- cli/open_test.go | 33 +++++++++++++++++- 2 files changed, 85 insertions(+), 39 deletions(-) diff --git a/cli/open.go b/cli/open.go index 58fc8e841a264..97eb85441bccc 100644 --- a/cli/open.go +++ b/cli/open.go @@ -2,7 +2,9 @@ package cli import ( "context" + "errors" "fmt" + "net/http" "net/url" "path" "path/filepath" @@ -215,8 +217,8 @@ func (r *RootCmd) openVSCode() *serpent.Command { func (r *RootCmd) openApp() *serpent.Command { var ( - preferredRegion string - testOpenError bool + regionArg string + testOpenError bool ) client := new(codersdk.Client) @@ -225,69 +227,82 @@ func (r *RootCmd) openApp() *serpent.Command { Use: "app ", Short: "Open a workspace application.", Middleware: serpent.Chain( - serpent.RequireNArgs(2), r.InitClient(client), ), Handler: func(inv *serpent.Invocation) error { ctx, cancel := context.WithCancel(inv.Context()) defer cancel() - // Check if we're inside a workspace. Generally, we know - // that if we're inside a workspace, `open` can't be used. - insideAWorkspace := inv.Environ.Get("CODER") == "true" + if len(inv.Args) == 0 || len(inv.Args) > 2 { + return inv.Command.HelpHandler(inv) + } - // Fetch the preferred region. + workspaceName := inv.Args[0] + ws, agt, err := getWorkspaceAndAgent(ctx, inv, client, false, workspaceName) + if err != nil { + var sdkErr *codersdk.Error + if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { + cliui.Errorf(inv.Stderr, "Workspace %q not found!", workspaceName) + return sdkErr + } + cliui.Errorf(inv.Stderr, "Failed to get workspace and agent: %s", err) + return err + } + + allAppSlugs := make([]string, len(agt.Apps)) + for i, app := range agt.Apps { + allAppSlugs[i] = app.Slug + } + + // If a user doesn't specify an app slug, we'll just list the available + // apps and exit. + if len(inv.Args) == 1 { + cliui.Infof(inv.Stderr, "Available apps in %q: %v", workspaceName, allAppSlugs) + return nil + } + + appSlug := inv.Args[1] + var foundApp codersdk.WorkspaceApp + appIdx := slices.IndexFunc(agt.Apps, func(a codersdk.WorkspaceApp) bool { + return a.Slug == appSlug + }) + if appIdx == -1 { + cliui.Errorf(inv.Stderr, "App %q not found in workspace %q!\nAvailable apps: %v", appSlug, workspaceName, allAppSlugs) + return xerrors.Errorf("app not found") + } + foundApp = agt.Apps[appIdx] + + // To build the app URL, we need to know the wildcard hostname + // and path app URL for the region. regions, err := client.Regions(ctx) if err != nil { return xerrors.Errorf("failed to fetch regions: %w", err) } var region codersdk.Region preferredIdx := slices.IndexFunc(regions, func(r codersdk.Region) bool { - return r.Name == preferredRegion + return r.Name == regionArg }) if preferredIdx == -1 { allRegions := make([]string, len(regions)) for i, r := range regions { allRegions[i] = r.Name } - cliui.Errorf(inv.Stderr, "Preferred region %q not found!\nAvailable regions: %v", preferredRegion, allRegions) + cliui.Errorf(inv.Stderr, "Preferred region %q not found!\nAvailable regions: %v", regionArg, allRegions) return xerrors.Errorf("region not found") } region = regions[preferredIdx] - workspaceName := inv.Args[0] - appSlug := inv.Args[1] - - // Fetch the ws and agent - ws, agt, err := getWorkspaceAndAgent(ctx, inv, client, false, workspaceName) - if err != nil { - return xerrors.Errorf("failed to get workspace and agent: %w", err) - } - - // Fetch the app - var app codersdk.WorkspaceApp - appIdx := slices.IndexFunc(agt.Apps, func(a codersdk.WorkspaceApp) bool { - return a.Slug == appSlug - }) - if appIdx == -1 { - appSlugs := make([]string, len(agt.Apps)) - for i, app := range agt.Apps { - appSlugs[i] = app.Slug - } - cliui.Errorf(inv.Stderr, "App %q not found in workspace %q!\nAvailable apps: %v", appSlug, workspaceName, appSlugs) - return xerrors.Errorf("app not found") - } - app = agt.Apps[appIdx] - - // Build the URL baseURL, err := url.Parse(region.PathAppURL) if err != nil { return xerrors.Errorf("failed to parse proxy URL: %w", err) } baseURL.Path = "" pathAppURL := strings.TrimPrefix(region.PathAppURL, baseURL.String()) - appURL := buildAppLinkURL(baseURL, ws, agt, app, region.WildcardHostname, pathAppURL) + appURL := buildAppLinkURL(baseURL, ws, agt, foundApp, region.WildcardHostname, pathAppURL) + // Check if we're inside a workspace. Generally, we know + // that if we're inside a workspace, `open` can't be used. + insideAWorkspace := inv.Environ.Get("CODER") == "true" if insideAWorkspace { _, _ = fmt.Fprintf(inv.Stderr, "Please open the following URI on your local machine:\n\n") _, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL) @@ -306,11 +321,11 @@ func (r *RootCmd) openApp() *serpent.Command { cmd.Options = serpent.OptionSet{ { - Flag: "preferred-region", + Flag: "region", Env: "CODER_OPEN_APP_PREFERRED_REGION", - Description: fmt.Sprintf("Preferred region to use when opening the app." + + Description: fmt.Sprintf("Region to use when opening the app." + " By default, the app will be opened using the main Coder deployment (a.k.a. \"primary\")."), - Value: serpent.StringOf(&preferredRegion), + Value: serpent.StringOf(®ionArg), Default: "primary", }, { diff --git a/cli/open_test.go b/cli/open_test.go index 4e3c732dd4d52..23a4316b75c31 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -311,6 +312,36 @@ func TestOpenApp(t *testing.T) { w.RequireContains("test.open-error") }) + t.Run("OnlyWorkspaceName", func(t *testing.T) { + t.Parallel() + + client, ws, _ := setupWorkspaceForAgent(t) + inv, root := clitest.New(t, "open", "app", ws.Name) + clitest.SetupConfig(t, client, root) + var sb strings.Builder + inv.Stdout = &sb + inv.Stderr = &sb + + w := clitest.StartWithWaiter(t, inv) + w.RequireSuccess() + + require.Contains(t, sb.String(), "Available apps in") + }) + + t.Run("WorkspaceNotFound", func(t *testing.T) { + t.Parallel() + + client, _, _ := setupWorkspaceForAgent(t) + inv, root := clitest.New(t, "open", "app", "not-a-workspace", "app1") + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + inv.Stdin = pty.Input() + inv.Stdout = pty.Output() + w := clitest.StartWithWaiter(t, inv) + w.RequireError() + w.RequireContains("Resource not found or you do not have access to this resource") + }) + t.Run("AppNotFound", func(t *testing.T) { t.Parallel() @@ -340,7 +371,7 @@ func TestOpenApp(t *testing.T) { return agents }) - inv, root := clitest.New(t, "open", "app", ws.Name, "app1", "--preferred-region", "bad-region") + inv, root := clitest.New(t, "open", "app", ws.Name, "app1", "--region", "bad-region") clitest.SetupConfig(t, client, root) pty := ptytest.New(t) inv.Stdin = pty.Input() From c7a942be4321b44a090c8a1e277d3860a6e4b4ba Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 21 Mar 2025 15:08:03 +0000 Subject: [PATCH 4/5] make gen --- cli/open.go | 2 +- cli/testdata/coder_open_app_--help.golden | 6 +++--- docs/reference/cli/open_app.md | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cli/open.go b/cli/open.go index 97eb85441bccc..d323f8e5dec82 100644 --- a/cli/open.go +++ b/cli/open.go @@ -322,7 +322,7 @@ func (r *RootCmd) openApp() *serpent.Command { cmd.Options = serpent.OptionSet{ { Flag: "region", - Env: "CODER_OPEN_APP_PREFERRED_REGION", + Env: "CODER_OPEN_APP_REGION", Description: fmt.Sprintf("Region to use when opening the app." + " By default, the app will be opened using the main Coder deployment (a.k.a. \"primary\")."), Value: serpent.StringOf(®ionArg), diff --git a/cli/testdata/coder_open_app_--help.golden b/cli/testdata/coder_open_app_--help.golden index 542d931d9568d..c648e88d058a5 100644 --- a/cli/testdata/coder_open_app_--help.golden +++ b/cli/testdata/coder_open_app_--help.golden @@ -6,9 +6,9 @@ USAGE: Open a workspace application. OPTIONS: - --preferred-region string, $CODER_OPEN_APP_PREFERRED_REGION (default: primary) - Preferred region to use when opening the app. By default, the app will - be opened using the main Coder deployment (a.k.a. "primary"). + --region string, $CODER_OPEN_APP_REGION (default: primary) + Region to use when opening the app. By default, the app will be opened + using the main Coder deployment (a.k.a. "primary"). ——— Run `coder --help` for a list of global options. diff --git a/docs/reference/cli/open_app.md b/docs/reference/cli/open_app.md index 6d1f3b2befd2f..1edd274815c52 100644 --- a/docs/reference/cli/open_app.md +++ b/docs/reference/cli/open_app.md @@ -11,12 +11,12 @@ coder open app [flags] ## Options -### --preferred-region +### --region -| | | -|-------------|-----------------------------------------------| -| Type | string | -| Environment | $CODER_OPEN_APP_PREFERRED_REGION | -| Default | primary | +| | | +|-------------|-------------------------------------| +| Type | string | +| Environment | $CODER_OPEN_APP_REGION | +| Default | primary | -Preferred region to use when opening the app. By default, the app will be opened using the main Coder deployment (a.k.a. "primary"). +Region to use when opening the app. By default, the app will be opened using the main Coder deployment (a.k.a. "primary"). From 598dec612d9475c2e467c2ab377e9d9a62dc8d6e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 21 Mar 2025 15:14:39 +0000 Subject: [PATCH 5/5] sort ALL the things --- cli/open.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/open.go b/cli/open.go index d323f8e5dec82..10a58f1c3693a 100644 --- a/cli/open.go +++ b/cli/open.go @@ -253,6 +253,7 @@ func (r *RootCmd) openApp() *serpent.Command { for i, app := range agt.Apps { allAppSlugs[i] = app.Slug } + slices.Sort(allAppSlugs) // If a user doesn't specify an app slug, we'll just list the available // apps and exit.