diff --git a/internal/installer/step_shell.go b/internal/installer/step_shell.go index eae5f63..b08c09f 100644 --- a/internal/installer/step_shell.go +++ b/internal/installer/step_shell.go @@ -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") } diff --git a/internal/shell/clone_plugins_test.go b/internal/shell/clone_plugins_test.go index dfafc93..56a943d 100644 --- a/internal/shell/clone_plugins_test.go +++ b/internal/shell/clone_plugins_test.go @@ -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) diff --git a/internal/shell/shell.go b/internal/shell/shell.go index ac9f951..ad009de 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -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 } @@ -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