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

+7-1
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

+2
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

+295-274
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

agent/proto/agent.proto

+2
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

+336
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)