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},
2728Children : []* serpent.Command {
2829r .openVSCode (),
30+ r .openApp (),
2931},
3032}
3133return cmd
@@ -211,6 +213,118 @@ func (r *RootCmd) openVSCode() *serpent.Command {
211213return 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+ }