2024-01-02 18:46:18 +00:00
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/cliui"
"github.com/coder/coder/v2/codersdk"
2024-03-15 16:24:38 +00:00
"github.com/coder/serpent"
2024-01-02 18:46:18 +00:00
)
2024-03-17 14:45:26 +00:00
func ( r * RootCmd ) open ( ) * serpent . Command {
cmd := & serpent . Command {
2024-01-02 18:46:18 +00:00
Use : "open" ,
Short : "Open a workspace" ,
2024-03-15 16:24:38 +00:00
Handler : func ( inv * serpent . Invocation ) error {
2024-01-02 18:46:18 +00:00
return inv . Command . HelpHandler ( inv )
} ,
2024-03-17 14:45:26 +00:00
Children : [ ] * serpent . Command {
2024-01-02 18:46:18 +00:00
r . openVSCode ( ) ,
} ,
}
return cmd
}
const vscodeDesktopName = "VS Code Desktop"
2024-03-17 14:45:26 +00:00
func ( r * RootCmd ) openVSCode ( ) * serpent . Command {
2024-01-02 18:46:18 +00:00
var (
generateToken bool
testOpenError bool
)
client := new ( codersdk . Client )
2024-03-17 14:45:26 +00:00
cmd := & serpent . Command {
2024-01-02 18:46:18 +00:00
Annotations : workspaceCommand ,
Use : "vscode <workspace> [<directory in workspace>]" ,
Short : fmt . Sprintf ( "Open a workspace in %s" , vscodeDesktopName ) ,
2024-03-15 16:24:38 +00:00
Middleware : serpent . Chain (
serpent . RequireRangeArgs ( 1 , 2 ) ,
2024-01-02 18:46:18 +00:00
r . InitClient ( client ) ,
) ,
2024-03-15 16:24:38 +00:00
Handler : func ( inv * serpent . Invocation ) error {
2024-01-02 18:46:18 +00:00
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
2024-04-13 18:39:57 +00:00
workspace , workspaceAgent , err := getWorkspaceAndAgent ( ctx , inv , client , autostart , workspaceQuery )
2024-01-02 18:46:18 +00:00
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" )
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
} ,
}
2024-03-15 16:24:38 +00:00
cmd . Options = serpent . OptionSet {
2024-01-02 18:46:18 +00:00
{
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 ,
) ,
2024-03-15 16:24:38 +00:00
Value : serpent . BoolOf ( & generateToken ) ,
2024-01-02 18:46:18 +00:00
} ,
{
Flag : "test.open-error" ,
Description : "Don't run the open command." ,
2024-03-15 16:24:38 +00:00
Value : serpent . BoolOf ( & testOpenError ) ,
2024-01-02 18:46:18 +00:00
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
}
}
// 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
}
func unixToWindowsPath ( p string ) string {
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
}
}