diff --git a/cli/config/file.go b/cli/config/file.go index 1bf3ce5bd35be..9af53f8e19f60 100644 --- a/cli/config/file.go +++ b/cli/config/file.go @@ -21,6 +21,10 @@ func (r Root) Organization() File { return File(filepath.Join(string(r), "organization")) } +func (r Root) DotfilesURL() File { + return File(filepath.Join(string(r), "dotfilesurl")) +} + // File provides convenience methods for interacting with *os.File. type File string diff --git a/cli/dotfiles.go b/cli/dotfiles.go new file mode 100644 index 0000000000000..e0310d34977ce --- /dev/null +++ b/cli/dotfiles.go @@ -0,0 +1,279 @@ +package cli + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/cli/cliflag" + "github.com/coder/coder/cli/cliui" +) + +func dotfiles() *cobra.Command { + var ( + symlinkDir string + ) + cmd := &cobra.Command{ + Use: "dotfiles [git_repo_url]", + Args: cobra.ExactArgs(1), + Short: "Checkout and install a dotfiles repository.", + Example: "coder dotfiles [-y] git@github.com:example/dotfiles.git", + RunE: func(cmd *cobra.Command, args []string) error { + var ( + dotfilesRepoDir = "dotfiles" + gitRepo = args[0] + cfg = createConfig(cmd) + 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", + } + ) + + _, _ = fmt.Fprint(cmd.OutOrStdout(), "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(cmd, 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(cmd.OutOrStdout(), "Done backup up dotfiles.\n") + dotfilesExists = false + moved = true + } + } + + var ( + gitCmdDir string + subcommands []string + promptText string + ) + if dotfilesExists { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "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(cmd.OutOrStdout(), "Did not find dotfiles repository at %s\n", dotfilesDir) + } + gitCmdDir = cfgDir + subcommands = []string{"clone", args[0], dotfilesRepoDir} + promptText = fmt.Sprintf("Cloning %s into directory %s.\n\n Continue?", gitRepo, dotfilesDir) + } + + _, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: promptText, + IsConfirm: true, + }) + if err != nil { + return err + } + + // ensure command dir exists + err = os.MkdirAll(gitCmdDir, 0750) + if err != nil { + return xerrors.Errorf("ensuring dir at %s: %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(cmd.Context(), "git", subcommands...) + c.Dir = gitCmdDir + c.Env = append(os.Environ(), fmt.Sprintf(`GIT_SSH_COMMAND=%s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no`, gitsshCmd)) + c.Stdout = cmd.OutOrStdout() + c.Stderr = cmd.ErrOrStderr() + 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(cmd.OutOrStdout(), cliui.Styles.Error.Render("Failed to update repo, continuing...")) + } + + // 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 + if strings.HasPrefix(f.Name(), ".") && !strings.HasPrefix(f.Name(), ".git") { + dotfiles = append(dotfiles, f.Name()) + } + } + + script := findScript(installScriptSet, files) + if script != "" { + _, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: fmt.Sprintf("Running install script %s.\n\n Continue?", script), + IsConfirm: true, + }) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Running %s...\n", 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(cmd.Context(), filepath.Join(dotfilesDir, script)) + scriptCmd.Dir = dotfilesDir + scriptCmd.Stdout = cmd.OutOrStdout() + scriptCmd.Stderr = cmd.ErrOrStderr() + err = scriptCmd.Run() + if err != nil { + return xerrors.Errorf("running %s: %w", script, err) + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dotfiles installation complete.") + return nil + } + + if len(dotfiles) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No install scripts or dotfiles found, nothing to do.") + return nil + } + + _, err = cliui.Prompt(cmd, 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(cmd.OutOrStdout(), "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(cmd.OutOrStdout(), "Moving %s to %s...\n", to, backup) + err = os.Rename(to, backup) + if err != nil { + return xerrors.Errorf("renaming dir %s: %w", to, err) + } + } + + err = os.Symlink(from, to) + if err != nil { + return xerrors.Errorf("symlinking %s to %s: %w", from, to, err) + } + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dotfiles installation complete.") + return nil + }, + } + cliui.AllowSkipPrompt(cmd) + cliflag.StringVarP(cmd.Flags(), &symlinkDir, "symlink-dir", "", "CODER_SYMLINK_DIR", "", "Specifies the directory for the dotfiles symlink destinations. If empty will use $HOME.") + + return cmd +} + +// 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 +} diff --git a/cli/dotfiles_test.go b/cli/dotfiles_test.go new file mode 100644 index 0000000000000..3a0c731257b4c --- /dev/null +++ b/cli/dotfiles_test.go @@ -0,0 +1,141 @@ +package cli_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/cli/config" + "github.com/coder/coder/cryptorand" +) + +// nolint:paralleltest +func TestDotfiles(t *testing.T) { + t.Run("MissingArg", func(t *testing.T) { + cmd, _ := clitest.New(t, "dotfiles") + err := cmd.Execute() + require.Error(t, err) + }) + t.Run("NoInstallScript", func(t *testing.T) { + _, root := clitest.New(t) + testRepo := testGitRepo(t, root) + + // nolint:gosec + err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0750) + require.NoError(t, err) + + c := exec.Command("git", "add", ".bashrc") + c.Dir = testRepo + err = c.Run() + require.NoError(t, err) + + c = exec.Command("git", "commit", "-m", `"add .bashrc"`) + c.Dir = testRepo + out, err := c.CombinedOutput() + require.NoError(t, err, string(out)) + + cmd, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo) + err = cmd.Execute() + require.NoError(t, err) + + b, err := os.ReadFile(filepath.Join(string(root), ".bashrc")) + require.NoError(t, err) + require.Equal(t, string(b), "wow") + }) + t.Run("InstallScript", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("install scripts on windows require sh and aren't very practical") + } + _, root := clitest.New(t) + testRepo := testGitRepo(t, root) + + // nolint:gosec + err := os.WriteFile(filepath.Join(testRepo, "install.sh"), []byte("#!/bin/bash\necho wow > "+filepath.Join(string(root), ".bashrc")), 0750) + require.NoError(t, err) + + c := exec.Command("git", "add", "install.sh") + c.Dir = testRepo + err = c.Run() + require.NoError(t, err) + + c = exec.Command("git", "commit", "-m", `"add install.sh"`) + c.Dir = testRepo + err = c.Run() + require.NoError(t, err) + + cmd, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo) + err = cmd.Execute() + require.NoError(t, err) + + b, err := os.ReadFile(filepath.Join(string(root), ".bashrc")) + require.NoError(t, err) + require.Equal(t, string(b), "wow\n") + }) + t.Run("SymlinkBackup", func(t *testing.T) { + _, root := clitest.New(t) + testRepo := testGitRepo(t, root) + + // nolint:gosec + err := os.WriteFile(filepath.Join(testRepo, ".bashrc"), []byte("wow"), 0750) + require.NoError(t, err) + + // add a conflicting file at destination + // nolint:gosec + err = os.WriteFile(filepath.Join(string(root), ".bashrc"), []byte("backup"), 0750) + require.NoError(t, err) + + c := exec.Command("git", "add", ".bashrc") + c.Dir = testRepo + err = c.Run() + require.NoError(t, err) + + c = exec.Command("git", "commit", "-m", `"add .bashrc"`) + c.Dir = testRepo + out, err := c.CombinedOutput() + require.NoError(t, err, string(out)) + + cmd, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo) + err = cmd.Execute() + require.NoError(t, err) + + b, err := os.ReadFile(filepath.Join(string(root), ".bashrc")) + require.NoError(t, err) + require.Equal(t, string(b), "wow") + + // check for backup file + b, err = os.ReadFile(filepath.Join(string(root), ".bashrc.bak")) + require.NoError(t, err) + require.Equal(t, string(b), "backup") + }) +} + +func testGitRepo(t *testing.T, root config.Root) string { + r, err := cryptorand.String(8) + require.NoError(t, err) + dir := filepath.Join(string(root), fmt.Sprintf("test-repo-%s", r)) + err = os.MkdirAll(dir, 0750) + require.NoError(t, err) + + c := exec.Command("git", "init") + c.Dir = dir + err = c.Run() + require.NoError(t, err) + + c = exec.Command("git", "config", "user.email", "ci@coder.com") + c.Dir = dir + err = c.Run() + require.NoError(t, err) + + c = exec.Command("git", "config", "user.name", "C I") + c.Dir = dir + err = c.Run() + require.NoError(t, err) + + return dir +} diff --git a/cli/root.go b/cli/root.go index 7398986608b79..b6867570e6297 100644 --- a/cli/root.go +++ b/cli/root.go @@ -69,6 +69,7 @@ func Root() *cobra.Command { configSSH(), create(), delete(), + dotfiles(), gitssh(), list(), login(),