Skip to content

Commit 58af238

Browse files
committed
feat: open coder apps with CLI
1 parent c88d86b commit 58af238

File tree

2 files changed

+127
-0
lines changed

2 files changed

+127
-0
lines changed

cli/open.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func (r *RootCmd) open() *serpent.Command {
2626
},
2727
Children: []*serpent.Command{
2828
r.openVSCode(),
29+
r.openApp(),
2930
},
3031
}
3132
return cmd
@@ -327,6 +328,127 @@ func resolveAgentAbsPath(workingDirectory, relOrAbsPath, agentOS string, local b
327328
}
328329
}
329330

331+
func (r *RootCmd) openApp() *serpent.Command {
332+
var (
333+
testOpenError bool
334+
appearanceConfig codersdk.AppearanceConfig
335+
)
336+
337+
client := new(codersdk.Client)
338+
cmd := &serpent.Command{
339+
Annotations: workspaceCommand,
340+
Use: "app <workspace> <app-slug>",
341+
Short: "Open a workspace app in the browser",
342+
Middleware: serpent.Chain(
343+
serpent.RequireNArgs(2),
344+
r.InitClient(client),
345+
initAppearance(client, &appearanceConfig),
346+
),
347+
Handler: func(inv *serpent.Invocation) error {
348+
ctx, cancel := context.WithCancel(inv.Context())
349+
defer cancel()
350+
351+
// Check if we're inside a workspace
352+
insideAWorkspace := inv.Environ.Get("CODER") == "true"
353+
inWorkspaceName := inv.Environ.Get("CODER_WORKSPACE_NAME") + "." + inv.Environ.Get("CODER_WORKSPACE_AGENT_NAME")
354+
355+
// Get workspace and agent
356+
workspaceQuery := inv.Args[0]
357+
appSlug := inv.Args[1]
358+
autostart := true
359+
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery)
360+
if err != nil {
361+
return xerrors.Errorf("get workspace and agent: %w", err)
362+
}
363+
364+
workspaceName := workspace.Name + "." + workspaceAgent.Name
365+
insideThisWorkspace := insideAWorkspace && inWorkspaceName == workspaceName
366+
367+
if !insideThisWorkspace {
368+
// Wait for the agent to connect
369+
err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{
370+
Fetch: client.WorkspaceAgent,
371+
FetchLogs: nil,
372+
Wait: false,
373+
DocsURL: appearanceConfig.DocsURL,
374+
})
375+
if err != nil {
376+
if xerrors.Is(err, context.Canceled) {
377+
return cliui.Canceled
378+
}
379+
return xerrors.Errorf("agent: %w", err)
380+
}
381+
}
382+
383+
// Fetch the latest agent data to get apps
384+
workspaceAgent, err = client.WorkspaceAgent(ctx, workspaceAgent.ID)
385+
if err != nil {
386+
return xerrors.Errorf("get workspace agent: %w", err)
387+
}
388+
389+
var matchedApp *codersdk.WorkspaceApp
390+
for _, app := range workspaceAgent.Apps {
391+
if app.Slug == appSlug {
392+
matchedApp = &app
393+
break
394+
}
395+
}
396+
397+
if matchedApp == nil {
398+
return xerrors.Errorf("app %q not found for workspace %q", appSlug, workspaceName)
399+
}
400+
401+
// External apps need to be opened directly, while internal apps require a URL for the Coder dashboard
402+
var appURL string
403+
if matchedApp.External {
404+
appURL = matchedApp.URL
405+
} else {
406+
// Construct the URL similar to the frontend's createAppLinkHref
407+
username := workspace.OwnerName
408+
appURL = fmt.Sprintf("%s/@%s/%s.%s/apps/%s/",
409+
client.URL.String(),
410+
username,
411+
workspace.Name,
412+
workspaceAgent.Name,
413+
url.PathEscape(appSlug),
414+
)
415+
}
416+
417+
if insideAWorkspace {
418+
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s is not supported inside a workspace, please open the following URL on your local machine instead:\n\n", workspaceName, matchedApp.DisplayName)
419+
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL)
420+
return nil
421+
}
422+
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s\n", workspaceName, matchedApp.DisplayName)
423+
424+
if !testOpenError {
425+
err = open.Run(appURL)
426+
} else {
427+
err = xerrors.New("test.open-error")
428+
}
429+
if err != nil {
430+
_, _ = fmt.Fprintf(inv.Stderr, "Could not automatically open %s in %s: %s\n", workspaceName, matchedApp.DisplayName, err)
431+
_, _ = fmt.Fprintf(inv.Stderr, "Please open the following URL instead:\n\n")
432+
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL)
433+
return nil
434+
}
435+
436+
return nil
437+
},
438+
}
439+
440+
cmd.Options = serpent.OptionSet{
441+
{
442+
Flag: "test.open-error",
443+
Description: "Don't run the open command.",
444+
Value: serpent.BoolOf(&testOpenError),
445+
Hidden: true, // This is for testing!
446+
},
447+
}
448+
449+
return cmd
450+
}
451+
330452
func doAsync(f func()) (wait func()) {
331453
done := make(chan struct{})
332454
go func() {

cli/open_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"runtime"
88
"testing"
99

10+
"github.com/google/uuid"
1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
1213

@@ -283,3 +284,7 @@ func TestOpenVSCode_NoAgentDirectory(t *testing.T) {
283284
})
284285
}
285286
}
287+
288+
func TestOpenApp(t *testing.T) {
289+
t.Skip("Incomplete test implementation")
290+
}

0 commit comments

Comments
 (0)