From 3782c0af0b14aad343ef212c8a930d2278084c81 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Tue, 7 Apr 2026 14:57:04 -0500 Subject: [PATCH 1/2] Resolve login shell PATH for .app bundle launches (#123) When Crow.app is launched from Finder/Dock, macOS provides a minimal PATH that excludes Homebrew-installed tools like gh and glab. Add a ShellEnvironment singleton in CrowCore that resolves the user's full PATH from their login shell on first access, then inject it into all Process calls across GitManager, ProviderManager, IssueTracker, SessionService, and AppDelegate. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/CrowCore/ShellEnvironment.swift | 124 ++++++++++++++++++ .../CrowGit/Sources/CrowGit/GitManager.swift | 1 + .../CrowProvider/ProviderManager.swift | 8 +- Sources/Crow/App/AppDelegate.swift | 24 +--- Sources/Crow/App/IssueTracker.swift | 10 +- Sources/Crow/App/SessionService.swift | 2 + 6 files changed, 139 insertions(+), 30 deletions(-) create mode 100644 Packages/CrowCore/Sources/CrowCore/ShellEnvironment.swift diff --git a/Packages/CrowCore/Sources/CrowCore/ShellEnvironment.swift b/Packages/CrowCore/Sources/CrowCore/ShellEnvironment.swift new file mode 100644 index 0000000..2ef420d --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/ShellEnvironment.swift @@ -0,0 +1,124 @@ +import Foundation + +/// Resolves the user's login shell PATH for subprocess execution. +/// +/// When Crow.app launches from Finder/Dock, macOS provides a minimal PATH +/// (`/usr/bin:/bin:/usr/sbin:/sbin`). This singleton resolves the full PATH +/// from the user's login shell so that Homebrew-installed tools like `gh`, +/// `glab`, and `claude` can be found. +public final class ShellEnvironment: Sendable { + public static let shared = ShellEnvironment() + + /// Full process environment with the resolved PATH. + public let env: [String: String] + + /// The resolved PATH string. + public let resolvedPATH: String + + private init() { + let inherited = ProcessInfo.processInfo.environment + let resolved = Self.resolvePATH(inherited: inherited) + self.resolvedPATH = resolved + var environment = inherited + environment["PATH"] = resolved + self.env = environment + } + + /// Returns `env` with additional key-value pairs merged on top. + public func merging(_ extra: [String: String]) -> [String: String] { + env.merging(extra) { _, new in new } + } + + /// Returns `true` if `name` is an executable found in the resolved PATH. + public func hasCommand(_ name: String) -> Bool { + let fm = FileManager.default + for dir in resolvedPATH.split(separator: ":") { + let path = "\(dir)/\(name)" + if fm.isExecutableFile(atPath: path) { + return true + } + } + return false + } + + // MARK: - PATH Resolution + + private static func resolvePATH(inherited: [String: String]) -> String { + if let resolved = resolveFromLoginShell(inherited: inherited) { + return resolved + } + return fallbackPATH(inherited: inherited) + } + + /// Runs the user's login shell to extract the full PATH. + private static func resolveFromLoginShell(inherited: [String: String]) -> String? { + let shell = inherited["SHELL"] ?? "/bin/zsh" + let process = Process() + let pipe = Pipe() + + process.executableURL = URL(fileURLWithPath: shell) + process.arguments = ["-lc", "echo $PATH"] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + // Inherit current environment so $HOME etc. are available + process.environment = inherited + + do { + try process.run() + } catch { + NSLog("[Crow] Failed to launch login shell for PATH resolution: %@", error.localizedDescription) + return nil + } + + // Timeout after 5 seconds to avoid hanging on slow shell configs + let deadline = DispatchTime.now() + .seconds(5) + let done = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + process.waitUntilExit() + done.signal() + } + if done.wait(timeout: deadline) == .timedOut { + process.terminate() + NSLog("[Crow] Login shell PATH resolution timed out") + return nil + } + + guard process.terminationStatus == 0 else { + NSLog("[Crow] Login shell exited with status %d", process.terminationStatus) + return nil + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return nil } + + // Take the last non-empty line (shell may print MOTD/banners before echo) + let path = output + .split(separator: "\n", omittingEmptySubsequences: true) + .last + .map(String.init)? + .trimmingCharacters(in: .whitespaces) + + guard let path, !path.isEmpty else { return nil } + NSLog("[Crow] Resolved PATH from login shell (%d components)", path.split(separator: ":").count) + return path + } + + /// Appends well-known tool directories to the inherited PATH. + private static func fallbackPATH(inherited: [String: String]) -> String { + NSLog("[Crow] Using fallback PATH resolution") + let current = inherited["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" + let existing = Set(current.split(separator: ":").map(String.init)) + + let wellKnown = [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + NSHomeDirectory() + "/.local/bin", + ] + + let additions = wellKnown.filter { !existing.contains($0) } + if additions.isEmpty { return current } + return current + ":" + additions.joined(separator: ":") + } +} diff --git a/Packages/CrowGit/Sources/CrowGit/GitManager.swift b/Packages/CrowGit/Sources/CrowGit/GitManager.swift index 0a8a61d..4936c13 100644 --- a/Packages/CrowGit/Sources/CrowGit/GitManager.swift +++ b/Packages/CrowGit/Sources/CrowGit/GitManager.swift @@ -122,6 +122,7 @@ public actor GitManager { let stderrPipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = args + process.environment = ShellEnvironment.shared.env process.standardOutput = stdoutPipe process.standardError = stderrPipe try process.run() diff --git a/Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift b/Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift index ff92a6d..bba374b 100644 --- a/Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift +++ b/Packages/CrowProvider/Sources/CrowProvider/ProviderManager.swift @@ -127,11 +127,9 @@ public actor ProviderManager { let pipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = args - if !env.isEmpty { - var environment = ProcessInfo.processInfo.environment - for (key, value) in env { environment[key] = value } - process.environment = environment - } + process.environment = env.isEmpty + ? ShellEnvironment.shared.env + : ShellEnvironment.shared.merging(env) process.standardOutput = pipe process.standardError = pipe try process.run() diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 3aec2fc..1f0c93d 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -119,29 +119,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Check for runtime dependencies (non-blocking) Task { let missing = await Task.detached { - var result: [String] = [] let tools = ["gh", "git", "claude", "glab", "code"] - for tool in tools { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/usr/bin/which") - proc.arguments = [tool] - proc.standardOutput = FileHandle.nullDevice - proc.standardError = FileHandle.nullDevice - do { - try proc.run() - proc.waitUntilExit() - if proc.terminationStatus != 0 { - NSLog("[Crow] Runtime dependency not found: %@", tool) - result.append(tool) - } - } catch { - NSLog("[Crow] Could not check for %@: %@", tool, error.localizedDescription) - result.append(tool) - } - } - return result + return tools.filter { !ShellEnvironment.shared.hasCommand($0) } }.value if !missing.isEmpty { + for tool in missing { + NSLog("[Crow] Runtime dependency not found: %@", tool) + } appState.missingDependencies = missing } } diff --git a/Sources/Crow/App/IssueTracker.swift b/Sources/Crow/App/IssueTracker.swift index f289335..be16448 100644 --- a/Sources/Crow/App/IssueTracker.swift +++ b/Sources/Crow/App/IssueTracker.swift @@ -342,6 +342,7 @@ final class IssueTracker { let pipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = args + process.environment = ShellEnvironment.shared.env process.standardOutput = pipe process.standardError = Pipe() try process.run() @@ -882,11 +883,9 @@ final class IssueTracker { let pipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = args - if !env.isEmpty { - var environment = ProcessInfo.processInfo.environment - for (k, v) in env { environment[k] = v } - process.environment = environment - } + process.environment = env.isEmpty + ? ShellEnvironment.shared.env + : ShellEnvironment.shared.merging(env) process.standardOutput = pipe process.standardError = Pipe() try process.run() @@ -913,6 +912,7 @@ final class IssueTracker { let errPipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = args + process.environment = ShellEnvironment.shared.env process.standardOutput = outPipe process.standardError = errPipe do { try process.run() } catch { return ShellResult(stdout: "", stderr: error.localizedDescription, exitCode: -1) } diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 4a8d1a9..d0b8653 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -321,6 +321,7 @@ final class SessionService { let stderrPipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = args + process.environment = ShellEnvironment.shared.env process.standardOutput = stdoutPipe process.standardError = stderrPipe try process.run() @@ -350,6 +351,7 @@ final class SessionService { let pipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = args + process.environment = ShellEnvironment.shared.env process.standardOutput = pipe process.standardError = Pipe() try process.run() From d095517882d3a07300849e07d4668917bbf3693f Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Tue, 7 Apr 2026 17:40:49 -0500 Subject: [PATCH 2/2] Bundle crow CLI inside Crow.app for release builds The bundle script only copied CrowApp into Contents/MacOS/, leaving the crow CLI binary behind. The app needs the CLI for hook event delivery but could not find it when launched from Finder. The existing findCrowBinary() already checks the same directory as the running executable, so placing crow alongside CrowApp makes it work. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/bundle.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 82388f9..9cb9cfb 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -41,13 +41,19 @@ if [ ! -f "$BUILD_DIR/CrowApp" ]; then exit 1 fi +if [ ! -f "$BUILD_DIR/crow" ]; then + echo "ERROR: CLI binary not found at $BUILD_DIR/crow" + exit 1 +fi + echo "==> Creating app bundle (v$VERSION)..." rm -rf "$APP_DIR" mkdir -p "$APP_DIR/Contents/MacOS" mkdir -p "$APP_DIR/Contents/Resources" -# Copy binary +# Copy binaries cp "$BUILD_DIR/CrowApp" "$APP_DIR/Contents/MacOS/" +cp "$BUILD_DIR/crow" "$APP_DIR/Contents/MacOS/" # Copy Ghostty resources if available if [ -d "$FRAMEWORKS_DIR/ghostty-resources" ]; then