|
| 1 | +package cli |
| 2 | + |
| 3 | +import ( |
| 4 | +"errors" |
| 5 | +"fmt" |
| 6 | +"io/fs" |
| 7 | +"os" |
| 8 | +"os/exec" |
| 9 | +"path/filepath" |
| 10 | +"strings" |
| 11 | +"time" |
| 12 | + |
| 13 | +"github.com/spf13/cobra" |
| 14 | +"golang.org/x/xerrors" |
| 15 | + |
| 16 | +"github.com/coder/coder/cli/cliflag" |
| 17 | +"github.com/coder/coder/cli/cliui" |
| 18 | +) |
| 19 | + |
| 20 | +funcdotfiles()*cobra.Command { |
| 21 | +var ( |
| 22 | +symlinkDirstring |
| 23 | +) |
| 24 | +cmd:=&cobra.Command{ |
| 25 | +Use:"dotfiles [git_repo_url]", |
| 26 | +Args:cobra.ExactArgs(1), |
| 27 | +Short:"Checkout and install a dotfiles repository.", |
| 28 | +Example:"coder dotfiles [-y] git@github.com:example/dotfiles.git", |
| 29 | +RunE:func(cmd*cobra.Command,args []string)error { |
| 30 | +var ( |
| 31 | +dotfilesRepoDir="dotfiles" |
| 32 | +gitRepo=args[0] |
| 33 | +cfg=createConfig(cmd) |
| 34 | +cfgDir=string(cfg) |
| 35 | +dotfilesDir=filepath.Join(cfgDir,dotfilesRepoDir) |
| 36 | +// This follows the same pattern outlined by others in the market: |
| 37 | +// https://github.com/coder/coder/pull/1696#issue-1245742312 |
| 38 | +installScriptSet= []string{ |
| 39 | +"install.sh", |
| 40 | +"install", |
| 41 | +"bootstrap.sh", |
| 42 | +"bootstrap", |
| 43 | +"script/bootstrap", |
| 44 | +"setup.sh", |
| 45 | +"setup", |
| 46 | +"script/setup", |
| 47 | +} |
| 48 | +) |
| 49 | + |
| 50 | +_,_=fmt.Fprint(cmd.OutOrStdout(),"Checking if dotfiles repository already exists...\n") |
| 51 | +dotfilesExists,err:=dirExists(dotfilesDir) |
| 52 | +iferr!=nil { |
| 53 | +returnxerrors.Errorf("checking dir %s: %w",dotfilesDir,err) |
| 54 | +} |
| 55 | + |
| 56 | +moved:=false |
| 57 | +ifdotfilesExists { |
| 58 | +du,err:=cfg.DotfilesURL().Read() |
| 59 | +iferr!=nil&&!errors.Is(err,os.ErrNotExist) { |
| 60 | +returnxerrors.Errorf("reading dotfiles url config: %w",err) |
| 61 | +} |
| 62 | +// if the git url has changed we create a backup and clone fresh |
| 63 | +ifgitRepo!=du { |
| 64 | +backupDir:=fmt.Sprintf("%s_backup_%s",dotfilesDir,time.Now().Format(time.RFC3339)) |
| 65 | +_,err=cliui.Prompt(cmd, cliui.PromptOptions{ |
| 66 | +Text:fmt.Sprintf("The dotfiles URL has changed from %q to %q.\n Coder will backup the existing repo to %s.\n\n Continue?",du,gitRepo,backupDir), |
| 67 | +IsConfirm:true, |
| 68 | +}) |
| 69 | +iferr!=nil { |
| 70 | +returnerr |
| 71 | +} |
| 72 | + |
| 73 | +err=os.Rename(dotfilesDir,backupDir) |
| 74 | +iferr!=nil { |
| 75 | +returnxerrors.Errorf("renaming dir %s: %w",dotfilesDir,err) |
| 76 | +} |
| 77 | +_,_=fmt.Fprint(cmd.OutOrStdout(),"Done backup up dotfiles.\n") |
| 78 | +dotfilesExists=false |
| 79 | +moved=true |
| 80 | +} |
| 81 | +} |
| 82 | + |
| 83 | +var ( |
| 84 | +gitCmdDirstring |
| 85 | +subcommands []string |
| 86 | +promptTextstring |
| 87 | +) |
| 88 | +ifdotfilesExists { |
| 89 | +_,_=fmt.Fprintf(cmd.OutOrStdout(),"Found dotfiles repository at %s\n",dotfilesDir) |
| 90 | +gitCmdDir=dotfilesDir |
| 91 | +subcommands= []string{"pull","--ff-only"} |
| 92 | +promptText=fmt.Sprintf("Pulling latest from %s into directory %s.\n Continue?",gitRepo,dotfilesDir) |
| 93 | +}else { |
| 94 | +if!moved { |
| 95 | +_,_=fmt.Fprintf(cmd.OutOrStdout(),"Did not find dotfiles repository at %s\n",dotfilesDir) |
| 96 | +} |
| 97 | +gitCmdDir=cfgDir |
| 98 | +subcommands= []string{"clone",args[0],dotfilesRepoDir} |
| 99 | +promptText=fmt.Sprintf("Cloning %s into directory %s.\n\n Continue?",gitRepo,dotfilesDir) |
| 100 | +} |
| 101 | + |
| 102 | +_,err=cliui.Prompt(cmd, cliui.PromptOptions{ |
| 103 | +Text:promptText, |
| 104 | +IsConfirm:true, |
| 105 | +}) |
| 106 | +iferr!=nil { |
| 107 | +returnerr |
| 108 | +} |
| 109 | + |
| 110 | +// ensure command dir exists |
| 111 | +err=os.MkdirAll(gitCmdDir,0750) |
| 112 | +iferr!=nil { |
| 113 | +returnxerrors.Errorf("ensuring dir at %s: %w",gitCmdDir,err) |
| 114 | +} |
| 115 | + |
| 116 | +// check if git ssh command already exists so we can just wrap it |
| 117 | +gitsshCmd:=os.Getenv("GIT_SSH_COMMAND") |
| 118 | +ifgitsshCmd=="" { |
| 119 | +gitsshCmd="ssh" |
| 120 | +} |
| 121 | + |
| 122 | +// clone or pull repo |
| 123 | +c:=exec.CommandContext(cmd.Context(),"git",subcommands...) |
| 124 | +c.Dir=gitCmdDir |
| 125 | +c.Env=append(os.Environ(),fmt.Sprintf(`GIT_SSH_COMMAND=%s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no`,gitsshCmd)) |
| 126 | +c.Stdout=cmd.OutOrStdout() |
| 127 | +c.Stderr=cmd.ErrOrStderr() |
| 128 | +err=c.Run() |
| 129 | +iferr!=nil { |
| 130 | +if!dotfilesExists { |
| 131 | +returnerr |
| 132 | +} |
| 133 | +// if the repo exists we soft fail the update operation and try to continue |
| 134 | +_,_=fmt.Fprintln(cmd.OutOrStdout(),cliui.Styles.Error.Render("Failed to update repo, continuing...")) |
| 135 | +} |
| 136 | + |
| 137 | +// save git repo url so we can detect changes next time |
| 138 | +err=cfg.DotfilesURL().Write(gitRepo) |
| 139 | +iferr!=nil { |
| 140 | +returnxerrors.Errorf("writing dotfiles url config: %w",err) |
| 141 | +} |
| 142 | + |
| 143 | +files,err:=os.ReadDir(dotfilesDir) |
| 144 | +iferr!=nil { |
| 145 | +returnxerrors.Errorf("reading files in dir %s: %w",dotfilesDir,err) |
| 146 | +} |
| 147 | + |
| 148 | +vardotfiles []string |
| 149 | +for_,f:=rangefiles { |
| 150 | +// make sure we do not copy `.git*` files |
| 151 | +ifstrings.HasPrefix(f.Name(),".")&&!strings.HasPrefix(f.Name(),".git") { |
| 152 | +dotfiles=append(dotfiles,f.Name()) |
| 153 | +} |
| 154 | +} |
| 155 | + |
| 156 | +script:=findScript(installScriptSet,files) |
| 157 | +ifscript!="" { |
| 158 | +_,err=cliui.Prompt(cmd, cliui.PromptOptions{ |
| 159 | +Text:fmt.Sprintf("Running install script %s.\n\n Continue?",script), |
| 160 | +IsConfirm:true, |
| 161 | +}) |
| 162 | +iferr!=nil { |
| 163 | +returnerr |
| 164 | +} |
| 165 | + |
| 166 | +_,_=fmt.Fprintf(cmd.OutOrStdout(),"Running %s...\n",script) |
| 167 | +// it is safe to use a variable command here because it's from |
| 168 | +// a filtered list of pre-approved install scripts |
| 169 | +// nolint:gosec |
| 170 | +scriptCmd:=exec.CommandContext(cmd.Context(),filepath.Join(dotfilesDir,script)) |
| 171 | +scriptCmd.Dir=dotfilesDir |
| 172 | +scriptCmd.Stdout=cmd.OutOrStdout() |
| 173 | +scriptCmd.Stderr=cmd.ErrOrStderr() |
| 174 | +err=scriptCmd.Run() |
| 175 | +iferr!=nil { |
| 176 | +returnxerrors.Errorf("running %s: %w",script,err) |
| 177 | +} |
| 178 | + |
| 179 | +_,_=fmt.Fprintln(cmd.OutOrStdout(),"Dotfiles installation complete.") |
| 180 | +returnnil |
| 181 | +} |
| 182 | + |
| 183 | +iflen(dotfiles)==0 { |
| 184 | +_,_=fmt.Fprintln(cmd.OutOrStdout(),"No install scripts or dotfiles found, nothing to do.") |
| 185 | +returnnil |
| 186 | +} |
| 187 | + |
| 188 | +_,err=cliui.Prompt(cmd, cliui.PromptOptions{ |
| 189 | +Text:"No install scripts found, symlinking dotfiles to home directory.\n\n Continue?", |
| 190 | +IsConfirm:true, |
| 191 | +}) |
| 192 | +iferr!=nil { |
| 193 | +returnerr |
| 194 | +} |
| 195 | + |
| 196 | +ifsymlinkDir=="" { |
| 197 | +symlinkDir,err=os.UserHomeDir() |
| 198 | +iferr!=nil { |
| 199 | +returnxerrors.Errorf("getting user home: %w",err) |
| 200 | +} |
| 201 | +} |
| 202 | + |
| 203 | +for_,df:=rangedotfiles { |
| 204 | +from:=filepath.Join(dotfilesDir,df) |
| 205 | +to:=filepath.Join(symlinkDir,df) |
| 206 | +_,_=fmt.Fprintf(cmd.OutOrStdout(),"Symlinking %s to %s...\n",from,to) |
| 207 | + |
| 208 | +isRegular,err:=isRegular(to) |
| 209 | +iferr!=nil { |
| 210 | +returnxerrors.Errorf("checking symlink for %s: %w",to,err) |
| 211 | +} |
| 212 | +// move conflicting non-symlink files to file.ext.bak |
| 213 | +ifisRegular { |
| 214 | +backup:=fmt.Sprintf("%s.bak",to) |
| 215 | +_,_=fmt.Fprintf(cmd.OutOrStdout(),"Moving %s to %s...\n",to,backup) |
| 216 | +err=os.Rename(to,backup) |
| 217 | +iferr!=nil { |
| 218 | +returnxerrors.Errorf("renaming dir %s: %w",to,err) |
| 219 | +} |
| 220 | +} |
| 221 | + |
| 222 | +err=os.Symlink(from,to) |
| 223 | +iferr!=nil { |
| 224 | +returnxerrors.Errorf("symlinking %s to %s: %w",from,to,err) |
| 225 | +} |
| 226 | +} |
| 227 | + |
| 228 | +_,_=fmt.Fprintln(cmd.OutOrStdout(),"Dotfiles installation complete.") |
| 229 | +returnnil |
| 230 | +}, |
| 231 | +} |
| 232 | +cliui.AllowSkipPrompt(cmd) |
| 233 | +cliflag.StringVarP(cmd.Flags(),&symlinkDir,"symlink-dir","","CODER_SYMLINK_DIR","","Specifies the directory for the dotfiles symlink destinations. If empty will use $HOME.") |
| 234 | + |
| 235 | +returncmd |
| 236 | +} |
| 237 | + |
| 238 | +// dirExists checks if the path exists and is a directory. |
| 239 | +funcdirExists(namestring) (bool,error) { |
| 240 | +fi,err:=os.Stat(name) |
| 241 | +iferr!=nil { |
| 242 | +ifos.IsNotExist(err) { |
| 243 | +returnfalse,nil |
| 244 | +} |
| 245 | + |
| 246 | +returnfalse,xerrors.Errorf("stat dir: %w",err) |
| 247 | +} |
| 248 | +if!fi.IsDir() { |
| 249 | +returnfalse,xerrors.New("exists but not a directory") |
| 250 | +} |
| 251 | + |
| 252 | +returntrue,nil |
| 253 | +} |
| 254 | + |
| 255 | +// findScript will find the first file that matches the script set. |
| 256 | +funcfindScript(scriptSet []string,files []fs.DirEntry)string { |
| 257 | +for_,i:=rangescriptSet { |
| 258 | +for_,f:=rangefiles { |
| 259 | +iff.Name()==i { |
| 260 | +returnf.Name() |
| 261 | +} |
| 262 | +} |
| 263 | +} |
| 264 | + |
| 265 | +return"" |
| 266 | +} |
| 267 | + |
| 268 | +// isRegular detects if the file exists and is not a symlink. |
| 269 | +funcisRegular(tostring) (bool,error) { |
| 270 | +fi,err:=os.Lstat(to) |
| 271 | +iferr!=nil { |
| 272 | +iferrors.Is(err,os.ErrNotExist) { |
| 273 | +returnfalse,nil |
| 274 | +} |
| 275 | +returnfalse,xerrors.Errorf("lstat %s: %w",to,err) |
| 276 | +} |
| 277 | + |
| 278 | +returnfi.Mode().IsRegular(),nil |
| 279 | +} |