Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions internal/installer/step_shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ func applyDotfiles(plan InstallPlan, r Reporter) error {
return fmt.Errorf("link dotfiles: %w", err)
}

// Dotfiles commonly ship their own .zshrc whose plugins=() list references
// external oh-my-zsh plugins (zsh-autosuggestions, fast-syntax-highlighting,
// ...). When the remote config carries no shell block, that list never flows
// through the shell step, so those plugins were never cloned and zsh logs
// "plugin '...' not found" at every startup. Clone them off the linked
// .zshrc now (no-op if OMZ is absent or no external plugins are referenced).
if err := shell.CloneExternalPluginsFromZshrc(plan.DryRun); err != nil {
return fmt.Errorf("clone plugins referenced by dotfiles: %w", err)
}

if !plan.DryRun {
r.Success("Dotfiles configured")
}
Expand Down
72 changes: 72 additions & 0 deletions internal/shell/clone_plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,78 @@ func TestCloneExternalPlugins_CloneFailureIsNonFatal(t *testing.T) {
require.NoError(t, err)
}

func TestCloneExternalPluginsFromZshrc_ClonesPluginsFromDotfiles(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
calls := withFakes(t, map[string]string{
"zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions",
"fast-syntax-highlighting": "https://github.com/zdharma-continuum/fast-syntax-highlighting",
})
require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0700))
zshrc := "export ZSH=\"$HOME/.oh-my-zsh\"\nZSH_THEME=\"robbyrussell\"\nplugins=(git helm kubectl zsh-autosuggestions fast-syntax-highlighting)\nsource $ZSH/oh-my-zsh.sh\n"
require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), []byte(zshrc), 0600))

require.NoError(t, CloneExternalPluginsFromZshrc(false))

require.Len(t, *calls, 2, "both external plugins from .zshrc should be cloned; built-ins skipped")
got := map[string]bool{(*calls)[0][0]: true, (*calls)[1][0]: true}
assert.True(t, got["https://github.com/zsh-users/zsh-autosuggestions"])
assert.True(t, got["https://github.com/zdharma-continuum/fast-syntax-highlighting"])
}

func TestCloneExternalPluginsFromZshrc_NoOmzIsNoOp(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
calls := withFakes(t, map[string]string{
"zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions",
})
// .zshrc present but ~/.oh-my-zsh absent → nothing to clone into.
require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), []byte("plugins=(zsh-autosuggestions)\n"), 0600))

require.NoError(t, CloneExternalPluginsFromZshrc(false))
assert.Empty(t, *calls, "must not clone when oh-my-zsh is not installed")
}

func TestCloneExternalPluginsFromZshrc_MissingZshrcIsNoOp(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
calls := withFakes(t, map[string]string{
"zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions",
})
require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0700))

require.NoError(t, CloneExternalPluginsFromZshrc(false))
assert.Empty(t, *calls, "a missing .zshrc must be a no-op, not an error")
}

func TestCloneExternalPluginsFromZshrc_UnreadableZshrcWarnsButSucceeds(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
calls := withFakes(t, map[string]string{
"zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions",
})
require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0700))
// A directory at the .zshrc path makes os.ReadFile fail with a non-NotExist
// error — best-effort plugin setup must warn and continue, not abort.
require.NoError(t, os.MkdirAll(filepath.Join(home, ".zshrc"), 0700))

require.NoError(t, CloneExternalPluginsFromZshrc(false), "an unreadable .zshrc must not be fatal")
assert.Empty(t, *calls)
}

func TestCloneExternalPluginsFromZshrc_DryRunDoesNotClone(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
calls := withFakes(t, map[string]string{
"zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions",
})
require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0700))
require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), []byte("plugins=(zsh-autosuggestions)\n"), 0600))

require.NoError(t, CloneExternalPluginsFromZshrc(true))
assert.Empty(t, *calls, "dry-run must not clone")
}

func TestCloneExternalPlugins_RestoreWritesBareNames(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
Expand Down
56 changes: 56 additions & 0 deletions internal/shell/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,19 @@ func cloneExternalPlugins(plugins []string, dryRun bool) error {
continue
}

// Defense in depth: a plugin name becomes a path segment under
// $ZSH_CUSTOM/plugins and may originate from a user-authored .zshrc
// (see CloneExternalPluginsFromZshrc). Reject anything that isn't a
// plain single segment so a crafted name can never escape the plugins
// directory. A matching catalog name is always a safe identifier, so
// this only ever rejects malicious input.
if name != filepath.Base(name) || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
ui.Warn(fmt.Sprintf("Skipping plugin %s: unsafe name", name))
continue
}

dest := filepath.Join(customPlugins, name)
//nolint:gosec // name is gated by resolvePluginURL (curated catalog) and the path-segment guard above; it cannot traverse out of customPlugins.
if _, err := os.Stat(dest); err == nil {
continue // already cloned — idempotent skip
}
Expand All @@ -317,6 +329,50 @@ func cloneExternalPlugins(plugins []string, dryRun bool) error {
return nil
}

// zshrcPluginsRe extracts the names inside a plugins=(...) declaration from a
// .zshrc. Mirrors snapshot.zshPluginsRe but tolerates leading whitespace so it
// also matches indented declarations in user-authored dotfiles.
var zshrcPluginsRe = regexp.MustCompile(`(?m)^\s*plugins=\((?s:(.*?))\)`)

// CloneExternalPluginsFromZshrc reads ~/.zshrc, extracts its plugins=() list,
// and git-clones any external (catalog) plugins not already present. It exists
// for the dotfiles path: when a user's shell setup comes entirely from a
// stowed .zshrc (the remote config carries no shell block), the plugin list
// never flows through RestoreFromSnapshot, so the external plugins it names are
// never cloned and oh-my-zsh logs "plugin '...' not found" at startup.
//
// It is a no-op when oh-my-zsh isn't installed or .zshrc is absent. Built-in
// and unknown plugins are left untouched. Like cloneExternalPlugins, plugin
// setup is best-effort: an unreadable .zshrc warns and returns nil rather than
// aborting the dotfiles step (the dotfiles are already cloned and linked by the
// time this runs).
func CloneExternalPluginsFromZshrc(dryRun bool) error {
if !IsOhMyZshInstalled() {
return nil
}
home, err := system.HomeDir()
if err != nil {
return fmt.Errorf("clone plugins from .zshrc: %w", err)
}
raw, err := os.ReadFile(filepath.Join(home, ".zshrc"))
if err != nil {
if os.IsNotExist(err) {
return nil
}
ui.Warn(fmt.Sprintf("Skipping plugin clone: could not read .zshrc: %v", err))
return nil
}
m := zshrcPluginsRe.FindSubmatch(raw)
if len(m) < 2 {
return nil // no plugins=() declaration
}
plugins := strings.Fields(string(m[1]))
if len(plugins) == 0 {
return nil
}
return cloneExternalPlugins(plugins, dryRun)
}

func RestoreFromSnapshot(ohMyZsh bool, theme string, plugins []string, dryRun bool) error {
if !ohMyZsh {
return nil
Expand Down
Loading