Skip to content

Commit 35ccb88

Browse files
authored
feat: add dotfiles command (#1723)
1 parent 47ef03f commit 35ccb88

File tree

4 files changed

+425
-0
lines changed

4 files changed

+425
-0
lines changed

cli/config/file.go

+4
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ func (r Root) Organization() File {
2121
return File(filepath.Join(string(r), "organization"))
2222
}
2323

24+
func (r Root) DotfilesURL() File {
25+
return File(filepath.Join(string(r), "dotfilesurl"))
26+
}
27+
2428
// File provides convenience methods for interacting with *os.File.
2529
type File string
2630

cli/dotfiles.go

+279
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
package cli
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io/fs"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
13+
"github.com/spf13/cobra"
14+
"golang.org/x/xerrors"
15+
16+
"github.com/coder/coder/cli/cliflag"
17+
"github.com/coder/coder/cli/cliui"
18+
)
19+
20+
func dotfiles() *cobra.Command {
21+
var (
22+
symlinkDir string
23+
)
24+
cmd := &cobra.Command{
25+
Use: "dotfiles [git_repo_url]",
26+
Args: cobra.ExactArgs(1),
27+
Short: "Checkout and install a dotfiles repository.",
28+
Example: "coder dotfiles [-y] git@github.com:example/dotfiles.git",
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
var (
31+
dotfilesRepoDir = "dotfiles"
32+
gitRepo = args[0]
33+
cfg = createConfig(cmd)
34+
cfgDir = string(cfg)
35+
dotfilesDir = filepath.Join(cfgDir, dotfilesRepoDir)
36+
// This follows the same pattern outlined by others in the market:
37+
// https://github.com/coder/coder/pull/1696#issue-1245742312
38+
installScriptSet = []string{
39+
"install.sh",
40+
"install",
41+
"bootstrap.sh",
42+
"bootstrap",
43+
"script/bootstrap",
44+
"setup.sh",
45+
"setup",
46+
"script/setup",
47+
}
48+
)
49+
50+
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Checking if dotfiles repository already exists...\n")
51+
dotfilesExists, err := dirExists(dotfilesDir)
52+
if err != nil {
53+
return xerrors.Errorf("checking dir %s: %w", dotfilesDir, err)
54+
}
55+
56+
moved := false
57+
if dotfilesExists {
58+
du, err := cfg.DotfilesURL().Read()
59+
if err != nil && !errors.Is(err, os.ErrNotExist) {
60+
return xerrors.Errorf("reading dotfiles url config: %w", err)
61+
}
62+
// if the git url has changed we create a backup and clone fresh
63+
if gitRepo != du {
64+
backupDir := fmt.Sprintf("%s_backup_%s", dotfilesDir, time.Now().Format(time.RFC3339))
65+
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
66+
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),
67+
IsConfirm: true,
68+
})
69+
if err != nil {
70+
return err
71+
}
72+
73+
err = os.Rename(dotfilesDir, backupDir)
74+
if err != nil {
75+
return xerrors.Errorf("renaming dir %s: %w", dotfilesDir, err)
76+
}
77+
_, _ = fmt.Fprint(cmd.OutOrStdout(), "Done backup up dotfiles.\n")
78+
dotfilesExists = false
79+
moved = true
80+
}
81+
}
82+
83+
var (
84+
gitCmdDir string
85+
subcommands []string
86+
promptText string
87+
)
88+
if dotfilesExists {
89+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Found dotfiles repository at %s\n", dotfilesDir)
90+
gitCmdDir = dotfilesDir
91+
subcommands = []string{"pull", "--ff-only"}
92+
promptText = fmt.Sprintf("Pulling latest from %s into directory %s.\n Continue?", gitRepo, dotfilesDir)
93+
} else {
94+
if !moved {
95+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Did not find dotfiles repository at %s\n", dotfilesDir)
96+
}
97+
gitCmdDir = cfgDir
98+
subcommands = []string{"clone", args[0], dotfilesRepoDir}
99+
promptText = fmt.Sprintf("Cloning %s into directory %s.\n\n Continue?", gitRepo, dotfilesDir)
100+
}
101+
102+
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
103+
Text: promptText,
104+
IsConfirm: true,
105+
})
106+
if err != nil {
107+
return err
108+
}
109+
110+
// ensure command dir exists
111+
err = os.MkdirAll(gitCmdDir, 0750)
112+
if err != nil {
113+
return xerrors.Errorf("ensuring dir at %s: %w", gitCmdDir, err)
114+
}
115+
116+
// check if git ssh command already exists so we can just wrap it
117+
gitsshCmd := os.Getenv("GIT_SSH_COMMAND")
118+
if gitsshCmd == "" {
119+
gitsshCmd = "ssh"
120+
}
121+
122+
// clone or pull repo
123+
c := exec.CommandContext(cmd.Context(), "git", subcommands...)
124+
c.Dir = gitCmdDir
125+
c.Env = append(os.Environ(), fmt.Sprintf(`GIT_SSH_COMMAND=%s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no`, gitsshCmd))
126+
c.Stdout = cmd.OutOrStdout()
127+
c.Stderr = cmd.ErrOrStderr()
128+
err = c.Run()
129+
if err != nil {
130+
if !dotfilesExists {
131+
return err
132+
}
133+
// if the repo exists we soft fail the update operation and try to continue
134+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), cliui.Styles.Error.Render("Failed to update repo, continuing..."))
135+
}
136+
137+
// save git repo url so we can detect changes next time
138+
err = cfg.DotfilesURL().Write(gitRepo)
139+
if err != nil {
140+
return xerrors.Errorf("writing dotfiles url config: %w", err)
141+
}
142+
143+
files, err := os.ReadDir(dotfilesDir)
144+
if err != nil {
145+
return xerrors.Errorf("reading files in dir %s: %w", dotfilesDir, err)
146+
}
147+
148+
var dotfiles []string
149+
for _, f := range files {
150+
// make sure we do not copy `.git*` files
151+
if strings.HasPrefix(f.Name(), ".") && !strings.HasPrefix(f.Name(), ".git") {
152+
dotfiles = append(dotfiles, f.Name())
153+
}
154+
}
155+
156+
script := findScript(installScriptSet, files)
157+
if script != "" {
158+
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
159+
Text: fmt.Sprintf("Running install script %s.\n\n Continue?", script),
160+
IsConfirm: true,
161+
})
162+
if err != nil {
163+
return err
164+
}
165+
166+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Running %s...\n", script)
167+
// it is safe to use a variable command here because it's from
168+
// a filtered list of pre-approved install scripts
169+
// nolint:gosec
170+
scriptCmd := exec.CommandContext(cmd.Context(), filepath.Join(dotfilesDir, script))
171+
scriptCmd.Dir = dotfilesDir
172+
scriptCmd.Stdout = cmd.OutOrStdout()
173+
scriptCmd.Stderr = cmd.ErrOrStderr()
174+
err = scriptCmd.Run()
175+
if err != nil {
176+
return xerrors.Errorf("running %s: %w", script, err)
177+
}
178+
179+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dotfiles installation complete.")
180+
return nil
181+
}
182+
183+
if len(dotfiles) == 0 {
184+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No install scripts or dotfiles found, nothing to do.")
185+
return nil
186+
}
187+
188+
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
189+
Text: "No install scripts found, symlinking dotfiles to home directory.\n\n Continue?",
190+
IsConfirm: true,
191+
})
192+
if err != nil {
193+
return err
194+
}
195+
196+
if symlinkDir == "" {
197+
symlinkDir, err = os.UserHomeDir()
198+
if err != nil {
199+
return xerrors.Errorf("getting user home: %w", err)
200+
}
201+
}
202+
203+
for _, df := range dotfiles {
204+
from := filepath.Join(dotfilesDir, df)
205+
to := filepath.Join(symlinkDir, df)
206+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Symlinking %s to %s...\n", from, to)
207+
208+
isRegular, err := isRegular(to)
209+
if err != nil {
210+
return xerrors.Errorf("checking symlink for %s: %w", to, err)
211+
}
212+
// move conflicting non-symlink files to file.ext.bak
213+
if isRegular {
214+
backup := fmt.Sprintf("%s.bak", to)
215+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Moving %s to %s...\n", to, backup)
216+
err = os.Rename(to, backup)
217+
if err != nil {
218+
return xerrors.Errorf("renaming dir %s: %w", to, err)
219+
}
220+
}
221+
222+
err = os.Symlink(from, to)
223+
if err != nil {
224+
return xerrors.Errorf("symlinking %s to %s: %w", from, to, err)
225+
}
226+
}
227+
228+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dotfiles installation complete.")
229+
return nil
230+
},
231+
}
232+
cliui.AllowSkipPrompt(cmd)
233+
cliflag.StringVarP(cmd.Flags(), &symlinkDir, "symlink-dir", "", "CODER_SYMLINK_DIR", "", "Specifies the directory for the dotfiles symlink destinations. If empty will use $HOME.")
234+
235+
return cmd
236+
}
237+
238+
// dirExists checks if the path exists and is a directory.
239+
func dirExists(name string) (bool, error) {
240+
fi, err := os.Stat(name)
241+
if err != nil {
242+
if os.IsNotExist(err) {
243+
return false, nil
244+
}
245+
246+
return false, xerrors.Errorf("stat dir: %w", err)
247+
}
248+
if !fi.IsDir() {
249+
return false, xerrors.New("exists but not a directory")
250+
}
251+
252+
return true, nil
253+
}
254+
255+
// findScript will find the first file that matches the script set.
256+
func findScript(scriptSet []string, files []fs.DirEntry) string {
257+
for _, i := range scriptSet {
258+
for _, f := range files {
259+
if f.Name() == i {
260+
return f.Name()
261+
}
262+
}
263+
}
264+
265+
return ""
266+
}
267+
268+
// isRegular detects if the file exists and is not a symlink.
269+
func isRegular(to string) (bool, error) {
270+
fi, err := os.Lstat(to)
271+
if err != nil {
272+
if errors.Is(err, os.ErrNotExist) {
273+
return false, nil
274+
}
275+
return false, xerrors.Errorf("lstat %s: %w", to, err)
276+
}
277+
278+
return fi.Mode().IsRegular(), nil
279+
}

0 commit comments

Comments
 (0)