lib/clacky/utils/login_shell.rb
# frozen_string_literal: true module Clacky module Utils # Spawn child processes in an environment that has the user's shell rc # files sourced — so version managers (mise / rbenv / asdf / nvm) and # custom PATH entries are active, even when the clacky server itself # was started by launchd / a desktop icon with a minimal PATH. # # ## Approach: manual `source` + `exec` # # Instead of using `$SHELL -l -i -c` (which prints rc banners, triggers # job-control warnings in non-tty contexts, and may not even work as # expected under launchd), we build an inline shell snippet: # # { source ~/.zshenv; source ~/.zprofile; source ~/.zshrc; } 1>&2 # exec <target-cmd> # # Then invoke it with plain `zsh -c <snippet>` (NO -l / -i flags). # # Why this wins: # # - `source ~/.zshrc` runs user's rc code including `eval "$(mise activate zsh)"` # which injects the correct PATH (so `node`/`ruby`/`gem` resolve). # - `{ … } 1>&2` redirects ALL rc-time output (banners, welcome msgs, # mise warnings) to stderr, keeping target's stdout CLEAN — critical # for JSON-RPC stdio channels like chrome-devtools-mcp. # - `exec` replaces the shell with the target process, so our pipe's # child is the target itself (pid / signals / waitpid all work). # - No `-i`, so no "no job control in this shell" warnings. # - No `-l` needed because we explicitly source what we need. # # ## Method: login_shell_command # # Build argv for `Open3.popen3` / `Process.spawn` that runs `command` # with rc files pre-sourced. Returns argv, not a running process — # caller picks the right Open3 method for their needs. module LoginShell # Build argv that runs `command` inside a shell with rc files sourced. # # @param command [String] shell-ready command (caller quotes user input). # @return [Array<String>] argv for Open3.popen3 / Process.spawn. def self.login_shell_command(command) shell = ENV["SHELL"].to_s shell = "/bin/bash" if shell.empty? || !File.executable?(shell) name = File.basename(shell) name = "bash" unless %w[zsh bash].include?(name) shell = "/bin/bash" if name == "bash" && !File.executable?(shell) rc_sources = rc_source_snippet(name) # { rc_sources; } 1>&2 — send rc-time stdout to stderr so target's # stdout is pristine. `exec` replaces the shell with target. script = "{ #{rc_sources}; } 1>&2; exec #{command}" [shell, "-c", script] end # Per-shell rc source chain. Order matters: # zsh: .zshenv → .zprofile → .zshrc (login + interactive equivalent) # bash: .profile → .bash_profile → .bashrc def self.rc_source_snippet(shell_name) files = case shell_name when "zsh" then %w[.zshenv .zprofile .zshrc] else %w[.profile .bash_profile .bashrc] end files.map { |f| %([ -f "$HOME/#{f}" ] && . "$HOME/#{f}") }.join("; ") end end end end