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 38f620b..a8e2f7a 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -118,29 +118,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 dc6c6aa..bbf4457 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -355,6 +355,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() @@ -384,6 +385,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() diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 97d64ab..ca6ed17 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