coder/cli/dotfiles.go

388 lines
12 KiB
Go

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 <git_repo_url>",
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
}