diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index 9192b291..8950ab2b 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -233,6 +233,12 @@ public final class AppState { /// Set by `IssueTracker` when polling is suspended; cleared on next success. public var rateLimitWarning: String? + /// Non-fatal warning surfaced in Settings when the per-launch + /// `corveil skill install` run fails (CROW-482). `nil` means the install + /// either succeeded or wasn't attempted (no path configured). Set by + /// `AppDelegate.launchMainApp` from the `Scaffolder` result. + public var corveilSkillInstallWarning: String? + /// Terminal readiness state per terminal ID. public var terminalReadiness: [UUID: TerminalReadiness] = [:] diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index 6bd8efac..ca1ab9e1 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -406,11 +406,22 @@ public struct ConfigDefaults: Codable, Sendable, Equatable { public var excludeReviewRepos: [String] public var excludeTicketRepos: [String] public var ignoreReviewLabels: [String] - /// Explicit absolute-path overrides for `CodingAgent` binary discovery, - /// keyed by `AgentKind.rawValue` (e.g. `"codex"`, `"cursor"`, `"claude-code"`). - /// Consulted before the PATH walk in `CodingAgent.findBinary()` — set this - /// when discovery doesn't find your install for any reason (exotic Node - /// manager, sandboxed PATH, etc.). See CROW-484. + /// Absolute-path overrides for executable binaries, keyed by tool name. + /// + /// Serves two callers that share the same map shape: + /// - **Agent binary discovery** (CROW-484): keyed by `AgentKind.rawValue` + /// (`"codex"`, `"cursor"`, `"claude-code"`). `CodingAgent.findBinary()` + /// consults this map before walking PATH — set this when discovery + /// doesn't find your install (exotic Node manager, sandboxed PATH, etc.). + /// - **External tool installers** (CROW-482): keyed by tool name (e.g. + /// `"corveil"`) and used by `Scaffolder` to run each tool's own skill + /// installer on launch. The Settings UI currently exposes only the + /// `corveil` slot; the map shape is intentionally generic so future + /// tools (soulstone, tanzanite, …) extend the same field without a + /// schema change. + /// + /// Agent keys (`claude-code`, `codex`, `cursor`) and tool keys (`corveil`, + /// …) don't overlap, so the two callers coexist in one map. public var binaries: [String: String] /// Characters that are invalid in git ref names (see `git check-ref-format`). diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift index 689dd4c8..7d384aa9 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift @@ -485,6 +485,31 @@ import Testing #expect(config.defaults.ignoreReviewLabels.isEmpty) } +// MARK: - defaults.binaries (CROW-482) + +@Test func binariesRoundTrip() throws { + let config = AppConfig( + defaults: ConfigDefaults(binaries: [ + "corveil": "/Users/jane/dev/corveil/corveil", + "soulstone": "/usr/local/bin/soulstone", + ]) + ) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + #expect(decoded.defaults.binaries["corveil"] == "/Users/jane/dev/corveil/corveil") + #expect(decoded.defaults.binaries["soulstone"] == "/usr/local/bin/soulstone") +} + +@Test func binariesDefaultsEmptyWhenKeyMissing() throws { + // Configs written before CROW-482 don't have `binaries` — they must still + // decode cleanly with an empty map (forward compatibility). + let json = """ + {"defaults": {"provider": "github", "cli": "gh", "branchPrefix": "feature/", "excludeDirs": []}} + """.data(using: .utf8)! + let config = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(config.defaults.binaries.isEmpty) +} + // MARK: - AI gateway (CROW-402) @Test func workspaceGatewayRoundTrip() throws { diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index c74a9f92..76969305 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -18,6 +18,12 @@ public struct SettingsView: View { @State private var editingJob: JobConfig? /// A pre-filled copy of a job, presented in the form (create mode) to duplicate it. @State private var duplicatingJob: JobConfig? + /// Live result of the most recent corveil "Verify" run. `nil` until the + /// user has clicked Verify at least once this Settings session. Starts + /// with `✓` on success, `✗` on failure (CROW-482). + @State private var corveilVerifyResult: String? + /// True while the Verify button's subprocess is in flight. + @State private var corveilVerifying: Bool = false public var onSave: ((String, AppConfig) -> Void)? public var onRescaffold: ((String) -> Void)? @@ -181,6 +187,23 @@ public struct SettingsView: View { } } + @ViewBuilder + private var corveilSkillWarningBanner: some View { + if let warning = appState.corveilSkillInstallWarning { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(warning) + .font(.caption) + .textSelection(.enabled) + Spacer() + } + .padding(10) + .background(Color.orange.opacity(0.12)) + .cornerRadius(6) + } + } + private var generalTab: some View { Form { if appState.githubScopeWarning != nil { @@ -192,6 +215,9 @@ public struct SettingsView: View { if appState.rateLimitWarning != nil { Section { rateLimitWarningBanner } } + if appState.corveilSkillInstallWarning != nil { + Section { corveilSkillWarningBanner } + } Section("Development Root") { HStack { TextField("Path", text: $devRoot) @@ -236,6 +262,37 @@ public struct SettingsView: View { .foregroundStyle(.secondary) } + Section("Corveil CLI") { + HStack { + TextField("Path to corveil binary", text: corveilBinding) + .textFieldStyle(.roundedBorder) + .onSubmit { save() } + Button("Browse...") { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + if panel.runModal() == .OK, let url = panel.url { + corveilBinding.wrappedValue = url.path + save() + // Clear stale verify result — it's about a previous binary. + corveilVerifyResult = nil + } + } + Button(corveilVerifying ? "Verifying…" : "Verify") { verifyCorveil() } + .disabled(corveilBinding.wrappedValue.isEmpty || corveilVerifying) + } + if let result = corveilVerifyResult { + Text(result) + .font(.caption) + .foregroundStyle(result.hasPrefix("✓") ? .green : .orange) + .textSelection(.enabled) + } + Text("On launch, Crow runs `corveil skill install --path` to install the `/query-corveil` slash command into this devRoot. Leave blank to skip.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Sidebar") { Toggle("Hide session details", isOn: $config.sidebar.hideSessionDetails) .onChange(of: config.sidebar.hideSessionDetails) { _, _ in save() } @@ -537,6 +594,112 @@ public struct SettingsView: View { onSave?(devRoot, config) } + /// Two-way binding into `config.defaults.binaries["corveil"]` that treats + /// an empty string as "unset" (so the map doesn't accumulate stale empty + /// entries when the user clears the field). Trimming happens on commit so + /// pasted paths with stray whitespace are normalized. + private var corveilBinding: Binding { + Binding( + get: { config.defaults.binaries["corveil"] ?? "" }, + set: { newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + config.defaults.binaries.removeValue(forKey: "corveil") + } else { + config.defaults.binaries["corveil"] = trimmed + } + } + ) + } + + /// Run ` --version` and surface the result. Lives off the + /// main actor so the spinning UI doesn't block. Truncates noisy output + /// to keep the inline result line readable. + private func verifyCorveil() { + let path = corveilBinding.wrappedValue + guard !path.isEmpty else { return } + corveilVerifying = true + corveilVerifyResult = nil + Task.detached { + let result = SettingsView.runCorveilVersion(at: path) + await MainActor.run { + corveilVerifyResult = result + corveilVerifying = false + } + } + } + + /// Pure helper for `verifyCorveil` — easier to reason about off the main + /// actor and trivially testable. Returns a single-line summary suitable + /// for inline display. + /// + /// Uses `proc.waitUntilExit()` (with a `TimeoutWatchdog` SIGTERM'ing the + /// child if it hangs) rather than a polling loop on `proc.isRunning`. + /// `waitUntilExit` is the only way to deterministically trigger + /// Foundation's pipe-write-FD cleanup; a polling loop leaves Foundation's + /// internal copy of the writeFD open and the post-exit reads either hang + /// (with a background drain) or return empty (Foundation closes its copy + /// on its own internal schedule). Once `waitUntilExit` returns, both + /// pipe writers (child + Foundation) have closed, so a synchronous + /// `readToEnd()` on each pipe returns immediately with the data. + nonisolated static func runCorveilVersion(at path: String) -> String { + let fm = FileManager.default + guard fm.isExecutableFile(atPath: path) else { + return "✗ Not executable: \(path)" + } + let proc = Process() + proc.executableURL = URL(fileURLWithPath: path) + proc.arguments = ["--version"] + let outPipe = Pipe() + let errPipe = Pipe() + proc.standardOutput = outPipe + proc.standardError = errPipe + + do { + try proc.run() + } catch { + return "✗ Could not launch: \(error.localizedDescription)" + } + + // Watchdog: SIGTERM after `verifyTimeout` so a hung binary unblocks + // `waitUntilExit` below. The watchdog also records the timeout so we + // can distinguish a normal exit-N from a wall-clock kill. + let watchdog = TimeoutWatchdog(deadline: verifyTimeout, proc: proc) + watchdog.start() + proc.waitUntilExit() + let timedOut = watchdog.cancel() + + let outStr = Self.readAll(outPipe) + let errStr = Self.readAll(errPipe) + let combined = [outStr, errStr].filter { !$0.isEmpty }.joined(separator: " — ") + let snippet = combined.split(separator: "\n").first.map(String.init) ?? combined + + if timedOut { + return "✗ Timed out after \(Int(verifyTimeout))s — binary may be hung." + } + if proc.terminationStatus == 0 { + return snippet.isEmpty ? "✓ Verified" : "✓ \(snippet)" + } + let detail = snippet.isEmpty ? "exit code \(proc.terminationStatus)" : snippet + return "✗ \(detail)" + } + + /// Synchronously read all bytes from a pipe's read end after the child + /// has exited (so `readToEnd()` returns immediately). Trims whitespace + /// and returns a UTF-8 string. + nonisolated static func readAll(_ pipe: Pipe) -> String { + let data = (try? pipe.fileHandleForReading.readToEnd()) ?? Data() + return String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + + /// Wall-clock budget for the "Verify" subprocess. Matches the install + /// path's `Scaffolder.corveilInstallTimeout` — a corveil that hangs on + /// `--version` is bounded to the same 5s window as one that hangs on + /// `skill install`. The Task wrapper runs off the main actor so the UI + /// stays responsive while this wait elapses. + nonisolated static let verifyTimeout: TimeInterval = 5.0 + /// One per-action agent picker (Coding/Reviews/Jobs). "Use default" /// removes the override; selecting a concrete agent writes the /// `config.agentsByKind` entry. Disabled until a second agent is @@ -574,3 +737,45 @@ public struct SettingsView: View { } } } + +/// SIGTERM a `Process` after `deadline` seconds if it's still running. Used +/// to bound `waitUntilExit` without a polling loop (a polling loop on +/// `proc.isRunning` defeats Foundation's pipe-write-FD cleanup, which only +/// runs as part of `waitUntilExit`). `cancel()` stops the timer and reports +/// whether it had already fired. +/// +/// `@unchecked Sendable` is sound here: every member is either immutable +/// (`proc`, `timer`) or guarded by `lock` (`didFire`). `proc.terminate()` +/// and `proc.isRunning` are thread-safe per Foundation. +fileprivate final class TimeoutWatchdog: @unchecked Sendable { + private let proc: Process + private let timer: DispatchSourceTimer + private let lock = NSLock() + private var didFire = false + + init(deadline: TimeInterval, proc: Process) { + self.proc = proc + self.timer = DispatchSource.makeTimerSource(queue: .global(qos: .userInitiated)) + self.timer.schedule(deadline: .now() + deadline) + } + + func start() { + timer.setEventHandler { [weak self] in + guard let self else { return } + self.lock.lock() + self.didFire = true + self.lock.unlock() + if self.proc.isRunning { + self.proc.terminate() + } + } + timer.resume() + } + + /// Cancel the watchdog. Returns true if it had already fired (timeout). + func cancel() -> Bool { + timer.cancel() + lock.lock(); defer { lock.unlock() } + return didFire + } +} diff --git a/Packages/CrowUI/Tests/CrowUITests/CrowUITests.swift b/Packages/CrowUI/Tests/CrowUITests/CrowUITests.swift index 70b46d6b..d9aed106 100644 --- a/Packages/CrowUI/Tests/CrowUITests/CrowUITests.swift +++ b/Packages/CrowUI/Tests/CrowUITests/CrowUITests.swift @@ -4,6 +4,41 @@ import Testing @testable import CrowCore @testable import CrowUI +// MARK: - SettingsView.runCorveilVersion (CROW-482) + +@Suite("SettingsView.runCorveilVersion") +struct RunCorveilVersionTests { + + @Test func nonExecutablePathReturnsError() { + let result = SettingsView.runCorveilVersion(at: "/this/path/definitely/does/not/exist") + #expect(result.hasPrefix("✗ Not executable:")) + } + + @Test func emptyPathReturnsError() { + // isExecutableFile returns false for "", so the gate still trips. + let result = SettingsView.runCorveilVersion(at: "") + #expect(result.hasPrefix("✗ Not executable:")) + } + + @Test func zeroExitWithNoOutputFormatsAsVerified() { + // /usr/bin/true ignores arguments and exits 0 with no stdout/stderr. + let result = SettingsView.runCorveilVersion(at: "/usr/bin/true") + #expect(result == "✓ Verified") + } + + @Test func nonZeroExitWithNoOutputSurfacesExitCode() { + // /usr/bin/false ignores arguments and exits 1 with no stdout/stderr. + let result = SettingsView.runCorveilVersion(at: "/usr/bin/false") + #expect(result == "✗ exit code 1") + } + + @Test func zeroExitWithStdoutShowsSnippet() { + // /bin/echo --version → exits 0, prints "--version\n" to stdout. + let result = SettingsView.runCorveilVersion(at: "/bin/echo") + #expect(result == "✓ --version") + } +} + // MARK: - SessionStatus Display Names @Test func sessionStatusDisplayNames() { diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 3db5200f..8cfba50c 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -298,11 +298,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Save devRoot pointer try ConfigStore.saveDevRoot(devRoot) - // Scaffold directory structure + // Scaffold directory structure. Don't run the corveil skill install + // here — `launchMainApp()` runs `scaffold(...)` again immediately + // below, so doing it twice on first-time setup just fires the + // subprocess twice with the second result winning. let scaffolder = Scaffolder(devRoot: devRoot) - try scaffolder.scaffold( + _ = try scaffolder.scaffold( workspaceNames: config.workspaces.map(\.name), - managerAgentKind: config.agentKind(for: .manager) + managerAgentKind: config.agentKind(for: .manager), + corveilBinaryPath: nil ) // Save config @@ -403,10 +407,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Update skills and CLAUDE.md on every launch let scaffolder = Scaffolder(devRoot: devRoot) do { - try scaffolder.scaffold( + let result = try scaffolder.scaffold( workspaceNames: config.workspaces.map(\.name), - managerAgentKind: config.agentKind(for: .manager) + managerAgentKind: config.agentKind(for: .manager), + corveilBinaryPath: config.defaults.binaries["corveil"] ) + appState.corveilSkillInstallWarning = result.warning } catch { NSLog("[Crow] Scaffold update failed: %@", error.localizedDescription) } @@ -1068,10 +1074,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { onRescaffold: { [weak self] devRoot in let scaffolder = Scaffolder(devRoot: devRoot) let cfg = self?.appConfig - try? scaffolder.scaffold( - workspaceNames: cfg?.workspaces.map(\.name) ?? [], - managerAgentKind: cfg?.agentKind(for: .manager) ?? .claudeCode - ) + do { + let result = try scaffolder.scaffold( + workspaceNames: cfg?.workspaces.map(\.name) ?? [], + managerAgentKind: cfg?.agentKind(for: .manager) ?? .claudeCode, + corveilBinaryPath: cfg?.defaults.binaries["corveil"] + ) + // Always assign — clears a stale warning from a prior + // launch when the install now succeeds (`result.warning` + // is `nil` on success or no-op). + self?.appState.corveilSkillInstallWarning = result.warning + } catch { + NSLog("[Crow] Re-scaffold failed: %@", error.localizedDescription) + // Replace any existing corveil-install banner with a + // fresh "rescaffold failed" message so the user isn't + // looking at a stale message from a prior launch. + self?.appState.corveilSkillInstallWarning = + "Re-scaffold failed: \(error.localizedDescription)" + } } ) diff --git a/Sources/Crow/App/Scaffolder.swift b/Sources/Crow/App/Scaffolder.swift index b432c5ac..7be35a26 100644 --- a/Sources/Crow/App/Scaffolder.swift +++ b/Sources/Crow/App/Scaffolder.swift @@ -1,6 +1,14 @@ import CrowCore import Foundation +/// Outcome of a single `Scaffolder.scaffold(...)` run. `warning` is non-nil only +/// for non-fatal post-scaffold issues (today: a configured `corveil` binary +/// that's missing/non-executable or whose `skill install` returned non-zero). +/// Callers surface it via `AppState.corveilSkillInstallWarning`; never fatal. +struct ScaffoldResult { + var warning: String? +} + /// Creates the devRoot directory structure and copies bundled resources. struct Scaffolder { let devRoot: String @@ -10,7 +18,17 @@ struct Scaffolder { /// `managerAgentKind` drives `{{CROW_AGENT_DISPLAY_NAME}}` substitution in the /// dev-root skill bodies (issue #447). The Manager session is the consumer of /// these files, so its agent kind is the right one to bake in. - func scaffold(workspaceNames: [String], managerAgentKind: AgentKind = .claudeCode) throws { + /// + /// `corveilBinaryPath`, when set and executable, triggers a post-scaffold + /// `corveil skill install --path {devRoot}/.claude/commands/query-corveil.md` + /// so the embedded `/query-corveil` slash command stays in sync with the + /// user's locally-built corveil binary (CROW-482). Failures here are + /// non-fatal: they are returned as `ScaffoldResult.warning` and never + /// throw — the rest of the scaffold has already succeeded by that point. + @discardableResult + func scaffold(workspaceNames: [String], + managerAgentKind: AgentKind = .claudeCode, + corveilBinaryPath: String? = nil) throws -> ScaffoldResult { let fm = FileManager.default // Create devRoot @@ -113,8 +131,97 @@ struct Scaffolder { // Create prompts directory for crow-workspace prompt files let promptsDir = (claudeDir as NSString).appendingPathComponent("prompts") try fm.createDirectory(atPath: promptsDir, withIntermediateDirectories: true) + + // Re-install the embedded /query-corveil slash command from the + // user-configured corveil binary on every launch (CROW-482). Failure + // here is intentionally non-fatal — the rest of the scaffold is done. + let warning = installCorveilSkill(corveilBinaryPath) + return ScaffoldResult(warning: warning) } + /// Runs ` skill install --path {devRoot}/.claude/commands/query-corveil.md` + /// when the path is set and points at an executable. Returns a short + /// user-facing warning string on failure; `nil` on success or when the + /// feature is unconfigured (empty/nil path). + /// + /// `Scaffolder.scaffold(...)` runs on the main thread during + /// `applicationDidFinishLaunching`, so a hung corveil binary (wrong + /// executable, stdin prompt, etc.) would freeze app startup with no + /// window drawn yet. The hard wall-clock timeout bounds the worst case: + /// after `corveilInstallTimeout` seconds the process is sent SIGTERM and + /// the install reports a warning rather than blocking forever. + private func installCorveilSkill(_ corveilBinaryPath: String?) -> String? { + guard let path = corveilBinaryPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + return nil + } + let fm = FileManager.default + guard fm.isExecutableFile(atPath: path) else { + NSLog("[Scaffolder] corveil binary not executable: %@", path) + return "Corveil skill install skipped — binary at \(path) is missing or not executable. Check Settings → General → Corveil CLI." + } + + let commandsDir = (devRoot as NSString).appendingPathComponent(".claude/commands") + do { + try fm.createDirectory(atPath: commandsDir, withIntermediateDirectories: true) + } catch { + NSLog("[Scaffolder] could not create commands dir: %@", error.localizedDescription) + return "Corveil skill install failed — could not create .claude/commands directory." + } + let target = (commandsDir as NSString).appendingPathComponent("query-corveil.md") + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: path) + proc.arguments = ["skill", "install", "--path", target] + let stderrPipe = Pipe() + proc.standardError = stderrPipe + // Discard stdout explicitly. We don't surface corveil's diagnostic + // line, and routing to /dev/null is deadlock-proof — an undrained + // `Pipe()` would block the child if it ever wrote >64KB before exit. + proc.standardOutput = FileHandle.nullDevice + + do { + try proc.run() + } catch { + NSLog("[Scaffolder] corveil launch failed: %@", error.localizedDescription) + return "Corveil skill install failed — \(error.localizedDescription). Check path in Settings." + } + + // Watchdog: SIGTERM after `corveilInstallTimeout` so a hung binary + // unblocks `waitUntilExit`. The watchdog records the timeout so we + // can distinguish exit-N from wall-clock kill below. `waitUntilExit` + // (not a polling loop) is what triggers Foundation's pipe-write-FD + // cleanup, which is the only way the post-exit `readToEnd()` below + // reliably sees EOF. + let watchdog = ScaffolderTimeoutWatchdog(deadline: Self.corveilInstallTimeout, proc: proc) + watchdog.start() + proc.waitUntilExit() + let timedOut = watchdog.cancel() + + if timedOut { + NSLog("[Scaffolder] corveil skill install timed out after %.1fs", Self.corveilInstallTimeout) + return "Corveil skill install timed out after \(Int(Self.corveilInstallTimeout))s — binary may be hung. Check path in Settings." + } + if proc.terminationStatus != 0 { + let stderr = (try? stderrPipe.fileHandleForReading.readToEnd()) + .flatMap { String(data: $0, encoding: .utf8) }? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + NSLog("[Scaffolder] corveil skill install exit=%d stderr=%@", + proc.terminationStatus, stderr) + let detail = stderr.isEmpty ? "exit code \(proc.terminationStatus)" : stderr + return "Corveil skill install failed — \(detail). Check path in Settings." + } + NSLog("[Scaffolder] corveil skill installed at %@", target) + return nil + } + + /// Wall-clock budget for the per-launch `corveil skill install` run. Tight + /// because `Scaffolder.scaffold(...)` runs on the main thread before the + /// app window is shown — a hung corveil binary delays first paint by this + /// many seconds. 5s is generous for a local subprocess that only writes + /// one ~10KB file. + static let corveilInstallTimeout: TimeInterval = 5.0 + // MARK: - Bundled Templates /// The CLAUDE.md template bundled with the app. @@ -327,3 +434,44 @@ struct Scaffolder { return nil } } + +/// SIGTERM a `Process` after `deadline` seconds if it's still running. Used +/// to bound `waitUntilExit` without a polling loop (a polling loop on +/// `proc.isRunning` consumes the exit observation Foundation needs to run +/// its pipe-write-FD cleanup, so post-exit `readToEnd()` reads return empty). +/// Mirrors `SettingsView`'s `TimeoutWatchdog`; duplicated here because +/// Scaffolder and SettingsView live in different targets with no shared +/// private utility module today, and the helper is small enough that two +/// copies beat introducing a CrowCore type for two callers. +fileprivate final class ScaffolderTimeoutWatchdog: @unchecked Sendable { + private let proc: Process + private let timer: DispatchSourceTimer + private let lock = NSLock() + private var didFire = false + + init(deadline: TimeInterval, proc: Process) { + self.proc = proc + self.timer = DispatchSource.makeTimerSource(queue: .global(qos: .userInitiated)) + self.timer.schedule(deadline: .now() + deadline) + } + + func start() { + timer.setEventHandler { [weak self] in + guard let self else { return } + self.lock.lock() + self.didFire = true + self.lock.unlock() + if self.proc.isRunning { + self.proc.terminate() + } + } + timer.resume() + } + + /// Cancel the watchdog. Returns true if it had already fired (timeout). + func cancel() -> Bool { + timer.cancel() + lock.lock(); defer { lock.unlock() } + return didFire + } +}