@@ -2,11 +2,14 @@ package cli
22
33import (
44"context"
5+ "errors"
56"fmt"
7+ "net/http"
68"net/url"
79"path"
810"path/filepath"
911"runtime"
12+ "slices"
1013"strings"
1114
1215"github.com/skratchdot/open-golang/open"
@@ -26,6 +29,7 @@ func (r *RootCmd) open() *serpent.Command {
2629},
2730Children : []* serpent.Command {
2831r .openVSCode (),
32+ r .openApp (),
2933},
3034}
3135return cmd
@@ -211,6 +215,131 @@ func (r *RootCmd) openVSCode() *serpent.Command {
211215return cmd
212216}
213217
218+ func (r * RootCmd )openApp ()* serpent.Command {
219+ var (
220+ regionArg string
221+ testOpenError bool
222+ )
223+
224+ client := new (codersdk.Client )
225+ cmd := & serpent.Command {
226+ Annotations :workspaceCommand ,
227+ Use :"app <workspace> <app slug>" ,
228+ Short :"Open a workspace application." ,
229+ Middleware :serpent .Chain (
230+ r .InitClient (client ),
231+ ),
232+ Handler :func (inv * serpent.Invocation )error {
233+ ctx ,cancel := context .WithCancel (inv .Context ())
234+ defer cancel ()
235+
236+ if len (inv .Args )== 0 || len (inv .Args )> 2 {
237+ return inv .Command .HelpHandler (inv )
238+ }
239+
240+ workspaceName := inv .Args [0 ]
241+ ws ,agt ,err := getWorkspaceAndAgent (ctx ,inv ,client ,false ,workspaceName )
242+ if err != nil {
243+ var sdkErr * codersdk.Error
244+ if errors .As (err ,& sdkErr )&& sdkErr .StatusCode ()== http .StatusNotFound {
245+ cliui .Errorf (inv .Stderr ,"Workspace %q not found!" ,workspaceName )
246+ return sdkErr
247+ }
248+ cliui .Errorf (inv .Stderr ,"Failed to get workspace and agent: %s" ,err )
249+ return err
250+ }
251+
252+ allAppSlugs := make ([]string ,len (agt .Apps ))
253+ for i ,app := range agt .Apps {
254+ allAppSlugs [i ]= app .Slug
255+ }
256+ slices .Sort (allAppSlugs )
257+
258+ // If a user doesn't specify an app slug, we'll just list the available
259+ // apps and exit.
260+ if len (inv .Args )== 1 {
261+ cliui .Infof (inv .Stderr ,"Available apps in %q: %v" ,workspaceName ,allAppSlugs )
262+ return nil
263+ }
264+
265+ appSlug := inv .Args [1 ]
266+ var foundApp codersdk.WorkspaceApp
267+ appIdx := slices .IndexFunc (agt .Apps ,func (a codersdk.WorkspaceApp )bool {
268+ return a .Slug == appSlug
269+ })
270+ if appIdx == - 1 {
271+ cliui .Errorf (inv .Stderr ,"App %q not found in workspace %q!\n Available apps: %v" ,appSlug ,workspaceName ,allAppSlugs )
272+ return xerrors .Errorf ("app not found" )
273+ }
274+ foundApp = agt .Apps [appIdx ]
275+
276+ // To build the app URL, we need to know the wildcard hostname
277+ // and path app URL for the region.
278+ regions ,err := client .Regions (ctx )
279+ if err != nil {
280+ return xerrors .Errorf ("failed to fetch regions: %w" ,err )
281+ }
282+ var region codersdk.Region
283+ preferredIdx := slices .IndexFunc (regions ,func (r codersdk.Region )bool {
284+ return r .Name == regionArg
285+ })
286+ if preferredIdx == - 1 {
287+ allRegions := make ([]string ,len (regions ))
288+ for i ,r := range regions {
289+ allRegions [i ]= r .Name
290+ }
291+ cliui .Errorf (inv .Stderr ,"Preferred region %q not found!\n Available regions: %v" ,regionArg ,allRegions )
292+ return xerrors .Errorf ("region not found" )
293+ }
294+ region = regions [preferredIdx ]
295+
296+ baseURL ,err := url .Parse (region .PathAppURL )
297+ if err != nil {
298+ return xerrors .Errorf ("failed to parse proxy URL: %w" ,err )
299+ }
300+ baseURL .Path = ""
301+ pathAppURL := strings .TrimPrefix (region .PathAppURL ,baseURL .String ())
302+ appURL := buildAppLinkURL (baseURL ,ws ,agt ,foundApp ,region .WildcardHostname ,pathAppURL )
303+
304+ // Check if we're inside a workspace. Generally, we know
305+ // that if we're inside a workspace, `open` can't be used.
306+ insideAWorkspace := inv .Environ .Get ("CODER" )== "true"
307+ if insideAWorkspace {
308+ _ ,_ = fmt .Fprintf (inv .Stderr ,"Please open the following URI on your local machine:\n \n " )
309+ _ ,_ = fmt .Fprintf (inv .Stdout ,"%s\n " ,appURL )
310+ return nil
311+ }
312+ _ ,_ = fmt .Fprintf (inv .Stderr ,"Opening %s\n " ,appURL )
313+
314+ if ! testOpenError {
315+ err = open .Run (appURL )
316+ }else {
317+ err = xerrors .New ("test.open-error" )
318+ }
319+ return err
320+ },
321+ }
322+
323+ cmd .Options = serpent.OptionSet {
324+ {
325+ Flag :"region" ,
326+ Env :"CODER_OPEN_APP_REGION" ,
327+ Description :fmt .Sprintf ("Region to use when opening the app." +
328+ " By default, the app will be opened using the main Coder deployment (a.k.a.\" primary\" )." ),
329+ Value :serpent .StringOf (& regionArg ),
330+ Default :"primary" ,
331+ },
332+ {
333+ Flag :"test.open-error" ,
334+ Description :"Don't run the open command." ,
335+ Value :serpent .BoolOf (& testOpenError ),
336+ Hidden :true ,// This is for testing!
337+ },
338+ }
339+
340+ return cmd
341+ }
342+
214343// waitForAgentCond uses the watch workspace API to update the agent information
215344// until the condition is met.
216345func 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()) {
337466<- done
338467}
339468}
469+
470+ // buildAppLinkURL returns the URL to open the app in the browser.
471+ // It follows similar logic to the TypeScript implementation in site/src/utils/app.ts
472+ // except that all URLs returned are absolute and based on the provided base URL.
473+ func buildAppLinkURL (baseURL * url.URL ,workspace codersdk.Workspace ,agent codersdk.WorkspaceAgent ,app codersdk.WorkspaceApp ,appsHost ,preferredPathBase string )string {
474+ // If app is external, return the URL directly
475+ if app .External {
476+ return app .URL
477+ }
478+
479+ var u url.URL
480+ u .Scheme = baseURL .Scheme
481+ u .Host = baseURL .Host
482+ // We redirect if we don't include a trailing slash, so we always include one to avoid extra roundtrips.
483+ u .Path = fmt .Sprintf (
484+ "%s/@%s/%s.%s/apps/%s/" ,
485+ preferredPathBase ,
486+ workspace .OwnerName ,
487+ workspace .Name ,
488+ agent .Name ,
489+ url .PathEscape (app .Slug ),
490+ )
491+ // The frontend leaves the returns a relative URL for the terminal, but we don't have that luxury.
492+ if app .Command != "" {
493+ u .Path = fmt .Sprintf (
494+ "%s/@%s/%s.%s/terminal" ,
495+ preferredPathBase ,
496+ workspace .OwnerName ,
497+ workspace .Name ,
498+ agent .Name ,
499+ )
500+ q := u .Query ()
501+ q .Set ("command" ,app .Command )
502+ u .RawQuery = q .Encode ()
503+ // encodeURIComponent replaces spaces with %20 but url.QueryEscape replaces them with +.
504+ // We replace them with %20 to match the TypeScript implementation.
505+ u .RawQuery = strings .ReplaceAll (u .RawQuery ,"+" ,"%20" )
506+ }
507+
508+ if appsHost != "" && app .Subdomain && app .SubdomainName != "" {
509+ u .Host = strings .Replace (appsHost ,"*" ,app .SubdomainName ,1 )
510+ u .Path = "/"
511+ }
512+ return u .String ()
513+ }