7
7
"path"
8
8
"path/filepath"
9
9
"runtime"
10
+ "slices"
10
11
"strings"
11
12
12
13
"github.com/skratchdot/open-golang/open"
@@ -26,6 +27,7 @@ func (r *RootCmd) open() *serpent.Command {
26
27
},
27
28
Children : []* serpent.Command {
28
29
r .openVSCode (),
30
+ r .openApp (),
29
31
},
30
32
}
31
33
return cmd
@@ -211,6 +213,118 @@ func (r *RootCmd) openVSCode() *serpent.Command {
211
213
return cmd
212
214
}
213
215
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
+
214
328
// waitForAgentCond uses the watch workspace API to update the agent information
215
329
// until the condition is met.
216
330
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 +451,48 @@ func doAsync(f func()) (wait func()) {
337
451
<- done
338
452
}
339
453
}
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