Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 21, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
158 changes: 158 additions & 0 deletions cli/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path"
"path/filepath"
"runtime"
"slices"
"strings"

"github.com/skratchdot/open-golang/open"
Expand All @@ -26,6 +27,7 @@ func (r *RootCmd) open() *serpent.Command {
},
Children: []*serpent.Command{
r.openVSCode(),
r.openApp(),
},
}
return cmd
Expand Down Expand Up @@ -211,6 +213,117 @@ 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 <workspace> <app slug>",
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"

// 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) {
Expand Down Expand Up @@ -337,3 +450,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")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review: I'm not sure if this is strictly necessary? Did it for completeness' sake.

}

if appsHost != "" && app.Subdomain && app.SubdomainName != "" {
u.Host = strings.Replace(appsHost, "*", app.SubdomainName, 1)
u.Path = "/"
}
return u.String()
}
114 changes: 113 additions & 1 deletion cli/open_internal_test.go
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -54,3 +62,107 @@ func Test_resolveAgentAbsPath(t *testing.T) {
})
}
}

func Test_buildAppLinkURL(t *testing.T) {
Copy link
Member Author

@johnstcn johnstcn Mar 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review: I adapted the test cases from app.test.ts. If you have other suggestions for test cases, I'd be happy to add them!

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)
})
}
}
72 changes: 70 additions & 2 deletions cli/open_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")
})
}
Loading
Loading