Skip to content

Commit 92f4e4e

Browse files
fullstackjamclaude
andauthored
fix: clone external zsh plugins referenced by dotfiles .zshrc (#122)
* fix: clone external zsh plugins referenced by dotfiles .zshrc When a remote config carries no shell block (rc.Shell == nil) but does set a dotfiles_repo, the shell setup comes entirely from the stowed .zshrc. Its plugins=() list — e.g. zsh-autosuggestions, fast-syntax-highlighting, zsh-autocomplete — never flowed through RestoreFromSnapshot, so the external plugins it names were never git-cloned into $ZSH_CUSTOM/plugins. oh-my-zsh then logged "plugin '...' not found" on every shell startup. Add shell.CloneExternalPluginsFromZshrc: after dotfiles are linked, read the effective ~/.zshrc, extract plugins=(), and clone any catalog (external) plugins not already present. Built-in/unknown names are left untouched and a failed clone stays non-fatal, matching cloneExternalPlugins. No-op when oh-my-zsh isn't installed or .zshrc is absent, and dry-run safe. This is the path `openboot install <slug>` takes for configs like fullstackjam, where #121's plan-level fix could not help because there was no shell block to carry through. * fix: guard plugin name against path traversal before clone gosec G703 flagged cloneExternalPlugins now that plugin names can originate from a user-authored .zshrc (via CloneExternalPluginsFromZshrc) and flow into filepath.Join. Add an explicit path-segment guard rejecting names that aren't a plain single segment, and annotate the os.Stat with a justified nolint. A name only reaches here after matching the curated catalog, so this guard only ever rejects malicious input — it's defense in depth, not a behavior change. * fix: treat unreadable .zshrc as non-fatal in plugin clone An unreadable .zshrc now warns and returns nil instead of aborting the dotfiles step. By the time CloneExternalPluginsFromZshrc runs the dotfiles are already cloned and linked, and plugin setup is best-effort everywhere else (cloneExternalPlugins warns and continues on a failed clone), so a marginal read error should not fail the whole step. Add a test covering the non-NotExist read-error path. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9549bff commit 92f4e4e

3 files changed

Lines changed: 138 additions & 0 deletions

File tree

internal/installer/step_shell.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ func applyDotfiles(plan InstallPlan, r Reporter) error {
8484
return fmt.Errorf("link dotfiles: %w", err)
8585
}
8686

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

internal/shell/clone_plugins_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,78 @@ func TestCloneExternalPlugins_CloneFailureIsNonFatal(t *testing.T) {
134134
require.NoError(t, err)
135135
}
136136

137+
func TestCloneExternalPluginsFromZshrc_ClonesPluginsFromDotfiles(t *testing.T) {
138+
home := t.TempDir()
139+
t.Setenv("HOME", home)
140+
calls := withFakes(t, map[string]string{
141+
"zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions",
142+
"fast-syntax-highlighting": "https://github.com/zdharma-continuum/fast-syntax-highlighting",
143+
})
144+
require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0700))
145+
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"
146+
require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), []byte(zshrc), 0600))
147+
148+
require.NoError(t, CloneExternalPluginsFromZshrc(false))
149+
150+
require.Len(t, *calls, 2, "both external plugins from .zshrc should be cloned; built-ins skipped")
151+
got := map[string]bool{(*calls)[0][0]: true, (*calls)[1][0]: true}
152+
assert.True(t, got["https://github.com/zsh-users/zsh-autosuggestions"])
153+
assert.True(t, got["https://github.com/zdharma-continuum/fast-syntax-highlighting"])
154+
}
155+
156+
func TestCloneExternalPluginsFromZshrc_NoOmzIsNoOp(t *testing.T) {
157+
home := t.TempDir()
158+
t.Setenv("HOME", home)
159+
calls := withFakes(t, map[string]string{
160+
"zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions",
161+
})
162+
// .zshrc present but ~/.oh-my-zsh absent → nothing to clone into.
163+
require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), []byte("plugins=(zsh-autosuggestions)\n"), 0600))
164+
165+
require.NoError(t, CloneExternalPluginsFromZshrc(false))
166+
assert.Empty(t, *calls, "must not clone when oh-my-zsh is not installed")
167+
}
168+
169+
func TestCloneExternalPluginsFromZshrc_MissingZshrcIsNoOp(t *testing.T) {
170+
home := t.TempDir()
171+
t.Setenv("HOME", home)
172+
calls := withFakes(t, map[string]string{
173+
"zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions",
174+
})
175+
require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0700))
176+
177+
require.NoError(t, CloneExternalPluginsFromZshrc(false))
178+
assert.Empty(t, *calls, "a missing .zshrc must be a no-op, not an error")
179+
}
180+
181+
func TestCloneExternalPluginsFromZshrc_UnreadableZshrcWarnsButSucceeds(t *testing.T) {
182+
home := t.TempDir()
183+
t.Setenv("HOME", home)
184+
calls := withFakes(t, map[string]string{
185+
"zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions",
186+
})
187+
require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0700))
188+
// A directory at the .zshrc path makes os.ReadFile fail with a non-NotExist
189+
// error — best-effort plugin setup must warn and continue, not abort.
190+
require.NoError(t, os.MkdirAll(filepath.Join(home, ".zshrc"), 0700))
191+
192+
require.NoError(t, CloneExternalPluginsFromZshrc(false), "an unreadable .zshrc must not be fatal")
193+
assert.Empty(t, *calls)
194+
}
195+
196+
func TestCloneExternalPluginsFromZshrc_DryRunDoesNotClone(t *testing.T) {
197+
home := t.TempDir()
198+
t.Setenv("HOME", home)
199+
calls := withFakes(t, map[string]string{
200+
"zsh-autosuggestions": "https://github.com/zsh-users/zsh-autosuggestions",
201+
})
202+
require.NoError(t, os.MkdirAll(filepath.Join(home, ".oh-my-zsh"), 0700))
203+
require.NoError(t, os.WriteFile(filepath.Join(home, ".zshrc"), []byte("plugins=(zsh-autosuggestions)\n"), 0600))
204+
205+
require.NoError(t, CloneExternalPluginsFromZshrc(true))
206+
assert.Empty(t, *calls, "dry-run must not clone")
207+
}
208+
137209
func TestCloneExternalPlugins_RestoreWritesBareNames(t *testing.T) {
138210
home := t.TempDir()
139211
t.Setenv("HOME", home)

internal/shell/shell.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,19 @@ func cloneExternalPlugins(plugins []string, dryRun bool) error {
296296
continue
297297
}
298298

299+
// Defense in depth: a plugin name becomes a path segment under
300+
// $ZSH_CUSTOM/plugins and may originate from a user-authored .zshrc
301+
// (see CloneExternalPluginsFromZshrc). Reject anything that isn't a
302+
// plain single segment so a crafted name can never escape the plugins
303+
// directory. A matching catalog name is always a safe identifier, so
304+
// this only ever rejects malicious input.
305+
if name != filepath.Base(name) || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
306+
ui.Warn(fmt.Sprintf("Skipping plugin %s: unsafe name", name))
307+
continue
308+
}
309+
299310
dest := filepath.Join(customPlugins, name)
311+
//nolint:gosec // name is gated by resolvePluginURL (curated catalog) and the path-segment guard above; it cannot traverse out of customPlugins.
300312
if _, err := os.Stat(dest); err == nil {
301313
continue // already cloned — idempotent skip
302314
}
@@ -317,6 +329,50 @@ func cloneExternalPlugins(plugins []string, dryRun bool) error {
317329
return nil
318330
}
319331

332+
// zshrcPluginsRe extracts the names inside a plugins=(...) declaration from a
333+
// .zshrc. Mirrors snapshot.zshPluginsRe but tolerates leading whitespace so it
334+
// also matches indented declarations in user-authored dotfiles.
335+
var zshrcPluginsRe = regexp.MustCompile(`(?m)^\s*plugins=\((?s:(.*?))\)`)
336+
337+
// CloneExternalPluginsFromZshrc reads ~/.zshrc, extracts its plugins=() list,
338+
// and git-clones any external (catalog) plugins not already present. It exists
339+
// for the dotfiles path: when a user's shell setup comes entirely from a
340+
// stowed .zshrc (the remote config carries no shell block), the plugin list
341+
// never flows through RestoreFromSnapshot, so the external plugins it names are
342+
// never cloned and oh-my-zsh logs "plugin '...' not found" at startup.
343+
//
344+
// It is a no-op when oh-my-zsh isn't installed or .zshrc is absent. Built-in
345+
// and unknown plugins are left untouched. Like cloneExternalPlugins, plugin
346+
// setup is best-effort: an unreadable .zshrc warns and returns nil rather than
347+
// aborting the dotfiles step (the dotfiles are already cloned and linked by the
348+
// time this runs).
349+
func CloneExternalPluginsFromZshrc(dryRun bool) error {
350+
if !IsOhMyZshInstalled() {
351+
return nil
352+
}
353+
home, err := system.HomeDir()
354+
if err != nil {
355+
return fmt.Errorf("clone plugins from .zshrc: %w", err)
356+
}
357+
raw, err := os.ReadFile(filepath.Join(home, ".zshrc"))
358+
if err != nil {
359+
if os.IsNotExist(err) {
360+
return nil
361+
}
362+
ui.Warn(fmt.Sprintf("Skipping plugin clone: could not read .zshrc: %v", err))
363+
return nil
364+
}
365+
m := zshrcPluginsRe.FindSubmatch(raw)
366+
if len(m) < 2 {
367+
return nil // no plugins=() declaration
368+
}
369+
plugins := strings.Fields(string(m[1]))
370+
if len(plugins) == 0 {
371+
return nil
372+
}
373+
return cloneExternalPlugins(plugins, dryRun)
374+
}
375+
320376
func RestoreFromSnapshot(ohMyZsh bool, theme string, plugins []string, dryRun bool) error {
321377
if !ohMyZsh {
322378
return nil

0 commit comments

Comments
 (0)