mirror of https://github.com/coder/coder.git
feat: add dotfiles command (#1723)
This commit is contained in:
parent
47ef03fea4
commit
35ccb88f60
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -70,6 +70,7 @@ func Root() *cobra.Command {
|
|||
configSSH(),
|
||||
create(),
|
||||
delete(),
|
||||
dotfiles(),
|
||||
gitssh(),
|
||||
list(),
|
||||
login(),
|
||||
|
|
Loading…
Reference in New Issue