-
Notifications
You must be signed in to change notification settings - Fork 881
feat: add dotfiles command #1723
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
7235e3a
chore: propose coder dotfiles command
f0ssel 6cd9f17
simplify example
f0ssel 6a2bd87
add command skeleton
f0ssel 3417628
Merge branch 'f0ssel/dotfiles-poc' of github.com:coder/coder into f0s…
f0ssel e127fe7
do clone/checkout
f0ssel 17490a4
add install and symlinking
f0ssel 2cf43d6
fix lint
f0ssel 3302497
spruce
f0ssel 250d10a
revert tf
f0ssel 675f7e6
lint
f0ssel eae24d0
ignore git files
f0ssel 8cf1c4f
pr comments
f0ssel 42e9028
remove shorthand
f0ssel 0b6480b
fixup
f0ssel 2769e0a
Add tests
f0ssel 051a361
lint
f0ssel 23c950a
try removing parallel
f0ssel 43c8b4f
formatting
f0ssel 2197126
pr comments
f0ssel 9e073ab
save dotfiles url config
f0ssel 0467613
fixup
f0ssel 3b505b0
%q
f0ssel fd9ba2a
testing
f0ssel 624265f
testing
f0ssel 44efa67
testing
f0ssel b059fdf
organize:
f0ssel b265149
add symlink backup test
f0ssel 581d6b8
handle script for windows
f0ssel 6dfc469
switch to require
f0ssel df5f6cd
pr comments
f0ssel b29ddf4
skip install script test on windows
f0ssel a435224
fix command
f0ssel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 { | ||
f0ssel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
_, _ = 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) | ||
f0ssel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} 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" | ||
f0ssel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
// 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()) | ||
} | ||
} | ||
f0ssel marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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 | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should improve the usage here. It will not be immediately obvious to users how we intend for them to use this (inside or outside of a workspace).
A few examples will go a long way!