Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 70e04ba

Browse files
committed
feat(cli): add open app <workspace> <app-slug> command
1 parent 69ba27e commit 70e04ba

File tree

8 files changed

+384
-3
lines changed

8 files changed

+384
-3
lines changed

cli/open.go

+159
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
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!\nAvailable 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!\nAvailable 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.
216330
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()) {
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+
}

cli/open_internal_test.go

+112-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
package cli
22

3-
import "testing"
3+
import (
4+
"net/url"
5+
"testing"
6+
7+
"github.com/coder/coder/v2/codersdk"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
411

512
func Test_resolveAgentAbsPath(t *testing.T) {
613
t.Parallel()
@@ -54,3 +61,107 @@ func Test_resolveAgentAbsPath(t *testing.T) {
5461
})
5562
}
5663
}
64+
65+
func Test_buildAppLinkURL(t *testing.T) {
66+
t.Parallel()
67+
68+
for _, tt := range []struct {
69+
name string
70+
// function arguments
71+
baseURL string
72+
workspace codersdk.Workspace
73+
agent codersdk.WorkspaceAgent
74+
app codersdk.WorkspaceApp
75+
appsHost string
76+
preferredPathBase string
77+
// expected results
78+
expectedLink string
79+
}{
80+
{
81+
name: "external url",
82+
baseURL: "https://coder.tld",
83+
app: codersdk.WorkspaceApp{
84+
External: true,
85+
URL: "https://external-url.tld",
86+
},
87+
expectedLink: "https://external-url.tld",
88+
},
89+
{
90+
name: "without subdomain",
91+
baseURL: "https://coder.tld",
92+
workspace: codersdk.Workspace{
93+
Name: "Test-Workspace",
94+
OwnerName: "username",
95+
},
96+
agent: codersdk.WorkspaceAgent{
97+
Name: "a-workspace-agent",
98+
},
99+
app: codersdk.WorkspaceApp{
100+
Slug: "app-slug",
101+
Subdomain: false,
102+
},
103+
preferredPathBase: "/path-base",
104+
expectedLink: "https://coder.tld/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/",
105+
},
106+
{
107+
name: "with command",
108+
baseURL: "https://coder.tld",
109+
workspace: codersdk.Workspace{
110+
Name: "Test-Workspace",
111+
OwnerName: "username",
112+
},
113+
agent: codersdk.WorkspaceAgent{
114+
Name: "a-workspace-agent",
115+
},
116+
app: codersdk.WorkspaceApp{
117+
Command: "ls -la",
118+
},
119+
expectedLink: "https://coder.tld/@username/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la",
120+
},
121+
{
122+
name: "with subdomain",
123+
baseURL: "ftps://coder.tld",
124+
workspace: codersdk.Workspace{
125+
Name: "Test-Workspace",
126+
OwnerName: "username",
127+
},
128+
agent: codersdk.WorkspaceAgent{
129+
Name: "a-workspace-agent",
130+
},
131+
app: codersdk.WorkspaceApp{
132+
Subdomain: true,
133+
SubdomainName: "hellocoder",
134+
},
135+
preferredPathBase: "/path-base",
136+
appsHost: "*.apps-host.tld",
137+
expectedLink: "ftps://hellocoder.apps-host.tld/",
138+
},
139+
{
140+
name: "with subdomain, but not apps host",
141+
baseURL: "https://coder.tld",
142+
workspace: codersdk.Workspace{
143+
Name: "Test-Workspace",
144+
OwnerName: "username",
145+
},
146+
agent: codersdk.WorkspaceAgent{
147+
Name: "a-workspace-agent",
148+
},
149+
app: codersdk.WorkspaceApp{
150+
Slug: "app-slug",
151+
Subdomain: true,
152+
SubdomainName: "It really doesn't matter what this is without AppsHost.",
153+
},
154+
preferredPathBase: "/path-base",
155+
expectedLink: "https://coder.tld/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/",
156+
},
157+
} {
158+
tt := tt
159+
t.Run(tt.name, func(t *testing.T) {
160+
t.Parallel()
161+
baseURL, err := url.Parse(tt.baseURL)
162+
require.NoError(t, err)
163+
actual := buildAppLinkURL(baseURL, tt.workspace, tt.agent, tt.app, tt.appsHost, tt.preferredPathBase)
164+
assert.Equal(t, tt.expectedLink, actual)
165+
})
166+
}
167+
}

cli/open_test.go

+70-2
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func TestOpenVSCode(t *testing.T) {
3333
})
3434

3535
_ = agenttest.New(t, client.URL, agentToken)
36-
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
36+
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
3737

3838
insideWorkspaceEnv := map[string]string{
3939
"CODER": "true",
@@ -168,7 +168,7 @@ func TestOpenVSCode_NoAgentDirectory(t *testing.T) {
168168
})
169169

170170
_ = agenttest.New(t, client.URL, agentToken)
171-
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
171+
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
172172

173173
insideWorkspaceEnv := map[string]string{
174174
"CODER": "true",
@@ -283,3 +283,71 @@ func TestOpenVSCode_NoAgentDirectory(t *testing.T) {
283283
})
284284
}
285285
}
286+
287+
func TestOpenApp(t *testing.T) {
288+
t.Parallel()
289+
290+
t.Run("OK", func(t *testing.T) {
291+
t.Parallel()
292+
293+
client, ws, _ := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent {
294+
agents[0].Apps = []*proto.App{
295+
{
296+
Slug: "app1",
297+
Url: "https://example.com/app1",
298+
},
299+
}
300+
return agents
301+
})
302+
303+
inv, root := clitest.New(t, "open", "app", ws.Name, "app1", "--test.open-error")
304+
clitest.SetupConfig(t, client, root)
305+
pty := ptytest.New(t)
306+
inv.Stdin = pty.Input()
307+
inv.Stdout = pty.Output()
308+
309+
w := clitest.StartWithWaiter(t, inv)
310+
w.RequireError()
311+
w.RequireContains("test.open-error")
312+
})
313+
314+
t.Run("AppNotFound", func(t *testing.T) {
315+
t.Parallel()
316+
317+
client, ws, _ := setupWorkspaceForAgent(t)
318+
319+
inv, root := clitest.New(t, "open", "app", ws.Name, "app1")
320+
clitest.SetupConfig(t, client, root)
321+
pty := ptytest.New(t)
322+
inv.Stdin = pty.Input()
323+
inv.Stdout = pty.Output()
324+
325+
w := clitest.StartWithWaiter(t, inv)
326+
w.RequireError()
327+
w.RequireContains("app not found")
328+
})
329+
330+
t.Run("RegionNotFound", func(t *testing.T) {
331+
t.Parallel()
332+
333+
client, ws, _ := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent {
334+
agents[0].Apps = []*proto.App{
335+
{
336+
Slug: "app1",
337+
Url: "https://example.com/app1",
338+
},
339+
}
340+
return agents
341+
})
342+
343+
inv, root := clitest.New(t, "open", "app", ws.Name, "app1", "--preferred-region", "bad-region")
344+
clitest.SetupConfig(t, client, root)
345+
pty := ptytest.New(t)
346+
inv.Stdin = pty.Input()
347+
inv.Stdout = pty.Output()
348+
349+
w := clitest.StartWithWaiter(t, inv)
350+
w.RequireError()
351+
w.RequireContains("region not found")
352+
})
353+
}

0 commit comments

Comments
 (0)