feat(cli): add open app <workspace> <app-slug> command (#17032)

Fixes https://github.com/coder/coder/issues/17009

Adds a CLI command `coder open app <workspace> <app-slug>` that allows
opening arbitrary `coder_apps` via the CLI.

Users can optionally specify a region for workspace
applications.
This commit is contained in:
Cian Johnston
2025-03-21 15:28:08 +00:00
committed by GitHub
parent 3b6bee9676
commit 0474888eb4
8 changed files with 431 additions and 3 deletions

View File

@ -2,11 +2,14 @@ package cli
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/skratchdot/open-golang/open"
@ -26,6 +29,7 @@ func (r *RootCmd) open() *serpent.Command {
},
Children: []*serpent.Command{
r.openVSCode(),
r.openApp(),
},
}
return cmd
@ -211,6 +215,131 @@ func (r *RootCmd) openVSCode() *serpent.Command {
return cmd
}
func (r *RootCmd) openApp() *serpent.Command {
var (
regionArg string
testOpenError bool
)
client := new(codersdk.Client)
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "app <workspace> <app slug>",
Short: "Open a workspace application.",
Middleware: serpent.Chain(
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
if len(inv.Args) == 0 || len(inv.Args) > 2 {
return inv.Command.HelpHandler(inv)
}
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
}
slices.Sort(allAppSlugs)
// 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 == 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", regionArg, allRegions)
return xerrors.Errorf("region not found")
}
region = regions[preferredIdx]
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, 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)
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: "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(&regionArg),
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 +466,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()
}

View File

@ -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)
})
}
}

View File

@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@ -33,7 +34,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 +169,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 +284,101 @@ 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("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()
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", "--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")
})
}

View File

@ -6,6 +6,7 @@ USAGE:
Open a workspace
SUBCOMMANDS:
app Open a workspace application.
vscode Open a workspace in VS Code Desktop
———

View File

@ -0,0 +1,14 @@
coder v0.0.0-devel
USAGE:
coder open app [flags] <workspace> <app slug>
Open a workspace application.
OPTIONS:
--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.

View File

@ -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",

View File

@ -14,3 +14,4 @@ coder open
| Name | Purpose |
|-----------------------------------------|-------------------------------------|
| [<code>vscode</code>](./open_vscode.md) | Open a workspace in VS Code Desktop |
| [<code>app</code>](./open_app.md) | Open a workspace application. |

22
docs/reference/cli/open_app.md generated Normal file
View File

@ -0,0 +1,22 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# open app
Open a workspace application.
## Usage
```console
coder open app [flags] <workspace> <app slug>
```
## Options
### --region
| | |
|-------------|-------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_OPEN_APP_REGION</code> |
| Default | <code>primary</code> |
Region to use when opening the app. By default, the app will be opened using the main Coder deployment (a.k.a. "primary").