diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index 5773d1a..9b2ad3f 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -78,6 +78,9 @@ public final class AppState { /// Whether the VS Code `code` CLI is available on this system. public var vsCodeAvailable: Bool = false + /// Runtime dependencies that were not found at startup (e.g., "gh", "git", "claude"). + public var missingDependencies: [String] = [] + /// Terminal readiness state per terminal ID. public var terminalReadiness: [UUID: TerminalReadiness] = [:] diff --git a/Packages/CrowCore/Sources/CrowCore/Validation.swift b/Packages/CrowCore/Sources/CrowCore/Validation.swift new file mode 100644 index 0000000..80c5668 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Validation.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Shared validation helpers used by the app and socket server. +public enum Validation { + /// Maximum allowed length for session names. + public static let maxSessionNameLength = 256 + + /// Check whether a path is within the given root directory (prevents path traversal). + public static func isPathWithinRoot(_ path: String, root: String) -> Bool { + let realPath = URL(fileURLWithPath: path).standardizedFileURL.path + let realRoot = URL(fileURLWithPath: root).standardizedFileURL.path + return realPath.hasPrefix(realRoot + "/") || realPath == realRoot + } + + /// Validate a session name contains no control characters and is within length limits. + public static func isValidSessionName(_ name: String) -> Bool { + !name.isEmpty + && name.count <= maxSessionNameLength + && !name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + } +} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppLifecycleTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppLifecycleTests.swift new file mode 100644 index 0000000..b8d9387 --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppLifecycleTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import CrowCore + +// MARK: - Validation Tests + +@Test func validSessionName() { + #expect(Validation.isValidSessionName("my-session")) + #expect(Validation.isValidSessionName("feature/crow-123-fix")) + #expect(Validation.isValidSessionName("a")) + #expect(Validation.isValidSessionName(String(repeating: "x", count: 256))) +} + +@Test func invalidSessionName_empty() { + #expect(!Validation.isValidSessionName("")) +} + +@Test func invalidSessionName_tooLong() { + #expect(!Validation.isValidSessionName(String(repeating: "x", count: 257))) +} + +@Test func invalidSessionName_controlChars() { + #expect(!Validation.isValidSessionName("hello\u{0000}world")) + #expect(!Validation.isValidSessionName("line\nbreak")) + #expect(!Validation.isValidSessionName("tab\there")) +} + +@Test func pathWithinRoot_normalPaths() { + #expect(Validation.isPathWithinRoot("/Users/dev/project/file.txt", root: "/Users/dev")) + #expect(Validation.isPathWithinRoot("/Users/dev/project", root: "/Users/dev")) + #expect(Validation.isPathWithinRoot("/Users/dev", root: "/Users/dev")) +} + +@Test func pathWithinRoot_rejectsOutsidePaths() { + #expect(!Validation.isPathWithinRoot("/Users/other/file.txt", root: "/Users/dev")) + #expect(!Validation.isPathWithinRoot("/etc/passwd", root: "/Users/dev")) +} + +@Test func pathWithinRoot_traversalAttempt() { + // ".." traversal should be resolved and rejected + #expect(!Validation.isPathWithinRoot("/Users/dev/../other/file.txt", root: "/Users/dev")) + #expect(!Validation.isPathWithinRoot("/Users/dev/project/../../etc/passwd", root: "/Users/dev")) +} + +@Test func pathWithinRoot_prefixTrick() { + // "/Users/devious" should NOT match root "/Users/dev" (prefix boundary check) + #expect(!Validation.isPathWithinRoot("/Users/devious/file.txt", root: "/Users/dev")) +} diff --git a/Packages/CrowPersistence/Package.swift b/Packages/CrowPersistence/Package.swift index d2add91..26350ff 100644 --- a/Packages/CrowPersistence/Package.swift +++ b/Packages/CrowPersistence/Package.swift @@ -12,6 +12,6 @@ let package = Package( ], targets: [ .target(name: "CrowPersistence", dependencies: ["CrowCore"]), - .testTarget(name: "CrowPersistenceTests", dependencies: ["CrowPersistence"]), + .testTarget(name: "CrowPersistenceTests", dependencies: ["CrowPersistence", "CrowCore"]), ] ) diff --git a/Packages/CrowPersistence/Sources/CrowPersistence/AppSupportDirectory.swift b/Packages/CrowPersistence/Sources/CrowPersistence/AppSupportDirectory.swift index 3bfaf13..95adcec 100644 --- a/Packages/CrowPersistence/Sources/CrowPersistence/AppSupportDirectory.swift +++ b/Packages/CrowPersistence/Sources/CrowPersistence/AppSupportDirectory.swift @@ -13,8 +13,12 @@ enum AppSupportDirectory { let oldDir = appSupport.appendingPathComponent("rm-ai-ide", isDirectory: true) if !FileManager.default.fileExists(atPath: crowDir.path), FileManager.default.fileExists(atPath: oldDir.path) { - try? FileManager.default.copyItem(at: oldDir, to: crowDir) - NSLog("[AppSupportDirectory] Migrated data from rm-ai-ide to crow") + do { + try FileManager.default.copyItem(at: oldDir, to: crowDir) + NSLog("[AppSupportDirectory] Migrated data from rm-ai-ide to crow") + } catch { + NSLog("[AppSupportDirectory] Failed to migrate rm-ai-ide data: %@", error.localizedDescription) + } } return crowDir }() diff --git a/Packages/CrowPersistence/Sources/CrowPersistence/ConfigStore.swift b/Packages/CrowPersistence/Sources/CrowPersistence/ConfigStore.swift index f34d61c..c0a9a40 100644 --- a/Packages/CrowPersistence/Sources/CrowPersistence/ConfigStore.swift +++ b/Packages/CrowPersistence/Sources/CrowPersistence/ConfigStore.swift @@ -23,6 +23,9 @@ public final class ConfigStore: Sendable { !path.isEmpty else { return nil } + if !FileManager.default.fileExists(atPath: path) { + NSLog("[ConfigStore] devRoot path does not exist on disk: %@", path) + } return path } diff --git a/Packages/CrowUI/Sources/CrowUI/SetupWizardView.swift b/Packages/CrowUI/Sources/CrowUI/SetupWizardView.swift index 7586bda..15dcc59 100644 --- a/Packages/CrowUI/Sources/CrowUI/SetupWizardView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SetupWizardView.swift @@ -11,14 +11,14 @@ public struct SetupWizardView: View { @State private var isAddingWorkspace = false @State private var errorMessage: String? - /// Called when setup completes with devRoot and config. - public var onComplete: ((String, AppConfig) -> Void)? + /// Called when setup completes with devRoot and config. Returns an error message on failure. + public var onComplete: ((String, AppConfig) -> String?)? /// Called if user wants to import from existing CMUX config. public var onImportCMUX: (() -> (devRoot: String, config: AppConfig)?)? public init( - onComplete: ((String, AppConfig) -> Void)? = nil, + onComplete: ((String, AppConfig) -> String?)? = nil, onImportCMUX: (() -> (devRoot: String, config: AppConfig)?)? = nil ) { self.onComplete = onComplete @@ -210,6 +210,8 @@ public struct SetupWizardView: View { private func completeSetup() { let config = AppConfig(workspaces: workspaces) - onComplete?(devRoot, config) + if let error = onComplete?(devRoot, config) { + errorMessage = error + } } } diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 6544d10..0f77902 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -57,7 +57,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSApp.activate(ignoringOtherApps: true) } - private func completeSetup(devRoot: String, config: AppConfig) { + private func completeSetup(devRoot: String, config: AppConfig) -> String? { do { // Save devRoot pointer try ConfigStore.saveDevRoot(devRoot) @@ -73,8 +73,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.devRoot = devRoot self.appConfig = config launchMainApp() + return nil } catch { - NSLog("Setup failed: \(error)") + NSLog("[Crow] Setup failed: %@", error.localizedDescription) + return "Setup failed: \(error.localizedDescription)" } } @@ -84,15 +86,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let devRoot else { return } // Initialize libghostty + NSLog("[Crow] Initializing Ghostty") GhosttyApp.shared.initialize() // Load config let config = appConfig ?? ConfigStore.loadConfig(devRoot: devRoot) ?? AppConfig() self.appConfig = config + NSLog("[Crow] Config loaded (workspaces: %d)", config.workspaces.count) // Update skills and CLAUDE.md on every launch let scaffolder = Scaffolder(devRoot: devRoot) - try? scaffolder.scaffold(workspaceNames: config.workspaces.map(\.name)) + do { + try scaffolder.scaffold(workspaceNames: config.workspaces.map(\.name)) + } catch { + NSLog("[Crow] Scaffold update failed: %@", error.localizedDescription) + } // Initialize persistence let store = JSONStore() @@ -103,28 +111,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate { service.hydrateState() service.wireTerminalReadiness() self.sessionService = service + NSLog("[Crow] Session state hydrated (%d sessions)", appState.sessions.count) // Detect orphaned worktrees (runs async, updates UI when done) Task { await service.detectOrphanedWorktrees() } // Check for runtime dependencies (non-blocking) - Task.detached { - let tools = ["gh", "git", "claude"] - 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) + 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) } - } catch { - NSLog("[Crow] Could not check for %@: %@", tool, error.localizedDescription) } + return result + }.value + if !missing.isEmpty { + appState.missingDependencies = missing } } @@ -217,6 +235,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Start socket server startSocketServer(store: store, devRoot: devRoot) + NSLog("[Crow] Main app launch complete — creating window") + // Create main window let contentView = MainContentView(appState: appState) let hostingView = NSHostingView(rootView: contentView) @@ -231,8 +251,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { defer: false ) mainWindow.title = "Crow" + mainWindow.minSize = NSSize(width: 800, height: 500) mainWindow.contentView = hostingView mainWindow.center() + // Set autosave name after center() so a saved frame takes precedence mainWindow.setFrameAutosaveName("MainWindow") mainWindow.makeKeyAndOrderFront(nil) self.window = mainWindow @@ -349,20 +371,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Socket Server /// Maximum allowed length for session names. - private nonisolated static let maxSessionNameLength = 256 + private nonisolated static let maxSessionNameLength = Validation.maxSessionNameLength /// Validate that a path is within the configured devRoot to prevent path traversal. private nonisolated static func isPathWithinDevRoot(_ path: String, devRoot: String) -> Bool { - let realPath = (path as NSString).standardizingPath - let realRoot = (devRoot as NSString).standardizingPath - return realPath.hasPrefix(realRoot + "/") || realPath == realRoot + Validation.isPathWithinRoot(path, root: devRoot) } /// Validate a session name contains no control characters and is within length limits. private nonisolated static func isValidSessionName(_ name: String) -> Bool { - !name.isEmpty - && name.count <= maxSessionNameLength - && !name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + Validation.isValidSessionName(name) } private func startSocketServer(store: JSONStore, devRoot: String) { @@ -854,10 +872,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } func applicationWillTerminate(_ notification: Notification) { + NSLog("[Crow] Application terminating — beginning cleanup") issueTracker?.stop() sessionService?.persistState() + // Persist config in case settings changed during this session + if let devRoot, let appConfig { + try? ConfigStore.saveConfig(appConfig, devRoot: devRoot) + } socketServer?.stop() GhosttyApp.shared.shutdown() + NSLog("[Crow] Cleanup complete") } // MARK: - Claude Binary Resolution diff --git a/Sources/Crow/App/Scaffolder.swift b/Sources/Crow/App/Scaffolder.swift index 6bfe057..882b90f 100644 --- a/Sources/Crow/App/Scaffolder.swift +++ b/Sources/Crow/App/Scaffolder.swift @@ -30,13 +30,13 @@ struct Scaffolder { let range = existing.range(of: "## Known Issues / Corrections") { // Preserve user corrections, replace everything above var userCorrections = String(existing[range.lowerBound...]) - // Sanitize stale references from pre-rename installations + // Sanitize stale references from pre-rename installations (case-insensitive) userCorrections = userCorrections - .replacingOccurrences(of: "ride ", with: "crow ") - .replacingOccurrences(of: "`ride`", with: "`crow`") - .replacingOccurrences(of: "ride.sock", with: "crow.sock") - .replacingOccurrences(of: "/ride-workspace", with: "/crow-workspace") - .replacingOccurrences(of: "rm-ai-ide", with: "Crow") + .replacingOccurrences(of: "ride ", with: "crow ", options: .caseInsensitive) + .replacingOccurrences(of: "`ride`", with: "`crow`", options: .caseInsensitive) + .replacingOccurrences(of: "ride.sock", with: "crow.sock", options: .caseInsensitive) + .replacingOccurrences(of: "/ride-workspace", with: "/crow-workspace", options: .caseInsensitive) + .replacingOccurrences(of: "rm-ai-ide", with: "Crow", options: .caseInsensitive) let templateBase: String if let templateRange = template.range(of: "## Known Issues / Corrections") { templateBase = String(template[..