Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commitafbe048

Browse files
committed
feat(cli): add open app <workspace> <app-slug> command
1 parentde6080c commitafbe048

File tree

8 files changed

+385
-3
lines changed

8 files changed

+385
-3
lines changed

‎cli/open.go

Lines changed: 159 additions & 0 deletions
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
returncmd
@@ -211,6 +213,118 @@ func (r *RootCmd) openVSCode() *serpent.Command {
211213
returncmd
212214
}
213215

216+
func (r*RootCmd)openApp()*serpent.Command {
217+
var (
218+
preferredRegionstring
219+
testOpenErrorbool
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+
defercancel()
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+
iferr!=nil {
243+
returnxerrors.Errorf("failed to fetch regions: %w",err)
244+
}
245+
varregion codersdk.Region
246+
preferredIdx:=slices.IndexFunc(regions,func(r codersdk.Region)bool {
247+
returnr.Name==preferredRegion
248+
})
249+
ifpreferredIdx==-1 {
250+
allRegions:=make([]string,len(regions))
251+
fori,r:=rangeregions {
252+
allRegions[i]=r.Name
253+
}
254+
cliui.Errorf(inv.Stderr,"Preferred region %q not found!\nAvailable regions: %v",preferredRegion,allRegions)
255+
returnxerrors.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+
iferr!=nil {
265+
returnxerrors.Errorf("failed to get workspace and agent: %w",err)
266+
}
267+
268+
// Fetch the app
269+
varapp codersdk.WorkspaceApp
270+
appIdx:=slices.IndexFunc(agt.Apps,func(a codersdk.WorkspaceApp)bool {
271+
returna.Slug==appSlug
272+
})
273+
ifappIdx==-1 {
274+
appSlugs:=make([]string,len(agt.Apps))
275+
fori,app:=rangeagt.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+
returnxerrors.Errorf("app not found")
280+
}
281+
app=agt.Apps[appIdx]
282+
283+
// Build the URL
284+
baseURL,err:=url.Parse(region.PathAppURL)
285+
iferr!=nil {
286+
returnxerrors.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+
ifinsideAWorkspace {
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+
returnnil
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+
returnerr
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+
returncmd
326+
}
327+
214328
// waitForAgentCond uses the watch workspace API to update the agent information
215329
// until the condition is met.
216330
funcwaitForAgentCond(ctx context.Context,client*codersdk.Client,workspace codersdk.Workspace,workspaceAgent codersdk.WorkspaceAgent,condfunc(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+
funcbuildAppLinkURL(baseURL*url.URL,workspace codersdk.Workspace,agent codersdk.WorkspaceAgent,app codersdk.WorkspaceApp,appsHost,preferredPathBasestring)string {
459+
// If app is external, return the URL directly
460+
ifapp.External {
461+
returnapp.URL
462+
}
463+
464+
varu 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+
ifapp.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+
ifappsHost!=""&&app.Subdomain&&app.SubdomainName!="" {
494+
u.Host=strings.Replace(appsHost,"*",app.SubdomainName,1)
495+
u.Path="/"
496+
}
497+
returnu.String()
498+
}

‎cli/open_internal_test.go

Lines changed: 113 additions & 1 deletion
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
funcTest_resolveAgentAbsPath(t*testing.T) {
614
t.Parallel()
@@ -54,3 +62,107 @@ func Test_resolveAgentAbsPath(t *testing.T) {
5462
})
5563
}
5664
}
65+
66+
funcTest_buildAppLinkURL(t*testing.T) {
67+
t.Parallel()
68+
69+
for_,tt:=range []struct {
70+
namestring
71+
// function arguments
72+
baseURLstring
73+
workspace codersdk.Workspace
74+
agent codersdk.WorkspaceAgent
75+
app codersdk.WorkspaceApp
76+
appsHoststring
77+
preferredPathBasestring
78+
// expected results
79+
expectedLinkstring
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

Lines changed: 70 additions & 2 deletions
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+
funcTestOpenApp(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+
returnagents
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+
returnagents
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)

[8]ページ先頭

©2009-2025 Movatter.jp