|
| 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 | +returninv.Command.HelpHandler(inv) |
| 26 | +}, |
| 27 | +Children: []*clibase.Cmd{ |
| 28 | +r.openVSCode(), |
| 29 | +}, |
| 30 | +} |
| 31 | +returncmd |
| 32 | +} |
| 33 | + |
| 34 | +constvscodeDesktopName="VS Code Desktop" |
| 35 | + |
| 36 | +func (r*RootCmd)openVSCode()*clibase.Cmd { |
| 37 | +var ( |
| 38 | +generateTokenbool |
| 39 | +testOpenErrorbool |
| 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 | +defercancel() |
| 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 | +iferr!=nil { |
| 69 | +returnxerrors.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 | +iferr!=nil { |
| 84 | +ifxerrors.Is(err,context.Canceled) { |
| 85 | +returncliui.Canceled |
| 86 | +} |
| 87 | +returnxerrors.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 | +ifworkspaceAgent.Directory!="" { |
| 95 | +workspace,workspaceAgent,err=waitForAgentCond(ctx,client,workspace,workspaceAgent,func(a codersdk.WorkspaceAgent)bool { |
| 96 | +returnworkspaceAgent.LifecycleState!=codersdk.WorkspaceAgentLifecycleCreated |
| 97 | +}) |
| 98 | +iferr!=nil { |
| 99 | +returnxerrors.Errorf("wait for agent: %w",err) |
| 100 | +} |
| 101 | +} |
| 102 | +} |
| 103 | + |
| 104 | +vardirectorystring |
| 105 | +iflen(inv.Args)>1 { |
| 106 | +directory=inv.Args[1] |
| 107 | +} |
| 108 | +directory,err=resolveAgentAbsPath(workspaceAgent.ExpandedDirectory,directory,workspaceAgent.OperatingSystem,insideThisWorkspace) |
| 109 | +iferr!=nil { |
| 110 | +returnxerrors.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 | +ifdirectory!="" { |
| 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 | +iferr!=nil { |
| 139 | +returnxerrors.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 | +ifdirectory!="" { |
| 148 | +openingPath+=":"+directory |
| 149 | +} |
| 150 | + |
| 151 | +ifinsideAWorkspace { |
| 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 | +returnnil |
| 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 | +iferr!=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 | +deferwait() |
| 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 | +returnnil |
| 183 | +} |
| 184 | + |
| 185 | +returnnil |
| 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 | +returncmd |
| 209 | +} |
| 210 | + |
| 211 | +// waitForAgentCond uses the watch workspace API to update the agent information |
| 212 | +// until the condition is met. |
| 213 | +funcwaitForAgentCond(ctx context.Context,client*codersdk.Client,workspace codersdk.Workspace,workspaceAgent codersdk.WorkspaceAgent,condfunc(codersdk.WorkspaceAgent)bool) (codersdk.Workspace, codersdk.WorkspaceAgent,error) { |
| 214 | +ctx,cancel:=context.WithCancel(ctx) |
| 215 | +defercancel() |
| 216 | + |
| 217 | +ifcond(workspaceAgent) { |
| 218 | +returnworkspace,workspaceAgent,nil |
| 219 | +} |
| 220 | + |
| 221 | +wc,err:=client.WatchWorkspace(ctx,workspace.ID) |
| 222 | +iferr!=nil { |
| 223 | +returnworkspace,workspaceAgent,xerrors.Errorf("watch workspace: %w",err) |
| 224 | +} |
| 225 | + |
| 226 | +forworkspace=rangewc { |
| 227 | +workspaceAgent,err=getWorkspaceAgent(workspace,workspaceAgent.Name) |
| 228 | +iferr!=nil { |
| 229 | +returnworkspace,workspaceAgent,xerrors.Errorf("get workspace agent: %w",err) |
| 230 | +} |
| 231 | +ifcond(workspaceAgent) { |
| 232 | +returnworkspace,workspaceAgent,nil |
| 233 | +} |
| 234 | +} |
| 235 | + |
| 236 | +returnworkspace,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 | +funcisWindowsAbsPath(pstring)bool { |
| 242 | +// Remove the drive letter, if present. |
| 243 | +iflen(p)>=2&&p[1]==':' { |
| 244 | +p=p[2:] |
| 245 | +} |
| 246 | + |
| 247 | +switch { |
| 248 | +caselen(p)==0: |
| 249 | +returnfalse |
| 250 | +casep[0]=='\\': |
| 251 | +returntrue |
| 252 | +default: |
| 253 | +returnfalse |
| 254 | +} |
| 255 | +} |
| 256 | + |
| 257 | +// windowsJoinPath joins the elements into a path, using Windows path separator |
| 258 | +// and converting forward slashes to backslashes. |
| 259 | +funcwindowsJoinPath(elem...string)string { |
| 260 | +ifruntime.GOOS=="windows" { |
| 261 | +returnfilepath.Join(elem...) |
| 262 | +} |
| 263 | + |
| 264 | +varsstring |
| 265 | +for_,e:=rangeelem { |
| 266 | +e=unixToWindowsPath(e) |
| 267 | +ife=="" { |
| 268 | +continue |
| 269 | +} |
| 270 | +ifs=="" { |
| 271 | +s=e |
| 272 | +continue |
| 273 | +} |
| 274 | +s+="\\"+strings.TrimSuffix(e,"\\") |
| 275 | +} |
| 276 | +returns |
| 277 | +} |
| 278 | + |
| 279 | +funcunixToWindowsPath(pstring)string { |
| 280 | +returnstrings.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 | +funcresolveAgentAbsPath(workingDirectory,relOrAbsPath,agentOSstring,localbool) (string,error) { |
| 292 | +switch { |
| 293 | +caserelOrAbsPath=="": |
| 294 | +returnworkingDirectory,nil |
| 295 | + |
| 296 | +caserelOrAbsPath=="~"||strings.HasPrefix(relOrAbsPath,"~/"): |
| 297 | +return"",xerrors.Errorf("path %q requires expansion and is not supported, use an absolute path instead",relOrAbsPath) |
| 298 | + |
| 299 | +caselocal: |
| 300 | +p,err:=filepath.Abs(relOrAbsPath) |
| 301 | +iferr!=nil { |
| 302 | +return"",xerrors.Errorf("expand path: %w",err) |
| 303 | +} |
| 304 | +returnp,nil |
| 305 | + |
| 306 | +caseagentOS=="windows": |
| 307 | +relOrAbsPath=unixToWindowsPath(relOrAbsPath) |
| 308 | +switch { |
| 309 | +caseworkingDirectory!=""&&!isWindowsAbsPath(relOrAbsPath): |
| 310 | +returnwindowsJoinPath(workingDirectory,relOrAbsPath),nil |
| 311 | +caseisWindowsAbsPath(relOrAbsPath): |
| 312 | +returnrelOrAbsPath,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 | +caseworkingDirectory!=""&&!path.IsAbs(relOrAbsPath): |
| 319 | +returnpath.Join(workingDirectory,relOrAbsPath),nil |
| 320 | +casepath.IsAbs(relOrAbsPath): |
| 321 | +returnrelOrAbsPath,nil |
| 322 | +default: |
| 323 | +return"",xerrors.Errorf("path %q not supported, use an absolute path instead",relOrAbsPath) |
| 324 | +} |
| 325 | +} |
| 326 | + |
| 327 | +funcdoAsync(ffunc()) (waitfunc()) { |
| 328 | +done:=make(chanstruct{}) |
| 329 | +gofunc() { |
| 330 | +deferclose(done) |
| 331 | +f() |
| 332 | +}() |
| 333 | +returnfunc() { |
| 334 | +<-done |
| 335 | +} |
| 336 | +} |