Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]

Expand Down
21 changes: 21 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/Validation.swift
Original file line number Diff line number Diff line change
@@ -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) })
}
}
48 changes: 48 additions & 0 deletions Packages/CrowCore/Tests/CrowCoreTests/AppLifecycleTests.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
2 changes: 1 addition & 1 deletion Packages/CrowPersistence/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ let package = Package(
],
targets: [
.target(name: "CrowPersistence", dependencies: ["CrowCore"]),
.testTarget(name: "CrowPersistenceTests", dependencies: ["CrowPersistence"]),
.testTarget(name: "CrowPersistenceTests", dependencies: ["CrowPersistence", "CrowCore"]),
]
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
}()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
10 changes: 6 additions & 4 deletions Packages/CrowUI/Sources/CrowUI/SetupWizardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
74 changes: 49 additions & 25 deletions Sources/Crow/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)"
}
}

Expand All @@ -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()
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions Sources/Crow/App/Scaffolder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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[..<templateRange.lowerBound])
Expand Down Expand Up @@ -149,7 +149,11 @@ struct Scaffolder {
for _ in 0..<10 {
if FileManager.default.fileExists(atPath: dir.appendingPathComponent("Package.swift").path) {
let filePath = dir.appendingPathComponent(relativePath)
return try? String(contentsOf: filePath)
if let content = try? String(contentsOf: filePath) {
return content
}
NSLog("[Scaffolder] File not found at repo path: %@", filePath.path)
return nil
}
dir = dir.deletingLastPathComponent()
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/Crow/App/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ if let iconURL = Bundle.module.url(forResource: "AppIcon", withExtension: "png")
app.applicationIconImage = iconImage
}

let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev"
NSLog("[Crow] Starting Crow %@ (pid %d)", version, ProcessInfo.processInfo.processIdentifier)

let delegate = AppDelegate()
app.delegate = delegate
app.run()
Loading