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

Skip to content

Commit afbe048

Browse files
committed
feat(cli): add open app <workspace> <app-slug> command
1 parent de6080c commit afbe048

File tree

8 files changed

+385
-3
lines changed

8 files changed

+385
-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: "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 xerrors.Errorf("failed to fetch regions: %w", err)
244+
}
245+
var region codersdk.Region
246+
preferredIdx := slices.IndexFunc(regions, func(r codersdk.Region) bool {
247+
return r.Name == preferredRegion
248+
})
249+
if preferredIdx == -1 {
250+
allRegions := make([]string, len(regions))
251+
for i, r := range regions {
252+
allRegions[i] = r.Name
253+
}
254+
cliui.Errorf(inv.Stderr, "Preferred region %q not found!\nAvailable regions: %v", preferredRegion, allRegions)
255+
return xerrors.Errorf("region not found")
256+
}
257+
region = regions[preferredIdx]
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 xerrors.Errorf("failed to get workspace and agent: %w", err)
266+
}
267+
268+
// Fetch the app
269+
var app codersdk.WorkspaceApp
270+
appIdx := slices.IndexFunc(agt.Apps, func(a codersdk.WorkspaceApp) bool {
271+
return a.Slug == appSlug
272+
})
273+
if appIdx == -1 {
274+
appSlugs := make([]string, len(agt.Apps))
275+
for i, app := range agt.Apps {
276+
appSlugs[i] = app.Slug
277+
}
278+
cliui.Errorf(inv.Stderr, "App %q not found in workspace %q!\nAvailable apps: %v", appSlug, workspaceName, appSlugs)
279+
return xerrors.Errorf("app not found")
280+
}
281+
app = agt.Apps[appIdx]
282+
283+
// Build the URL
284+
baseURL, err := url.Parse(region.PathAppURL)
285+
if err != nil {
286+
return xerrors.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

+113-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
package cli
22

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

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

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)