Skip to content

Commit df3c310

Browse files
authored
feat(cli): add coder open vscode (#11191)
Fixes #7667
1 parent 099be24 commit df3c310

26 files changed

+1122
-291
lines changed

agent/agent_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -926,7 +926,7 @@ func TestAgent_EnvironmentVariableExpansion(t *testing.T) {
926926
func TestAgent_CoderEnvVars(t *testing.T) {
927927
t.Parallel()
928928

929-
for _, key := range []string{"CODER"} {
929+
for _, key := range []string{"CODER", "CODER_WORKSPACE_NAME", "CODER_WORKSPACE_AGENT_NAME"} {
930930
key := key
931931
t.Run(key, func(t *testing.T) {
932932
t.Parallel()
@@ -2015,6 +2015,12 @@ func setupAgent(t *testing.T, metadata agentsdk.Manifest, ptyTimeout time.Durati
20152015
if metadata.AgentID == uuid.Nil {
20162016
metadata.AgentID = uuid.New()
20172017
}
2018+
if metadata.AgentName == "" {
2019+
metadata.AgentName = "test-agent"
2020+
}
2021+
if metadata.WorkspaceName == "" {
2022+
metadata.WorkspaceName = "test-workspace"
2023+
}
20182024
coordinator := tailnet.NewCoordinator(logger)
20192025
t.Cleanup(func() {
20202026
_ = coordinator.Close()

agent/agentssh/agentssh.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,8 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string)
659659
// Set environment variables reliable detection of being inside a
660660
// Coder workspace.
661661
cmd.Env = append(cmd.Env, "CODER=true")
662+
cmd.Env = append(cmd.Env, "CODER_WORKSPACE_NAME="+manifest.WorkspaceName)
663+
cmd.Env = append(cmd.Env, "CODER_WORKSPACE_AGENT_NAME="+manifest.AgentName)
662664
cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username))
663665
// Git on Windows resolves with UNIX-style paths.
664666
// If using backslashes, it's unable to find the executable.

agent/proto/agent.pb.go

Lines changed: 295 additions & 274 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

agent/proto/agent.proto

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,10 @@ message WorkspaceAgentMetadata {
7575

7676
message Manifest {
7777
bytes agent_id = 1;
78+
string agent_name = 15;
7879
string owner_username = 13;
7980
bytes workspace_id = 14;
81+
string workspace_name = 16;
8082
uint32 git_auth_configs = 2;
8183
map<string, string> environment_variables = 3;
8284
string directory = 4;

cli/open.go

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/url"
7+
"path"
8+
"path/filepath"
9+
"runtime"
10+
"strings"
11+
12+
"github.com/skratchdot/open-golang/open"
13+
"golang.org/x/xerrors"
14+
15+
"github.com/coder/coder/v2/cli/clibase"
16+
"github.com/coder/coder/v2/cli/cliui"
17+
"github.com/coder/coder/v2/codersdk"
18+
)
19+
20+
func (r *RootCmd) open() *clibase.Cmd {
21+
cmd := &clibase.Cmd{
22+
Use: "open",
23+
Short: "Open a workspace",
24+
Handler: func(inv *clibase.Invocation) error {
25+
return inv.Command.HelpHandler(inv)
26+
},
27+
Children: []*clibase.Cmd{
28+
r.openVSCode(),
29+
},
30+
}
31+
return cmd
32+
}
33+
34+
const vscodeDesktopName = "VS Code Desktop"
35+
36+
func (r *RootCmd) openVSCode() *clibase.Cmd {
37+
var (
38+
generateToken bool
39+
testOpenError bool
40+
)
41+
42+
client := new(codersdk.Client)
43+
cmd := &clibase.Cmd{
44+
Annotations: workspaceCommand,
45+
Use: "vscode <workspace> [<directory in workspace>]",
46+
Short: fmt.Sprintf("Open a workspace in %s", vscodeDesktopName),
47+
Middleware: clibase.Chain(
48+
clibase.RequireRangeArgs(1, 2),
49+
r.InitClient(client),
50+
),
51+
Handler: func(inv *clibase.Invocation) error {
52+
ctx, cancel := context.WithCancel(inv.Context())
53+
defer cancel()
54+
55+
// Check if we're inside a workspace, and especially inside _this_
56+
// workspace so we can perform path resolution/expansion. Generally,
57+
// we know that if we're inside a workspace, `open` can't be used.
58+
insideAWorkspace := inv.Environ.Get("CODER") == "true"
59+
inWorkspaceName := inv.Environ.Get("CODER_WORKSPACE_NAME") + "." + inv.Environ.Get("CODER_WORKSPACE_AGENT_NAME")
60+
61+
// We need a started workspace to figure out e.g. expanded directory.
62+
// Pehraps the vscode-coder extension could handle this by accepting
63+
// default_directory=true, then probing the agent. Then we wouldn't
64+
// need to wait for the agent to start.
65+
workspaceQuery := inv.Args[0]
66+
autostart := true
67+
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, codersdk.Me, workspaceQuery)
68+
if err != nil {
69+
return xerrors.Errorf("get workspace and agent: %w", err)
70+
}
71+
72+
workspaceName := workspace.Name + "." + workspaceAgent.Name
73+
insideThisWorkspace := insideAWorkspace && inWorkspaceName == workspaceName
74+
75+
if !insideThisWorkspace {
76+
// Wait for the agent to connect, we don't care about readiness
77+
// otherwise (e.g. wait).
78+
err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{
79+
Fetch: client.WorkspaceAgent,
80+
FetchLogs: nil,
81+
Wait: false,
82+
})
83+
if err != nil {
84+
if xerrors.Is(err, context.Canceled) {
85+
return cliui.Canceled
86+
}
87+
return xerrors.Errorf("agent: %w", err)
88+
}
89+
90+
// The agent will report it's expanded directory before leaving
91+
// the created state, so we need to wait for that to happen.
92+
// However, if no directory is set, the expanded directory will
93+
// not be set either.
94+
if workspaceAgent.Directory != "" {
95+
workspace, workspaceAgent, err = waitForAgentCond(ctx, client, workspace, workspaceAgent, func(a codersdk.WorkspaceAgent) bool {
96+
return workspaceAgent.LifecycleState != codersdk.WorkspaceAgentLifecycleCreated
97+
})
98+
if err != nil {
99+
return xerrors.Errorf("wait for agent: %w", err)
100+
}
101+
}
102+
}
103+
104+
var directory string
105+
if len(inv.Args) > 1 {
106+
directory = inv.Args[1]
107+
}
108+
directory, err = resolveAgentAbsPath(workspaceAgent.ExpandedDirectory, directory, workspaceAgent.OperatingSystem, insideThisWorkspace)
109+
if err != nil {
110+
return xerrors.Errorf("resolve agent path: %w", err)
111+
}
112+
113+
u := &url.URL{
114+
Scheme: "vscode",
115+
Host: "coder.coder-remote",
116+
Path: "/open",
117+
}
118+
119+
qp := url.Values{}
120+
121+
qp.Add("url", client.URL.String())
122+
qp.Add("owner", workspace.OwnerName)
123+
qp.Add("workspace", workspace.Name)
124+
qp.Add("agent", workspaceAgent.Name)
125+
if directory != "" {
126+
qp.Add("folder", directory)
127+
}
128+
129+
// We always set the token if we believe we can open without
130+
// printing the URI, otherwise the token must be explicitly
131+
// requested as it will be printed in plain text.
132+
if !insideAWorkspace || generateToken {
133+
// Prepare an API key. This is for automagical configuration of
134+
// VS Code, however, if running on a local machine we could try
135+
// to probe VS Code settings to see if the current configuration
136+
// is valid. Future improvement idea.
137+
apiKey, err := client.CreateAPIKey(ctx, codersdk.Me)
138+
if err != nil {
139+
return xerrors.Errorf("create API key: %w", err)
140+
}
141+
qp.Add("token", apiKey.Key)
142+
}
143+
144+
u.RawQuery = qp.Encode()
145+
146+
openingPath := workspaceName
147+
if directory != "" {
148+
openingPath += ":" + directory
149+
}
150+
151+
if insideAWorkspace {
152+
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s is not supported inside a workspace, please open the following URI on your local machine instead:\n\n", openingPath, vscodeDesktopName)
153+
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", u.String())
154+
return nil
155+
}
156+
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s\n", openingPath, vscodeDesktopName)
157+
158+
if !testOpenError {
159+
err = open.Run(u.String())
160+
} else {
161+
err = xerrors.New("test.open-error")
162+
}
163+
if err != nil {
164+
if !generateToken {
165+
// This is not an important step, so we don't want
166+
// to block the user here.
167+
token := qp.Get("token")
168+
wait := doAsync(func() {
169+
// Best effort, we don't care if this fails.
170+
apiKeyID := strings.SplitN(token, "-", 2)[0]
171+
_ = client.DeleteAPIKey(ctx, codersdk.Me, apiKeyID)
172+
})
173+
defer wait()
174+
175+
qp.Del("token")
176+
u.RawQuery = qp.Encode()
177+
}
178+
179+
_, _ = fmt.Fprintf(inv.Stderr, "Could not automatically open %s in %s: %s\n", openingPath, vscodeDesktopName, err)
180+
_, _ = fmt.Fprintf(inv.Stderr, "Please open the following URI instead:\n\n")
181+
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", u.String())
182+
return nil
183+
}
184+
185+
return nil
186+
},
187+
}
188+
189+
cmd.Options = clibase.OptionSet{
190+
{
191+
Flag: "generate-token",
192+
Env: "CODER_OPEN_VSCODE_GENERATE_TOKEN",
193+
Description: fmt.Sprintf(
194+
"Generate an auth token and include it in the vscode:// URI. This is for automagical configuration of %s and not needed if already configured. "+
195+
"This flag does not need to be specified when running this command on a local machine unless automatic open fails.",
196+
vscodeDesktopName,
197+
),
198+
Value: clibase.BoolOf(&generateToken),
199+
},
200+
{
201+
Flag: "test.open-error",
202+
Description: "Don't run the open command.",
203+
Value: clibase.BoolOf(&testOpenError),
204+
Hidden: true, // This is for testing!
205+
},
206+
}
207+
208+
return cmd
209+
}
210+
211+
// waitForAgentCond uses the watch workspace API to update the agent information
212+
// until the condition is met.
213+
func waitForAgentCond(ctx context.Context, client *codersdk.Client, workspace codersdk.Workspace, workspaceAgent codersdk.WorkspaceAgent, cond func(codersdk.WorkspaceAgent) bool) (codersdk.Workspace, codersdk.WorkspaceAgent, error) {
214+
ctx, cancel := context.WithCancel(ctx)
215+
defer cancel()
216+
217+
if cond(workspaceAgent) {
218+
return workspace, workspaceAgent, nil
219+
}
220+
221+
wc, err := client.WatchWorkspace(ctx, workspace.ID)
222+
if err != nil {
223+
return workspace, workspaceAgent, xerrors.Errorf("watch workspace: %w", err)
224+
}
225+
226+
for workspace = range wc {
227+
workspaceAgent, err = getWorkspaceAgent(workspace, workspaceAgent.Name)
228+
if err != nil {
229+
return workspace, workspaceAgent, xerrors.Errorf("get workspace agent: %w", err)
230+
}
231+
if cond(workspaceAgent) {
232+
return workspace, workspaceAgent, nil
233+
}
234+
}
235+
236+
return workspace, workspaceAgent, xerrors.New("watch workspace: unexpected closed channel")
237+
}
238+
239+
// isWindowsAbsPath does a simplistic check for if the path is an absolute path
240+
// on Windows. Drive letter or preceding `\` is interpreted as absolute.
241+
func isWindowsAbsPath(p string) bool {
242+
// Remove the drive letter, if present.
243+
if len(p) >= 2 && p[1] == ':' {
244+
p = p[2:]
245+
}
246+
247+
switch {
248+
case len(p) == 0:
249+
return false
250+
case p[0] == '\\':
251+
return true
252+
default:
253+
return false
254+
}
255+
}
256+
257+
// windowsJoinPath joins the elements into a path, using Windows path separator
258+
// and converting forward slashes to backslashes.
259+
func windowsJoinPath(elem ...string) string {
260+
if runtime.GOOS == "windows" {
261+
return filepath.Join(elem...)
262+
}
263+
264+
var s string
265+
for _, e := range elem {
266+
e = unixToWindowsPath(e)
267+
if e == "" {
268+
continue
269+
}
270+
if s == "" {
271+
s = e
272+
continue
273+
}
274+
s += "\\" + strings.TrimSuffix(e, "\\")
275+
}
276+
return s
277+
}
278+
279+
func unixToWindowsPath(p string) string {
280+
return strings.ReplaceAll(p, "/", "\\")
281+
}
282+
283+
// resolveAgentAbsPath resolves the absolute path to a file or directory in the
284+
// workspace. If the path is relative, it will be resolved relative to the
285+
// workspace's expanded directory. If the path is absolute, it will be returned
286+
// as-is. If the path is relative and the workspace directory is not expanded,
287+
// an error will be returned.
288+
//
289+
// If the path is being resolved within the workspace, the path will be resolved
290+
// relative to the current working directory.
291+
func resolveAgentAbsPath(workingDirectory, relOrAbsPath, agentOS string, local bool) (string, error) {
292+
switch {
293+
case relOrAbsPath == "":
294+
return workingDirectory, nil
295+
296+
case relOrAbsPath == "~" || strings.HasPrefix(relOrAbsPath, "~/"):
297+
return "", xerrors.Errorf("path %q requires expansion and is not supported, use an absolute path instead", relOrAbsPath)
298+
299+
case local:
300+
p, err := filepath.Abs(relOrAbsPath)
301+
if err != nil {
302+
return "", xerrors.Errorf("expand path: %w", err)
303+
}
304+
return p, nil
305+
306+
case agentOS == "windows":
307+
relOrAbsPath = unixToWindowsPath(relOrAbsPath)
308+
switch {
309+
case workingDirectory != "" && !isWindowsAbsPath(relOrAbsPath):
310+
return windowsJoinPath(workingDirectory, relOrAbsPath), nil
311+
case isWindowsAbsPath(relOrAbsPath):
312+
return relOrAbsPath, nil
313+
default:
314+
return "", xerrors.Errorf("path %q not supported, use an absolute path instead", relOrAbsPath)
315+
}
316+
317+
// Note that we use `path` instead of `filepath` since we want Unix behavior.
318+
case workingDirectory != "" && !path.IsAbs(relOrAbsPath):
319+
return path.Join(workingDirectory, relOrAbsPath), nil
320+
case path.IsAbs(relOrAbsPath):
321+
return relOrAbsPath, nil
322+
default:
323+
return "", xerrors.Errorf("path %q not supported, use an absolute path instead", relOrAbsPath)
324+
}
325+
}
326+
327+
func doAsync(f func()) (wait func()) {
328+
done := make(chan struct{})
329+
go func() {
330+
defer close(done)
331+
f()
332+
}()
333+
return func() {
334+
<-done
335+
}
336+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy