@@ -2,11 +2,14 @@ package cli
2
2
3
3
import (
4
4
"context"
5
+ "errors"
5
6
"fmt"
7
+ "net/http"
6
8
"net/url"
7
9
"path"
8
10
"path/filepath"
9
11
"runtime"
12
+ "slices"
10
13
"strings"
11
14
12
15
"github.com/skratchdot/open-golang/open"
@@ -26,6 +29,7 @@ func (r *RootCmd) open() *serpent.Command {
26
29
},
27
30
Children : []* serpent.Command {
28
31
r .openVSCode (),
32
+ r .openApp (),
29
33
},
30
34
}
31
35
return cmd
@@ -211,6 +215,131 @@ func (r *RootCmd) openVSCode() *serpent.Command {
211
215
return cmd
212
216
}
213
217
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
+
214
343
// waitForAgentCond uses the watch workspace API to update the agent information
215
344
// until the condition is met.
216
345
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()) {
337
466
<- done
338
467
}
339
468
}
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
+ }
0 commit comments