77 "path"
88 "path/filepath"
99 "runtime"
10+ "slices"
1011 "strings"
1112
1213 "github.com/skratchdot/open-golang/open"
@@ -26,6 +27,7 @@ func (r *RootCmd) open() *serpent.Command {
2627 },
2728 Children : []* serpent.Command {
2829 r .openVSCode (),
30+ r .openApp (),
2931 },
3032 }
3133 return cmd
@@ -211,6 +213,118 @@ func (r *RootCmd) openVSCode() *serpent.Command {
211213 return cmd
212214}
213215
216+ func (r * RootCmd ) openApp () * serpent.Command {
217+ var (
218+ preferredRegion string
219+ testOpenError bool
220+ )
221+
222+ client := new (codersdk.Client )
223+ cmd := & serpent.Command {
224+ Annotations : workspaceCommand ,
225+ Use : "app <workspace> <app slug>" ,
226+ Short : fmt .Sprintf ("Open a workspace application." ),
227+ Middleware : serpent .Chain (
228+ serpent .RequireNArgs (2 ),
229+ r .InitClient (client ),
230+ ),
231+ Handler : func (inv * serpent.Invocation ) error {
232+ ctx , cancel := context .WithCancel (inv .Context ())
233+ defer cancel ()
234+
235+ // Check if we're inside a workspace, and especially inside _this_
236+ // workspace so we can perform path resolution/expansion. Generally,
237+ // we know that if we're inside a workspace, `open` can't be used.
238+ insideAWorkspace := inv .Environ .Get ("CODER" ) == "true"
239+
240+ // Fetch the preferred region.
241+ regions , err := client .Regions (ctx )
242+ if err != nil {
243+ return fmt .Errorf ("failed to fetch regions: %w" , err )
244+ }
245+ var region codersdk.Region
246+ if preferredIdx := slices .IndexFunc (regions , func (r codersdk.Region ) bool {
247+ return r .Name == preferredRegion
248+ }); preferredIdx == - 1 {
249+ allRegions := make ([]string , len (regions ))
250+ for i , r := range regions {
251+ allRegions [i ] = r .Name
252+ }
253+ cliui .Errorf (inv .Stderr , "Preferred region %q not found!\n Available regions: %v" , preferredRegion , allRegions )
254+ return fmt .Errorf ("region not found" )
255+ } else {
256+ region = regions [preferredIdx ]
257+ }
258+
259+ workspaceName := inv .Args [0 ]
260+ appSlug := inv .Args [1 ]
261+
262+ // Fetch the ws and agent
263+ ws , agt , err := getWorkspaceAndAgent (ctx , inv , client , false , workspaceName )
264+ if err != nil {
265+ return fmt .Errorf ("failed to get workspace and agent: %w" , err )
266+ }
267+
268+ // Fetch the app
269+ var app codersdk.WorkspaceApp
270+ if appIdx := slices .IndexFunc (agt .Apps , func (a codersdk.WorkspaceApp ) bool {
271+ return a .Slug == appSlug
272+ }); appIdx == - 1 {
273+ appSlugs := make ([]string , len (agt .Apps ))
274+ for i , app := range agt .Apps {
275+ appSlugs [i ] = app .Slug
276+ }
277+ cliui .Errorf (inv .Stderr , "App %q not found in workspace %q!\n Available apps: %v" , appSlug , workspaceName , appSlugs )
278+ return fmt .Errorf ("app not found" )
279+ } else {
280+ app = agt .Apps [appIdx ]
281+ }
282+
283+ // Build the URL
284+ baseURL , err := url .Parse (region .PathAppURL )
285+ if err != nil {
286+ return fmt .Errorf ("failed to parse proxy URL: %w" , err )
287+ }
288+ baseURL .Path = ""
289+ pathAppURL := strings .TrimPrefix (region .PathAppURL , baseURL .String ())
290+ appURL := buildAppLinkURL (baseURL , ws , agt , app , region .WildcardHostname , pathAppURL )
291+
292+ if insideAWorkspace {
293+ _ , _ = fmt .Fprintf (inv .Stderr , "Please open the following URI on your local machine:\n \n " )
294+ _ , _ = fmt .Fprintf (inv .Stdout , "%s\n " , appURL )
295+ return nil
296+ }
297+ _ , _ = fmt .Fprintf (inv .Stderr , "Opening %s\n " , appURL )
298+
299+ if ! testOpenError {
300+ err = open .Run (appURL )
301+ } else {
302+ err = xerrors .New ("test.open-error" )
303+ }
304+ return err
305+ },
306+ }
307+
308+ cmd .Options = serpent.OptionSet {
309+ {
310+ Flag : "preferred-region" ,
311+ Env : "CODER_OPEN_APP_PREFERRED_REGION" ,
312+ Description : fmt .Sprintf ("Preferred region to use when opening the app." +
313+ " By default, the app will be opened using the main Coder deployment (a.k.a. \" primary\" )." ),
314+ Value : serpent .StringOf (& preferredRegion ),
315+ Default : "primary" ,
316+ },
317+ {
318+ Flag : "test.open-error" ,
319+ Description : "Don't run the open command." ,
320+ Value : serpent .BoolOf (& testOpenError ),
321+ Hidden : true , // This is for testing!
322+ },
323+ }
324+
325+ return cmd
326+ }
327+
214328// waitForAgentCond uses the watch workspace API to update the agent information
215329// until the condition is met.
216330func 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()) {
337451 <- done
338452 }
339453}
454+
455+ // buildAppLinkURL returns the URL to open the app in the browser.
456+ // It follows similar logic to the TypeScript implementation in site/src/utils/app.ts
457+ // except that all URLs returned are absolute and based on the provided base URL.
458+ func buildAppLinkURL (baseURL * url.URL , workspace codersdk.Workspace , agent codersdk.WorkspaceAgent , app codersdk.WorkspaceApp , appsHost , preferredPathBase string ) string {
459+ // If app is external, return the URL directly
460+ if app .External {
461+ return app .URL
462+ }
463+
464+ var u url.URL
465+ u .Scheme = baseURL .Scheme
466+ u .Host = baseURL .Host
467+ // We redirect if we don't include a trailing slash, so we always include one to avoid extra roundtrips.
468+ u .Path = fmt .Sprintf (
469+ "%s/@%s/%s.%s/apps/%s/" ,
470+ preferredPathBase ,
471+ workspace .OwnerName ,
472+ workspace .Name ,
473+ agent .Name ,
474+ url .PathEscape (app .Slug ),
475+ )
476+ // The frontend leaves the returns a relative URL for the terminal, but we don't have that luxury.
477+ if app .Command != "" {
478+ u .Path = fmt .Sprintf (
479+ "%s/@%s/%s.%s/terminal" ,
480+ preferredPathBase ,
481+ workspace .OwnerName ,
482+ workspace .Name ,
483+ agent .Name ,
484+ )
485+ q := u .Query ()
486+ q .Set ("command" , app .Command )
487+ u .RawQuery = q .Encode ()
488+ // encodeURIComponent replaces spaces with %20 but url.QueryEscape replaces them with +.
489+ // We replace them with %20 to match the TypeScript implementation.
490+ u .RawQuery = strings .ReplaceAll (u .RawQuery , "+" , "%20" )
491+ }
492+
493+ if appsHost != "" && app .Subdomain && app .SubdomainName != "" {
494+ u .Host = strings .Replace (appsHost , "*" , app .SubdomainName , 1 )
495+ u .Path = "/"
496+ }
497+ return u .String ()
498+ }
0 commit comments