feat: add dotfiles command (#1723)

This commit is contained in:
Garrett Delfosse 2022-05-25 16:43:20 -05:00 committed by GitHub
parent 47ef03fea4
commit 35ccb88f60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 425 additions and 0 deletions

View File

@ -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

279
cli/dotfiles.go Normal file
View File

@ -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
}

141
cli/dotfiles_test.go Normal file
View File

@ -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
}

View File

@ -70,6 +70,7 @@ func Root() *cobra.Command {
configSSH(),
create(),
delete(),
dotfiles(),
gitssh(),
list(),
login(),