|
| 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 | +func dotfiles() *cobra.Command { |
| 21 | + var ( |
| 22 | + symlinkDir string |
| 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] [email protected]: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 | + if err != nil { |
| 53 | + return xerrors.Errorf("checking dir %s: %w", dotfilesDir, err) |
| 54 | + } |
| 55 | + |
| 56 | + moved := false |
| 57 | + if dotfilesExists { |
| 58 | + du, err := cfg.DotfilesURL().Read() |
| 59 | + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 60 | + return xerrors.Errorf("reading dotfiles url config: %w", err) |
| 61 | + } |
| 62 | + // if the git url has changed we create a backup and clone fresh |
| 63 | + if gitRepo != 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 | + if err != nil { |
| 70 | + return err |
| 71 | + } |
| 72 | + |
| 73 | + err = os.Rename(dotfilesDir, backupDir) |
| 74 | + if err != nil { |
| 75 | + return xerrors.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 | + gitCmdDir string |
| 85 | + subcommands []string |
| 86 | + promptText string |
| 87 | + ) |
| 88 | + if dotfilesExists { |
| 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 | + if err != nil { |
| 107 | + return err |
| 108 | + } |
| 109 | + |
| 110 | + // ensure command dir exists |
| 111 | + err = os.MkdirAll(gitCmdDir, 0750) |
| 112 | + if err != nil { |
| 113 | + return xerrors.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 | + if gitsshCmd == "" { |
| 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 | + if err != nil { |
| 130 | + if !dotfilesExists { |
| 131 | + return err |
| 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 | + if err != nil { |
| 140 | + return xerrors.Errorf("writing dotfiles url config: %w", err) |
| 141 | + } |
| 142 | + |
| 143 | + files, err := os.ReadDir(dotfilesDir) |
| 144 | + if err != nil { |
| 145 | + return xerrors.Errorf("reading files in dir %s: %w", dotfilesDir, err) |
| 146 | + } |
| 147 | + |
| 148 | + var dotfiles []string |
| 149 | + for _, f := range files { |
| 150 | + // make sure we do not copy `.git*` files |
| 151 | + if strings.HasPrefix(f.Name(), ".") && !strings.HasPrefix(f.Name(), ".git") { |
| 152 | + dotfiles = append(dotfiles, f.Name()) |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + script := findScript(installScriptSet, files) |
| 157 | + if script != "" { |
| 158 | + _, err = cliui.Prompt(cmd, cliui.PromptOptions{ |
| 159 | + Text: fmt.Sprintf("Running install script %s.\n\n Continue?", script), |
| 160 | + IsConfirm: true, |
| 161 | + }) |
| 162 | + if err != nil { |
| 163 | + return err |
| 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 | + if err != nil { |
| 176 | + return xerrors.Errorf("running %s: %w", script, err) |
| 177 | + } |
| 178 | + |
| 179 | + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dotfiles installation complete.") |
| 180 | + return nil |
| 181 | + } |
| 182 | + |
| 183 | + if len(dotfiles) == 0 { |
| 184 | + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No install scripts or dotfiles found, nothing to do.") |
| 185 | + return nil |
| 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 | + if err != nil { |
| 193 | + return err |
| 194 | + } |
| 195 | + |
| 196 | + if symlinkDir == "" { |
| 197 | + symlinkDir, err = os.UserHomeDir() |
| 198 | + if err != nil { |
| 199 | + return xerrors.Errorf("getting user home: %w", err) |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + for _, df := range dotfiles { |
| 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 | + if err != nil { |
| 210 | + return xerrors.Errorf("checking symlink for %s: %w", to, err) |
| 211 | + } |
| 212 | + // move conflicting non-symlink files to file.ext.bak |
| 213 | + if isRegular { |
| 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 | + if err != nil { |
| 218 | + return xerrors.Errorf("renaming dir %s: %w", to, err) |
| 219 | + } |
| 220 | + } |
| 221 | + |
| 222 | + err = os.Symlink(from, to) |
| 223 | + if err != nil { |
| 224 | + return xerrors.Errorf("symlinking %s to %s: %w", from, to, err) |
| 225 | + } |
| 226 | + } |
| 227 | + |
| 228 | + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dotfiles installation complete.") |
| 229 | + return nil |
| 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 | + return cmd |
| 236 | +} |
| 237 | + |
| 238 | +// dirExists checks if the path exists and is a directory. |
| 239 | +func dirExists(name string) (bool, error) { |
| 240 | + fi, err := os.Stat(name) |
| 241 | + if err != nil { |
| 242 | + if os.IsNotExist(err) { |
| 243 | + return false, nil |
| 244 | + } |
| 245 | + |
| 246 | + return false, xerrors.Errorf("stat dir: %w", err) |
| 247 | + } |
| 248 | + if !fi.IsDir() { |
| 249 | + return false, xerrors.New("exists but not a directory") |
| 250 | + } |
| 251 | + |
| 252 | + return true, nil |
| 253 | +} |
| 254 | + |
| 255 | +// findScript will find the first file that matches the script set. |
| 256 | +func findScript(scriptSet []string, files []fs.DirEntry) string { |
| 257 | + for _, i := range scriptSet { |
| 258 | + for _, f := range files { |
| 259 | + if f.Name() == i { |
| 260 | + return f.Name() |
| 261 | + } |
| 262 | + } |
| 263 | + } |
| 264 | + |
| 265 | + return "" |
| 266 | +} |
| 267 | + |
| 268 | +// isRegular detects if the file exists and is not a symlink. |
| 269 | +func isRegular(to string) (bool, error) { |
| 270 | + fi, err := os.Lstat(to) |
| 271 | + if err != nil { |
| 272 | + if errors.Is(err, os.ErrNotExist) { |
| 273 | + return false, nil |
| 274 | + } |
| 275 | + return false, xerrors.Errorf("lstat %s: %w", to, err) |
| 276 | + } |
| 277 | + |
| 278 | + return fi.Mode().IsRegular(), nil |
| 279 | +} |
0 commit comments