package cli import ( "bytes" "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" "strings" "time" "golang.org/x/xerrors" "github.com/coder/pretty" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/serpent" ) func (r *RootCmd) dotfiles() *serpent.Command { var symlinkDir string var gitbranch string var dotfilesRepoDir string cmd := &serpent.Command{ Use: "dotfiles ", Middleware: serpent.RequireNArgs(1), Short: "Personalize your workspace by applying a canonical dotfiles repository", Long: formatExamples( example{ Description: "Check out and install a dotfiles repository without prompts", Command: "coder dotfiles --yes git@github.com:example/dotfiles.git", }, ), Handler: func(inv *serpent.Invocation) error { var ( gitRepo = inv.Args[0] cfg = r.createConfig() cfgDir = string(cfg) dotfilesDir = filepath.Join(cfgDir, dotfilesRepoDir) // This follows the same pattern outlined by others in the market: // https://github.com/coder/coder/pull/1696#issue-1245742312 installScriptSet = []string{ "install.sh", "install", "bootstrap.sh", "bootstrap", "script/bootstrap", "setup.sh", "setup", "script/setup", } ) if cfg == "" { return xerrors.Errorf("no config directory") } _, _ = fmt.Fprint(inv.Stdout, "Checking if dotfiles repository already exists...\n") dotfilesExists, err := dirExists(dotfilesDir) if err != nil { return xerrors.Errorf("checking dir %s: %w", dotfilesDir, err) } moved := false if dotfilesExists { du, err := cfg.DotfilesURL().Read() if err != nil && !errors.Is(err, os.ErrNotExist) { return xerrors.Errorf("reading dotfiles url config: %w", err) } // if the git url has changed we create a backup and clone fresh if gitRepo != du { backupDir := fmt.Sprintf("%s_backup_%s", dotfilesDir, time.Now().Format(time.RFC3339)) _, err = cliui.Prompt(inv, cliui.PromptOptions{ 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), IsConfirm: true, }) if err != nil { return err } err = os.Rename(dotfilesDir, backupDir) if err != nil { return xerrors.Errorf("renaming dir %s: %w", dotfilesDir, err) } _, _ = fmt.Fprint(inv.Stdout, "Done backup up dotfiles.\n") dotfilesExists = false moved = true } } var ( gitCmdDir string subcommands []string promptText string ) if dotfilesExists { _, _ = fmt.Fprintf(inv.Stdout, "Found dotfiles repository at %s\n", dotfilesDir) gitCmdDir = dotfilesDir subcommands = []string{"pull", "--ff-only"} promptText = fmt.Sprintf("Pulling latest from %s into directory %s.\n Continue?", gitRepo, dotfilesDir) } else { if !moved { _, _ = fmt.Fprintf(inv.Stdout, "Did not find dotfiles repository at %s\n", dotfilesDir) } gitCmdDir = cfgDir subcommands = []string{"clone", inv.Args[0], dotfilesRepoDir} if gitbranch != "" { subcommands = append(subcommands, "--branch", gitbranch) } promptText = fmt.Sprintf("Cloning %s into directory %s.\n\n Continue?", gitRepo, dotfilesDir) } _, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: promptText, IsConfirm: true, }) if err != nil { return err } // ensure command dir exists err = os.MkdirAll(gitCmdDir, 0o750) if err != nil { return xerrors.Errorf("ensuring dir at %q: %w", gitCmdDir, err) } // check if git ssh command already exists so we can just wrap it gitsshCmd := os.Getenv("GIT_SSH_COMMAND") if gitsshCmd == "" { gitsshCmd = "ssh" } // clone or pull repo c := exec.CommandContext(inv.Context(), "git", subcommands...) c.Dir = gitCmdDir c.Env = append(inv.Environ.ToOS(), fmt.Sprintf(`GIT_SSH_COMMAND=%s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no`, gitsshCmd)) c.Stdout = inv.Stdout c.Stderr = inv.Stderr err = c.Run() if err != nil { if !dotfilesExists { return err } // if the repo exists we soft fail the update operation and try to continue _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Error, "Failed to update repo, continuing...")) } if dotfilesExists && gitbranch != "" { // If the repo exists and the git-branch is specified, we need to check out the branch. We do this after // git pull to make sure the branch was pulled down locally. If we do this before the pull, we could be // trying to checkout a branch that does not yet exist locally and get a git error. _, _ = fmt.Fprintf(inv.Stdout, "Dotfiles git branch %q specified\n", gitbranch) err := ensureCorrectGitBranch(inv, ensureCorrectGitBranchParams{ repoDir: dotfilesDir, gitSSHCommand: gitsshCmd, gitBranch: gitbranch, }) if err != nil { // Do not block on this error, just log it and continue _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Error, fmt.Sprintf("Failed to use branch %q (%s), continuing...", err.Error(), gitbranch))) } } // save git repo url so we can detect changes next time err = cfg.DotfilesURL().Write(gitRepo) if err != nil { return xerrors.Errorf("writing dotfiles url config: %w", err) } files, err := os.ReadDir(dotfilesDir) if err != nil { return xerrors.Errorf("reading files in dir %s: %w", dotfilesDir, err) } var dotfiles []string for _, f := range files { // make sure we do not copy `.git*` files except `.gitconfig` if strings.HasPrefix(f.Name(), ".") && (!strings.HasPrefix(f.Name(), ".git") || f.Name() == ".gitconfig") { dotfiles = append(dotfiles, f.Name()) } } script := findScript(installScriptSet, files) if script != "" { _, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: fmt.Sprintf("Running install script %s.\n\n Continue?", script), IsConfirm: true, }) if err != nil { return err } _, _ = fmt.Fprintf(inv.Stdout, "Running %s...\n", script) // Check if the script is executable and notify on error scriptPath := filepath.Join(dotfilesDir, script) fi, err := os.Stat(scriptPath) if err != nil { return xerrors.Errorf("stat %s: %w", scriptPath, err) } if fi.Mode()&0o111 == 0 { return xerrors.Errorf("script %q is not executable. See https://coder.com/docs/v2/latest/dotfiles for information on how to resolve the issue.", script) } // it is safe to use a variable command here because it's from // a filtered list of pre-approved install scripts // nolint:gosec scriptCmd := exec.CommandContext(inv.Context(), filepath.Join(dotfilesDir, script)) scriptCmd.Dir = dotfilesDir scriptCmd.Stdout = inv.Stdout scriptCmd.Stderr = inv.Stderr err = scriptCmd.Run() if err != nil { return xerrors.Errorf("running %s: %w", script, err) } _, _ = fmt.Fprintln(inv.Stdout, "Dotfiles installation complete.") return nil } if len(dotfiles) == 0 { _, _ = fmt.Fprintln(inv.Stdout, "No install scripts or dotfiles found, nothing to do.") return nil } _, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "No install scripts found, symlinking dotfiles to home directory.\n\n Continue?", IsConfirm: true, }) if err != nil { return err } if symlinkDir == "" { symlinkDir, err = os.UserHomeDir() if err != nil { return xerrors.Errorf("getting user home: %w", err) } } for _, df := range dotfiles { from := filepath.Join(dotfilesDir, df) to := filepath.Join(symlinkDir, df) _, _ = fmt.Fprintf(inv.Stdout, "Symlinking %s to %s...\n", from, to) isRegular, err := isRegular(to) if err != nil { return xerrors.Errorf("checking symlink for %s: %w", to, err) } // move conflicting non-symlink files to file.ext.bak if isRegular { backup := fmt.Sprintf("%s.bak", to) _, _ = fmt.Fprintf(inv.Stdout, "Moving %s to %s...\n", to, backup) err = os.Rename(to, backup) if err != nil { return xerrors.Errorf("renaming dir %s: %w", to, err) } } // attempt to delete the file before creating a new symlink. This overwrites any existing symlinks // which are typically leftover from a previous call to coder dotfiles. We do this best effort and // ignore errors because the symlink may or may not exist. Any regular files are backed up above. _ = os.Remove(to) err = os.Symlink(from, to) if err != nil { return xerrors.Errorf("symlinking %s to %s: %w", from, to, err) } } _, _ = fmt.Fprintln(inv.Stdout, "Dotfiles installation complete.") return nil }, } cmd.Options = serpent.OptionSet{ { Flag: "symlink-dir", Env: "CODER_SYMLINK_DIR", Description: "Specifies the directory for the dotfiles symlink destinations. If empty, will use $HOME.", Value: serpent.StringOf(&symlinkDir), }, { Flag: "branch", FlagShorthand: "b", Description: "Specifies which branch to clone. " + "If empty, will default to cloning the default branch or using the existing branch in the cloned repo on disk.", Value: serpent.StringOf(&gitbranch), }, { Flag: "repo-dir", Default: "dotfiles", Env: "CODER_DOTFILES_REPO_DIR", Description: "Specifies the directory for the dotfiles repository, relative to global config directory.", Value: serpent.StringOf(&dotfilesRepoDir), }, cliui.SkipPromptOption(), } return cmd } type ensureCorrectGitBranchParams struct { repoDir string gitSSHCommand string gitBranch string } func ensureCorrectGitBranch(baseInv *serpent.Invocation, params ensureCorrectGitBranchParams) error { dotfileCmd := func(cmd string, args ...string) *exec.Cmd { c := exec.CommandContext(baseInv.Context(), cmd, args...) c.Dir = params.repoDir c.Env = append(baseInv.Environ.ToOS(), fmt.Sprintf(`GIT_SSH_COMMAND=%s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no`, params.gitSSHCommand)) c.Stdout = baseInv.Stdout c.Stderr = baseInv.Stderr return c } c := dotfileCmd("git", "branch", "--show-current") // Save the output var out bytes.Buffer c.Stdout = &out err := c.Run() if err != nil { return xerrors.Errorf("getting current git branch: %w", err) } if strings.TrimSpace(out.String()) != params.gitBranch { // Checkout and pull the branch c := dotfileCmd("git", "checkout", params.gitBranch) err := c.Run() if err != nil { return xerrors.Errorf("checkout git branch %q: %w", params.gitBranch, err) } c = dotfileCmd("git", "pull", "--ff-only") err = c.Run() if err != nil { return xerrors.Errorf("pull git branch %q: %w", params.gitBranch, err) } } return nil } // dirExists checks if the path exists and is a directory. func dirExists(name string) (bool, error) { fi, err := os.Stat(name) if err != nil { if os.IsNotExist(err) { return false, nil } return false, xerrors.Errorf("stat dir: %w", err) } if !fi.IsDir() { return false, xerrors.New("exists but not a directory") } return true, nil } // findScript will find the first file that matches the script set. func findScript(scriptSet []string, files []fs.DirEntry) string { for _, i := range scriptSet { for _, f := range files { if f.Name() == i { return f.Name() } } } return "" } // isRegular detects if the file exists and is not a symlink. func isRegular(to string) (bool, error) { fi, err := os.Lstat(to) if err != nil { if errors.Is(err, os.ErrNotExist) { return false, nil } return false, xerrors.Errorf("lstat %s: %w", to, err) } return fi.Mode().IsRegular(), nil }