-
Notifications
You must be signed in to change notification settings - Fork 943
feat(cli): add coder open vscode
#11191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c7dba53
31e21fc
2fd2464
b0c3dce
bc71c63
d438c49
3a69da2
8e8e08d
9ee8d8f
257c45f
3fec654
0d647bd
1202b65
7c3f48b
747ecad
caf84c5
081a006
d09546c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,336 @@ | ||
< 8000 td class="blob-code blob-code-addition js-file-line"> package cli | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"net/url" | ||
"path" | ||
"path/filepath" | ||
"runtime" | ||
"strings" | ||
|
||
"github.com/skratchdot/open-golang/open" | ||
"golang.org/x/xerrors" | ||
|
||
"github.com/coder/coder/v2/cli/clibase" | ||
"github.com/coder/coder/v2/cli/cliui" | ||
"github.com/coder/coder/v2/codersdk" | ||
) | ||
|
||
func (r *RootCmd) open() *clibase.Cmd { | ||
cmd := &clibase.Cmd{ | ||
Use: "open", | ||
Short: "Open a workspace", | ||
Handler: func(inv *clibase.Invocation) error { | ||
return inv.Command.HelpHandler(inv) | ||
}, | ||
Children: []*clibase.Cmd{ | ||
r.openVSCode(), | ||
}, | ||
} | ||
return cmd | ||
} | ||
|
||
const vscodeDesktopName = "VS Code Desktop" | ||
|
||
func (r *RootCmd) openVSCode() *clibase.Cmd { | ||
var ( | ||
generateToken bool | ||
testOpenError bool | ||
) | ||
|
||
client := new(codersdk.Client) | ||
cmd := &clibase.Cmd{ | ||
Annotations: workspaceCommand, | ||
Use: "vscode <workspace> [<directory in workspace>]", | ||
Short: fmt.Sprintf("Open a workspace in %s", vscodeDesktopName), | ||
Middleware: clibase.Chain( | ||
clibase.RequireRangeArgs(1, 2), | ||
r.InitClient(client), | ||
), | ||
Handler: func(inv *clibase.Invocation) error { | ||
ctx, cancel := context.WithCancel(inv.Context()) | ||
defer cancel() | ||
|
||
// Check if we're inside a workspace, and especially inside _this_ | ||
// workspace so we can perform path resolution/expansion. Generally, | ||
// we know that if we're inside a workspace, `open` can't be used. | ||
insideAWorkspace := inv.Environ.Get("CODER") == "true" | ||
inWorkspaceName := inv.Environ.Get("CODER_WORKSPACE_NAME") + "." + inv.Environ.Get("CODER_WORKSPACE_AGENT_NAME") | ||
|
||
// We need a started workspace to figure out e.g. expanded directory. | ||
// Pehraps the vscode-coder extension could handle this by accepting | ||
// default_directory=true, then probing the agent. Then we wouldn't | ||
// need to wait for the agent to start. | ||
workspaceQuery := inv.Args[0] | ||
autostart := true | ||
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, codersdk.Me, workspaceQuery) | ||
if err != nil { | ||
return xerrors.Errorf("get workspace and agent: %w", err) | ||
} | ||
|
||
workspaceName := workspace.Name + "." + workspaceAgent.Name | ||
insideThisWorkspace := insideAWorkspace && inWorkspaceName == workspaceName | ||
|
||
if !insideThisWorkspace { | ||
// Wait for the agent to connect, we don't care about readiness | ||
// otherwise (e.g. wait). | ||
err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{ | ||
Fetch: client.WorkspaceAgent, | ||
FetchLogs: nil, | ||
Wait: false, | ||
}) | ||
if err != nil { | ||
if xerrors.Is(err, context.Canceled) { | ||
return cliui.Canceled | ||
} | ||
return xerrors.Errorf("agent: %w", err) | ||
} | ||
|
||
// The agent will report it's expanded directory before leaving | ||
// the created state, so we need to wait for that to happen. | ||
// However, if no directory is set, the expanded directory will | ||
// not be set either. | ||
if workspaceAgent.Directory != "" { | ||
workspace, workspaceAgent, err = waitForAgentCond(ctx, client, workspace, workspaceAgent, func(a codersdk.WorkspaceAgent) bool { | ||
return workspaceAgent.LifecycleState != codersdk.WorkspaceAgentLifecycleCreated | ||
}) | ||
if err != nil { | ||
return xerrors.Errorf("wait for agent: %w", err) | ||
} | ||
} | ||
} | ||
|
||
var directory string | ||
if len(inv.Args) > 1 { | ||
directory = inv.Args[1] | ||
} | ||
directory, err = resolveAgentAbsPath(workspaceAgent.ExpandedDirectory, directory, workspaceAgent.OperatingSystem, insideThisWorkspace) | ||
if err != nil { | ||
return xerrors.Errorf("resolve agent path: %w", err) | ||
} | ||
|
||
u := &url.URL{ | ||
Scheme: "vscode", | ||
Host: "coder.coder-remote", | ||
Path: "/open", | ||
} | ||
|
||
qp := url.Values{} | ||
|
||
qp.Add("url", client.URL.String()) | ||
qp.Add("owner", workspace.OwnerName) | ||
qp.Add("workspace", workspace.Name) | ||
qp.Add("agent", workspaceAgent.Name) | ||
if directory != "" { | ||
qp.Add("folder", directory) | ||
} | ||
|
||
// We always set the token if we believe we can open without | ||
// printing the URI, otherwise the token must be explicitly | ||
// requested as it will be printed in plain text. | ||
if !insideAWorkspace || generateToken { | ||
// Prepare an API key. This is for automagical configuration of | ||
// VS Code, however, if running on a local machine we could try | ||
// to probe VS Code settings to see if the current configuration | ||
// is valid. Future improvement idea. | ||
apiKey, err := client.CreateAPIKey(ctx, codersdk.Me) | ||
if err != nil { | ||
return xerrors.Errorf("create API key: %w", err) | ||
} | ||
qp.Add("token", apiKey.Key) | ||
} | ||
|
||
u.RawQuery = qp.Encode() | ||
|
||
openingPath := workspaceName | ||
if directory != "" { | ||
openingPath += ":" + directory | ||
} | ||
|
||
if insideAWorkspace { | ||
_, _ = 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) | ||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", u.String()) | ||
return nil | ||
} | ||
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s\n", openingPath, vscodeDesktopName) | ||
|
||
if !testOpenError { | ||
err = open.Run(u.String()) | ||
} else { | ||
err = xerrors.New("test.open-error") | ||
} | ||
if err != nil { | ||
if !generateToken { | ||
// This is not an important step, so we don't want | ||
// to block the user here. | ||
token := qp.Get("token") | ||
wait := doAsync(func() { | ||
// Best effort, we don't care if this fails. | ||
apiKeyID := strings.SplitN(token, "-", 2)[0] | ||
_ = client.DeleteAPIKey(ctx, codersdk.Me, apiKeyID) | ||
}) | ||
defer wait() | ||
|
||
qp.Del("token") | ||
mafredri marked this conversation as resolved.
Show resolved
Hide resolved
|
||
u.RawQuery = qp.Encode() | ||
} | ||
|
||
_, _ = fmt.Fprintf(inv.Stderr, "Could not automatically open %s in %s: %s\n", openingPath, vscodeDesktopName, err) | ||
_, _ = fmt.Fprintf(inv.Stderr, "Please open the following URI instead:\n\n") | ||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", u.String()) | ||
return nil | ||
} | ||
|
||
return nil | ||
}, | ||
} | ||
|
||
cmd.Options = clibase.OptionSet{ | ||
{ | ||
Flag: "generate-token", | ||
Env: "CODER_OPEN_VSCODE_GENERATE_TOKEN", | ||
Description: fmt.Sprintf( | ||
"Generate an auth token and include it in the vscode:// URI. This is for automagical configuration of %s and not needed if already configured. "+ | ||
"This flag does not need to be specified when running this command on a local machine unless automatic open fails.", | ||
vscodeDesktopName, | ||
), | ||
Value: clibase.BoolOf(&generateToken), | ||
}, | ||
{ | ||
Flag: "test.open-error", | ||
Description: "Don't run the open command.", | ||
Value: clibase.BoolOf(&testOpenError), | ||
Hidden: true, // This is for testing! | ||
}, | ||
} | ||
|
||
return cmd | ||
} | ||
|
||
// waitForAgentCond uses the watch workspace API to update the agent information | ||
// until the condition is met. | ||
func waitForAgentCond(ctx context.Context, client *codersdk.Client, workspace codersdk.Workspace, workspaceAgent codersdk.WorkspaceAgent, cond func(codersdk.WorkspaceAgent) bool) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { | ||
ctx, cancel := context.WithCancel(ctx) | ||
defer cancel() | ||
|
||
if cond(workspaceAgent) { | ||
return workspace, workspaceAgent, nil | ||
} | ||
|
||
wc, err := client.WatchWorkspace(ctx, workspace.ID) | ||
if err != nil { | ||
return workspace, workspaceAgent, xerrors.Errorf("watch workspace: %w", err) | ||
} | ||
|
||
for workspace = range wc { | ||
workspaceAgent, err = getWorkspaceAgent(workspace, workspaceAgent.Name) | ||
if err != nil { | ||
return workspace, workspaceAgent, xerrors.Errorf("get workspace agent: %w", err) | ||
} | ||
if cond(workspaceAgent) { | ||
return workspace, workspaceAgent, nil | ||
} | ||
} | ||
|
||
return workspace, workspaceAgent, xerrors.New("watch workspace: unexpected closed channel") | ||
} | ||
|
||
// isWindowsAbsPath does a simplistic check for if the path is an absolute path | ||
// on Windows. Drive letter or preceding `\` is interpreted as absolute. | ||
func isWindowsAbsPath(p string) bool { | ||
// Remove the drive letter, if present. | ||
if len(p) >= 2 && p[1] == ':' { | ||
p = p[2:] | ||
} | ||
|
||
switch { | ||
case len(p) == 0: | ||
return false | ||
case p[0] == '\\': | ||
return true | ||
default: | ||
return false | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried IDK how paths work in Windows containers either, unsure if they have drive letters or not. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe I'll need to play around with this some more. I was actually planning on testing with agents on Windows today, but it turns out our win template is broken atm. I know at least the "expanded directory" resolves to Perhaps we can just make everything slash and only have the logic for detecting abs path on Windows. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added some handling for the cases you mentioned, and relaxed the abs requirement to include There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, I can't seem to make CI happy because Go (I kept the fallback to |
||
} | ||
} | ||
|
||
// windowsJoinPath joins the elements into a path, using Windows path separator | ||
// and converting forward slashes to backslashes. | ||
func windowsJoinPath(elem ...string) string { | ||
if runtime.GOOS == "windows" { | ||
return filepath.Join(elem...) | ||
} | ||
|
||
var s string | ||
for _, e := range elem { | ||
e = unixToWindowsPath(e) | ||
if e == "" { | ||
continue | ||
} | ||
if s == "" { | ||
s = e | ||
continue | ||
} | ||
s += "\\" + strings.TrimSuffix(e, "\\") | ||
} | ||
return s | ||
} | ||
|
||
|
||
return strings.ReplaceAll(p, "/", "\\") | ||
} | ||
|
||
// resolveAgentAbsPath resolves the absolute path to a file or directory in the | ||
// workspace. If the path is relative, it will be resolved relative to the | ||
// workspace's expanded directory. If the path is absolute, it will be returned | ||
// as-is. If the path is relative and the workspace directory is not expanded, | ||
// an error will be returned. | ||
// | ||
// If the path is being resolved within the workspace, the path will be resolved | ||
// relative to the current working directory. | ||
func resolveAgentAbsPath(workingDirectory, relOrAbsPath, agentOS string, local bool) (string, error) { | ||
switch { | ||
case relOrAbsPath == "": | ||
return workingDirectory, nil | ||
|
||
case relOrAbsPath == "~" || strings.HasPrefix(relOrAbsPath, "~/"): | ||
return "", xerrors.Errorf("path %q requires expansion and is not supported, use an absolute path instead", relOrAbsPath) | ||
|
||
case local: | ||
p, err := filepath.Abs(relOrAbsPath) | ||
if err != nil { | ||
return "", xerrors.Errorf("expand path: %w", err) | ||
} | ||
return p, nil | ||
|
||
case agentOS == "windows": | ||
relOrAbsPath = unixToWindowsPath(relOrAbsPath) | ||
switch { | ||
case workingDirectory != "" && !isWindowsAbsPath(relOrAbsPath): | ||
return windowsJoinPath(workingDirectory, relOrAbsPath), nil | ||
case isWindowsAbsPath(relOrAbsPath): | ||
return relOrAbsPath, nil | ||
default: | ||
return "", xerrors.Errorf("path %q not supported, use an absolute path instead", relOrAbsPath) | ||
} | ||
|
||
// Note that we use `path` instead of `filepath` since we want Unix behavior. | ||
case workingDirectory != "" && !path.IsAbs(relOrAbsPath): | ||
return path.Join(workingDirectory, relOrAbsPath), nil | ||
case path.IsAbs(relOrAbsPath): | ||
return relOrAbsPath, nil | ||
default: | ||
return "", xerrors.Errorf("path %q not supported, use an absolute path instead", relOrAbsPath) | ||
} | ||
} | ||
|
||
func doAsync(f func()) (wait func()) { | ||
done := make(chan struct{}) | ||
go func() { | ||
defer close(done) | ||
f() | ||
}() | ||
return func() { | ||
<-done | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.