From a4ca49ad5dad8551842f9f58ba9cf360019a0da4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:36:25 -0400 Subject: [PATCH 01/12] Add ForemanCore control protocol, copy provenance, and MCP intents Groundwork for the repo-cloning MCP: the transport-agnostic half of Foreman's control endpoint lives in Core so it can be unit-tested. - CopyProvenance (worktree|clone + parent repo + branch), persisted on RepoConfiguration and mirrored on Repo.provenance so a copy's origin survives and can be removed correctly. - Codable ControlRequest/ControlResponse + DTOs and a ControlRequestHandler mapping requests to ForemanServices; this is the wire contract foreman-mcp mirrors. - ForemanServices intents: describe(), adoptAndStartWorker(at:provenance:) (validates the path is a direct child of the scan dir), and removeCopy(at:) (stops + drains the worker before removal, gated on recorded provenance). - RepoCopyRemoving/SystemRepoCopyRemover seam (git worktree remove / trash a clone), injected so tests never touch the filesystem. - ControlError messages via the generated-symbol catalog. Covered by new Swift Testing suites using temp git repos and a recording remover; failures surface honestly rather than being swallowed. Co-authored-by: Cursor --- .../ForemanCore/Sources/ControlProtocol.swift | 275 ++++++++++++++++++ .../Sources/ControlRequestHandler.swift | 39 +++ .../ForemanCore/Sources/CopyProvenance.swift | 29 ++ .../ForemanCore/Sources/ForemanServices.swift | 134 +++++++++ Foreman/ForemanCore/Sources/Repo.swift | 20 +- .../ForemanCore/Sources/RepoCopyRemover.swift | 79 +++++ .../Sources/Resources/Localizable.xcstrings | 84 ++++++ .../Sources/WorkerConfigStore.swift | 19 +- .../Tests/ControlProtocolTests.swift | 147 ++++++++++ .../Tests/ControlRequestHandlerTests.swift | 101 +++++++ .../Tests/CopyProvenanceTests.swift | 54 ++++ .../Tests/ForemanCoreTestSupport.swift | 106 ++++++- .../Tests/ForemanServicesTests.swift | 167 +++++++++++ .../Tests/RepoCopyRemoverTests.swift | 79 +++++ .../ForemanCore/Tests/RepoSectionTests.swift | 1 + Foreman/ForemanCore/Tests/RepoTests.swift | 1 + 16 files changed, 1329 insertions(+), 6 deletions(-) create mode 100644 Foreman/ForemanCore/Sources/ControlProtocol.swift create mode 100644 Foreman/ForemanCore/Sources/ControlRequestHandler.swift create mode 100644 Foreman/ForemanCore/Sources/CopyProvenance.swift create mode 100644 Foreman/ForemanCore/Sources/RepoCopyRemover.swift create mode 100644 Foreman/ForemanCore/Tests/ControlProtocolTests.swift create mode 100644 Foreman/ForemanCore/Tests/ControlRequestHandlerTests.swift create mode 100644 Foreman/ForemanCore/Tests/CopyProvenanceTests.swift create mode 100644 Foreman/ForemanCore/Tests/RepoCopyRemoverTests.swift diff --git a/Foreman/ForemanCore/Sources/ControlProtocol.swift b/Foreman/ForemanCore/Sources/ControlProtocol.swift new file mode 100644 index 00000000..a5653ff9 --- /dev/null +++ b/Foreman/ForemanCore/Sources/ControlProtocol.swift @@ -0,0 +1,275 @@ +import Foundation + +// The wire contract for Foreman's local control socket: newline-delimited +// JSON, one ``ControlRequest`` per line, one ``ControlResponse`` back. +// +// The DTOs use plain `String` ids/paths (not ``RepoID``/`URL`) so the +// hand-written TypeScript client (`Foreman/foreman-mcp`) can speak the same +// JSON without guessing Swift's encoding of wrapper types. + +// MARK: - Requests + +/// A command sent to Foreman over the control socket. +public enum ControlRequest: Sendable, Equatable { + /// Report the scan directory and every known repo with its worker state. + case describe + /// Adopt the copy at `path` (recording its provenance) and start its + /// worker. `path` must be an immediate subdirectory of the scan directory. + case adopt(path: String, provenance: CopyProvenanceDTO) + /// Stop the worker for the copy at `path` and remove it from disk. Only + /// copies Foreman created (with recorded provenance) can be removed. + case removeCopy(path: String) +} + +extension ControlRequest: Codable { + private enum CodingKeys: String, CodingKey { + case command, path, provenance + } + + private enum Command: String, Codable { + case describe, adopt, removeCopy + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Command.self, forKey: .command) { + case .describe: + self = .describe + case .adopt: + self = try .adopt( + path: container.decode(String.self, forKey: .path), + provenance: container.decode(CopyProvenanceDTO.self, forKey: .provenance), + ) + case .removeCopy: + self = try .removeCopy(path: container.decode(String.self, forKey: .path)) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .describe: + try container.encode(Command.describe, forKey: .command) + case let .adopt(path, provenance): + try container.encode(Command.adopt, forKey: .command) + try container.encode(path, forKey: .path) + try container.encode(provenance, forKey: .provenance) + case let .removeCopy(path): + try container.encode(Command.removeCopy, forKey: .command) + try container.encode(path, forKey: .path) + } + } +} + +// MARK: - Responses + +/// Foreman's reply to a ``ControlRequest``. On the wire, successes carry +/// `"ok": true` plus a `"kind"` discriminator; failures carry `"ok": false` +/// and an `"error"` message. +public enum ControlResponse: Sendable, Equatable { + case describe(DescribeResultDTO) + case repo(RepoStatusDTO) + case removed(path: String) + case failure(message: String) +} + +extension ControlResponse: Codable { + private enum CodingKeys: String, CodingKey { + case ok, kind, describe, repo, path, error + } + + private enum Kind: String, Codable { + case describe, repo, removed + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard try container.decode(Bool.self, forKey: .ok) else { + self = try .failure(message: container.decode(String.self, forKey: .error)) + return + } + switch try container.decode(Kind.self, forKey: .kind) { + case .describe: + self = try .describe(container.decode(DescribeResultDTO.self, forKey: .describe)) + case .repo: + self = try .repo(container.decode(RepoStatusDTO.self, forKey: .repo)) + case .removed: + self = try .removed(path: container.decode(String.self, forKey: .path)) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .describe(result): + try container.encode(true, forKey: .ok) + try container.encode(Kind.describe, forKey: .kind) + try container.encode(result, forKey: .describe) + case let .repo(status): + try container.encode(true, forKey: .ok) + try container.encode(Kind.repo, forKey: .kind) + try container.encode(status, forKey: .repo) + case let .removed(path): + try container.encode(true, forKey: .ok) + try container.encode(Kind.removed, forKey: .kind) + try container.encode(path, forKey: .path) + case let .failure(message): + try container.encode(false, forKey: .ok) + try container.encode(message, forKey: .error) + } + } +} + +// MARK: - DTOs + +/// A copy's provenance in wire form: `kind` is `"worktree"` or `"clone"`, +/// `parentRepoID` is the parent repo's path. +public struct CopyProvenanceDTO: Codable, Equatable, Sendable { + public var kind: String + public var parentRepoID: String + public var branch: String + + public init(kind: String, parentRepoID: String, branch: String) { + self.kind = kind + self.parentRepoID = parentRepoID + self.branch = branch + } + + public init(_ provenance: CopyProvenance) { + self.init( + kind: provenance.kind.rawValue, + parentRepoID: provenance.parentRepoID.rawValue, + branch: provenance.branch, + ) + } + + /// Parses this wire value into a ``CopyProvenance``, throwing + /// ``ControlError/invalidProvenanceKind(_:)`` for an unrecognized `kind`. + public func model() throws -> CopyProvenance { + guard let kind = CopyProvenance.Kind(rawValue: kind) else { + throw ControlError.invalidProvenanceKind(kind) + } + return CopyProvenance( + kind: kind, + parentRepoID: RepoID(rawValue: parentRepoID), + branch: branch, + ) + } +} + +/// One repository's status as reported over the control socket. +public struct RepoStatusDTO: Codable, Equatable, Sendable { + public var id: String + public var name: String + public var path: String + public var enabled: Bool + /// `"stopped"`, `"running"`, `"stopping"`, or `"failed"`. + public var workerState: String + public var pid: Int? + public var failureReason: String? + public var provenance: CopyProvenanceDTO? + + public init( + id: String, + name: String, + path: String, + enabled: Bool, + workerState: String, + pid: Int?, + failureReason: String?, + provenance: CopyProvenanceDTO?, + ) { + self.id = id + self.name = name + self.path = path + self.enabled = enabled + self.workerState = workerState + self.pid = pid + self.failureReason = failureReason + self.provenance = provenance + } +} + +/// The `describe` result: the scan directory plus every known repo. +public struct DescribeResultDTO: Codable, Equatable, Sendable { + public var scanDirectory: String + public var repos: [RepoStatusDTO] + + public init(scanDirectory: String, repos: [RepoStatusDTO]) { + self.scanDirectory = scanDirectory + self.repos = repos + } +} + +@MainActor +extension RepoStatusDTO { + /// Snapshots a live ``Repo`` into its wire form. + public init(repo: Repo) { + let workerState: String + var pid: Int? + var failureReason: String? + switch repo.worker.state { + case .stopped: + workerState = "stopped" + case let .running(runningPID, _): + workerState = "running" + pid = Int(runningPID) + case .stopping: + workerState = "stopping" + case let .failed(reason): + workerState = "failed" + failureReason = reason + } + self.init( + id: repo.id.rawValue, + name: repo.name, + path: repo.rootURL.path, + enabled: repo.isEnabled, + workerState: workerState, + pid: pid, + failureReason: failureReason, + provenance: repo.provenance.map(CopyProvenanceDTO.init), + ) + } +} + +// MARK: - Errors + +/// A control operation that couldn't be carried out. All cases are +/// user-recoverable and carry a localized description surfaced to the caller +/// (the MCP over the socket, or the Foreman UI's Remove action). +public enum ControlError: Error, LocalizedError, Equatable { + /// The path isn't an immediate subdirectory of the scan directory. + case pathNotUnderScanDirectory(path: String, scanDirectory: String) + /// The path isn't a git working copy. + case notAGitRepository(path: String) + /// No repo with that path is known after a rescan. + case repoNotFound(path: String) + /// The path has no recorded provenance, so it isn't a Foreman-made copy. + case notACopy(path: String) + /// The provenance `kind` string wasn't `"worktree"` or `"clone"`. + case invalidProvenanceKind(String) + /// The worker didn't stop in time to remove the copy safely. + case workerDidNotStop(path: String) + /// The underlying removal (git or Trash) failed; carries its reason. + case removeFailed(reason: String) + + public var errorDescription: String? { + switch self { + case let .pathNotUnderScanDirectory(path, scanDirectory): + String(localized: .controlNotUnderScanDirectory(path: path, scan: scanDirectory)) + case let .notAGitRepository(path): + String(localized: .controlNotAGitRepo(path: path)) + case let .repoNotFound(path): + String(localized: .controlRepoNotFound(path: path)) + case let .notACopy(path): + String(localized: .controlNotACopy(path: path)) + case let .invalidProvenanceKind(kind): + String(localized: .controlInvalidProvenanceKind(kind: kind)) + case .workerDidNotStop: + String(localized: .controlWorkerDidNotStop) + case let .removeFailed(reason): + String(localized: .controlRemoveFailed(error: reason)) + } + } +} diff --git a/Foreman/ForemanCore/Sources/ControlRequestHandler.swift b/Foreman/ForemanCore/Sources/ControlRequestHandler.swift new file mode 100644 index 00000000..2b6c929a --- /dev/null +++ b/Foreman/ForemanCore/Sources/ControlRequestHandler.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Maps a decoded ``ControlRequest`` to the matching ``ForemanServices`` +/// intent and wraps the outcome in a ``ControlResponse``. +/// +/// Transport-agnostic on purpose: the app target's socket server does the raw +/// I/O and hands decoded requests here, so this — and the services intents it +/// calls — is what `ForemanCoreTests` covers. Every thrown error becomes a +/// `.failure` response (the reason is localized), never a crash or a silent +/// success. +@MainActor +public struct ControlRequestHandler { + private let services: ForemanServices + + public init(services: ForemanServices) { + self.services = services + } + + public func handle(_ request: ControlRequest) async -> ControlResponse { + do { + switch request { + case .describe: + return .describe(services.describe()) + case let .adopt(path, provenanceDTO): + let provenance = try provenanceDTO.model() + let status = try services.adoptAndStartWorker( + at: URL(fileURLWithPath: path), + provenance: provenance, + ) + return .repo(status) + case let .removeCopy(path): + try await services.removeCopy(at: URL(fileURLWithPath: path)) + return .removed(path: path) + } + } catch { + return .failure(message: error.localizedDescription) + } + } +} diff --git a/Foreman/ForemanCore/Sources/CopyProvenance.swift b/Foreman/ForemanCore/Sources/CopyProvenance.swift new file mode 100644 index 00000000..4c3b80e6 --- /dev/null +++ b/Foreman/ForemanCore/Sources/CopyProvenance.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Records that a repository is a copy Foreman created on behalf of the MCP +/// server — a `git worktree` or a full `git clone` of a parent repo. +/// +/// Persisted on ``RepoConfiguration`` so a copy can be badged in the UI and +/// removed the right way: a worktree via `git worktree remove`, a clone by +/// moving it to the Trash. Ordinary discovered repositories have no +/// provenance (`nil`). +public struct CopyProvenance: Codable, Equatable, Sendable { + /// How the copy was created. The two kinds have different removal paths, + /// so this is a single value rather than a loose `isWorktree` flag. + public enum Kind: String, Codable, Sendable { + case worktree + case clone + } + + public var kind: Kind + /// The repository the copy was made from. + public var parentRepoID: RepoID + /// The branch checked out in the copy. + public var branch: String + + public init(kind: Kind, parentRepoID: RepoID, branch: String) { + self.kind = kind + self.parentRepoID = parentRepoID + self.branch = branch + } +} diff --git a/Foreman/ForemanCore/Sources/ForemanServices.swift b/Foreman/ForemanCore/Sources/ForemanServices.swift index fd3140f8..0b7bae12 100644 --- a/Foreman/ForemanCore/Sources/ForemanServices.swift +++ b/Foreman/ForemanCore/Sources/ForemanServices.swift @@ -112,6 +112,18 @@ public final class ForemanServices { .appendingPathComponent("Library/Logs/Foreman", isDirectory: true) } + /// The Unix domain socket the app listens on for MCP control requests: + /// `~/Library/Application Support/com.stuff.foreman/control.sock`. The + /// `foreman-mcp` server connects here (its `FOREMAN_CONTROL_SOCKET` + /// default is the same path). + public static var controlSocketURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent( + "Library/Application Support/com.stuff.foreman/control.sock", + isDirectory: false, + ) + } + private static let logger = ForemanLog.channel(.services) @ObservationIgnored private var configuration: ForemanConfiguration @@ -123,6 +135,7 @@ public final class ForemanServices { private let sleepInhibitor: SleepInhibitor private let loginItem: LoginItemController private let locator = CursorAgentLocator() + private let copyRemover: any RepoCopyRemoving public convenience init(configStore: WorkerConfigStore, logDirectory: URL) { self.init( @@ -143,11 +156,13 @@ public final class ForemanServices { logDirectory: URL, sleepInhibitor: SleepInhibitor, loginItem: LoginItemController, + copyRemover: any RepoCopyRemoving = SystemRepoCopyRemover(), ) { self.configStore = configStore self.logDirectory = logDirectory self.sleepInhibitor = sleepInhibitor self.loginItem = loginItem + self.copyRemover = copyRemover do { configuration = try configStore.load() configLoadFailure = nil @@ -206,6 +221,123 @@ public final class ForemanServices { } } + // MARK: - Control (MCP) + + /// A snapshot of the scan directory and every known repo, for the MCP's + /// `list_repos` and for placing new copies. Pure read of the live tree. + public func describe() -> DescribeResultDTO { + DescribeResultDTO( + scanDirectory: settings.resolvedScanDirectory.path, + repos: discovery.repos.map { RepoStatusDTO(repo: $0) }, + ) + } + + /// Records that the repo at `path` is a Foreman-created copy and starts its + /// worker, returning the resulting status. + /// + /// `path` must be an immediate subdirectory of the scan directory (that's + /// all `RepoDiscovery` sees) and a git working copy; otherwise this throws + /// a ``ControlError`` describing the problem rather than silently doing + /// nothing. Safe to call again for an already-adopted copy — it refreshes + /// the provenance and makes sure the worker is running. + @discardableResult + public func adoptAndStartWorker( + at path: URL, + provenance: CopyProvenance, + ) throws -> RepoStatusDTO { + let copy = path.standardizedFileURL + let scanDirectory = settings.resolvedScanDirectory.standardizedFileURL + + guard copy.deletingLastPathComponent().path == scanDirectory.path else { + throw ControlError.pathNotUnderScanDirectory( + path: copy.path, + scanDirectory: scanDirectory.path, + ) + } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: copy.path, isDirectory: &isDirectory), + isDirectory.boolValue, + FileManager.default.fileExists(atPath: copy.appendingPathComponent(".git").path) + else { + throw ControlError.notAGitRepository(path: copy.path) + } + + // Discover the freshly-created copy, then record its provenance on the + // live repo (which writes through) — this also covers the case where a + // prior rescan had already picked the directory up without provenance. + rescan() + let id = RepoID(rootURL: copy) + guard let repo = discovery.repos.first(where: { $0.id == id }) else { + throw ControlError.repoNotFound(path: copy.path) + } + repo.provenance = provenance + + if repo.isEnabled { + repo.startIfEnabled() + repo.retry() + } else { + repo.isEnabled = true + } + Self.logger.info("Adopted \(provenance.kind.rawValue) copy at \(copy.path)") + return RepoStatusDTO(repo: repo) + } + + /// Stops the worker for the copy at `path` and removes it: a worktree via + /// `git worktree remove`, a clone by moving it to the Trash. Throws a + /// ``ControlError`` if the path isn't a recorded copy or the removal + /// fails, so a failure is never mistaken for success. + public func removeCopy(at path: URL) async throws { + let copy = path.standardizedFileURL + let id = RepoID(rootURL: copy) + let repo = discovery.repos.first { $0.id == id } + guard let provenance = repo?.provenance ?? configuration.configuration(for: id).provenance + else { + throw ControlError.notACopy(path: copy.path) + } + + // A live cursor-agent holds the worktree open, so stop it and wait for + // the process to actually exit before touching the files. + if let repo, repo.worker.state.isLive { + repo.isEnabled = false + try await waitForWorkerToStop(repo, path: copy) + } + + do { + switch provenance.kind { + case .worktree: + try copyRemover.removeWorktree( + at: copy, + parentRepoPath: URL(fileURLWithPath: provenance.parentRepoID.rawValue), + ) + case .clone: + try copyRemover.removeClone(at: copy) + } + } catch { + Self.logger.error("Couldn't remove copy at \(copy.path): \(error)") + throw ControlError.removeFailed(reason: error.localizedDescription) + } + + Self.logger.info("Removed \(provenance.kind.rawValue) copy at \(copy.path)") + rescan() + } + + /// Polls until `repo`'s worker settles at a non-live state, throwing + /// ``ControlError/workerDidNotStop(path:)`` if it hasn't within `timeout`. + private func waitForWorkerToStop( + _ repo: Repo, + path: URL, + timeout: Duration = .seconds(10), + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while repo.worker.state.isLive { + if clock.now >= deadline { + throw ControlError.workerDidNotStop(path: path.path) + } + try await Task.sleep(for: .milliseconds(50)) + } + } + // MARK: - Tree wiring /// Thrown by a repo's executable resolution when the owning services @@ -228,6 +360,7 @@ public final class ForemanServices { isEnabled: record.isEnabled, isFavorite: record.isFavorite, options: record.options, + provenance: record.provenance, worker: Worker( name: scanned.name, workerDirectory: scanned.rootURL, @@ -248,6 +381,7 @@ public final class ForemanServices { isEnabled: repo.isEnabled, isFavorite: repo.isFavorite, options: repo.options, + provenance: repo.provenance, ) if record == .standard { // A fully default record reads identically to an absent entry; diff --git a/Foreman/ForemanCore/Sources/Repo.swift b/Foreman/ForemanCore/Sources/Repo.swift index 02beef9c..786f1c0c 100644 --- a/Foreman/ForemanCore/Sources/Repo.swift +++ b/Foreman/ForemanCore/Sources/Repo.swift @@ -55,6 +55,18 @@ public final class Repo: Identifiable { } } + /// Set when this repo is a copy Foreman created (a worktree or clone of a + /// parent repo); `nil` for ordinary discovered repos. Recorded when the + /// copy is adopted via the control socket and used to badge the copy and + /// remove it correctly. Persisted like the other intent; reassigning the + /// current value is a no-op. + public var provenance: CopyProvenance? { + didSet { + guard oldValue != provenance else { return } + onPersistentChange(self) + } + } + private static let logger = ForemanLog.channel(.repo) private let resolveExecutable: @MainActor () throws -> URL @@ -67,18 +79,21 @@ public final class Repo: Identifiable { /// - isFavorite: The saved favorite flag (pins the repo to the top of /// its sidebar section). /// - options: The saved worker options. + /// - provenance: The saved copy provenance, or `nil` for an ordinary + /// repo. /// - worker: The repo's worker (the owning tree wires its /// `onStateChange`). /// - resolveExecutable: Locates the `cursor-agent` binary at start /// time; a throw lands in the worker's `.failed` state. /// - onPersistentChange: Invoked after every - /// `isEnabled`/`isFavorite`/`options` change so the owning tree can - /// write through and save. + /// `isEnabled`/`isFavorite`/`options`/`provenance` change so the + /// owning tree can write through and save. public init( scanned: ScannedRepo, isEnabled: Bool, isFavorite: Bool, options: WorkerOptions, + provenance: CopyProvenance?, worker: Worker, resolveExecutable: @escaping @MainActor () throws -> URL, onPersistentChange: @escaping @MainActor (Repo) -> Void, @@ -90,6 +105,7 @@ public final class Repo: Identifiable { self.isEnabled = isEnabled self.isFavorite = isFavorite self.options = options + self.provenance = provenance self.resolveExecutable = resolveExecutable self.onPersistentChange = onPersistentChange } diff --git a/Foreman/ForemanCore/Sources/RepoCopyRemover.swift b/Foreman/ForemanCore/Sources/RepoCopyRemover.swift new file mode 100644 index 00000000..15ee5639 --- /dev/null +++ b/Foreman/ForemanCore/Sources/RepoCopyRemover.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Removes a repository copy Foreman created: a `git worktree` (via +/// `git worktree remove`) or a full `git clone` (moved to the Trash). +/// +/// Split behind a protocol so ``ForemanServices/removeCopy(at:)`` can be +/// tested without shelling out to `git` or moving files to the user's Trash. +public protocol RepoCopyRemoving: Sendable { + /// Removes the worktree checked out at `path`, whose main repository is at + /// `parentRepoPath`. Throws with a human-readable reason on failure. + func removeWorktree(at path: URL, parentRepoPath: URL) throws + /// Removes a full clone at `path` (moved to the Trash). Throws on failure. + func removeClone(at path: URL) throws +} + +/// Production remover: shells out to `git worktree remove` for worktrees and +/// moves clones to the Trash via `FileManager` (recoverable, unlike a hard +/// delete). GUI apps don't inherit the shell `PATH`, so `git` is located at +/// its known install paths. +public struct SystemRepoCopyRemover: RepoCopyRemoving { + /// Where `git` installs, checked in order. + static let gitSearchPaths = [ + "/usr/bin/git", + "/opt/homebrew/bin/git", + "/usr/local/bin/git", + ] + + public init() {} + + public func removeWorktree(at path: URL, parentRepoPath: URL) throws { + try runGit(["-C", parentRepoPath.path, "worktree", "remove", "--force", path.path]) + } + + public func removeClone(at path: URL) throws { + try FileManager.default.trashItem(at: path, resultingItemURL: nil) + } + + /// A `git` invocation that failed, carrying the command's stderr as its + /// message so the reason isn't swallowed. + struct GitError: Error, LocalizedError { + let message: String + var errorDescription: String? { + message + } + } + + private func runGit(_ arguments: [String]) throws { + guard let git = Self.gitSearchPaths + .first(where: { FileManager.default.isExecutableFile(atPath: $0) }) + .map({ URL(fileURLWithPath: $0) }) + else { + throw GitError( + message: "git executable not found in \(Self.gitSearchPaths.joined(separator: ", "))", + ) + } + + let process = Process() + process.executableURL = git + process.arguments = arguments + let errorPipe = Pipe() + process.standardError = errorPipe + process.standardOutput = FileHandle.nullDevice + try process.run() + // Read to EOF (which lands at exit) before waiting, so a chatty git + // can't deadlock on a full pipe. + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let stderr = String(data: errorData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + throw GitError( + message: stderr.isEmpty + ? "git exited with status \(process.terminationStatus)" + : stderr, + ) + } + } +} diff --git a/Foreman/ForemanCore/Sources/Resources/Localizable.xcstrings b/Foreman/ForemanCore/Sources/Resources/Localizable.xcstrings index 9a8fc12e..8451a027 100644 --- a/Foreman/ForemanCore/Sources/Resources/Localizable.xcstrings +++ b/Foreman/ForemanCore/Sources/Resources/Localizable.xcstrings @@ -13,6 +13,90 @@ } } }, + "control.invalidProvenanceKind" : { + "comment" : "Control-socket error when an adopt request's copy kind isn't recognized. The placeholder is the bad kind string.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown copy kind “%(kind)@”." + } + } + } + }, + "control.notACopy" : { + "comment" : "Control-socket error when asked to remove a path Foreman didn't create as a copy. The placeholder is the path.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%(path)@” isn't a copy Foreman created, so it won't be removed." + } + } + } + }, + "control.notAGitRepo" : { + "comment" : "Control-socket error when an adopt path isn't a git working copy. The placeholder is the path.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%(path)@” isn't a git repository." + } + } + } + }, + "control.notUnderScanDirectory" : { + "comment" : "Control-socket error when an adopt path isn't directly inside the scan directory. First placeholder is the path, second is the scan directory.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%(path)@” isn't directly inside Foreman's scan directory (%(scan)@)." + } + } + } + }, + "control.removeFailed" : { + "comment" : "Control-socket error when removing a copy failed. The placeholder is the underlying error message.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't remove the copy: %(error)@" + } + } + } + }, + "control.repoNotFound" : { + "comment" : "Control-socket error when no repo exists at the given path after a rescan. The placeholder is the path.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foreman has no repository at “%(path)@”." + } + } + } + }, + "control.workerDidNotStop" : { + "comment" : "Control-socket error when a copy's worker didn't stop in time to remove it safely.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The worker didn't stop in time to remove the copy." + } + } + } + }, "cursorAgent.notFound" : { "comment" : "Error when the cursor-agent executable couldn't be located. The placeholder is a comma-separated list of paths that were checked.", "extractionState" : "manual", diff --git a/Foreman/ForemanCore/Sources/WorkerConfigStore.swift b/Foreman/ForemanCore/Sources/WorkerConfigStore.swift index 7c51efbe..a96d92c9 100644 --- a/Foreman/ForemanCore/Sources/WorkerConfigStore.swift +++ b/Foreman/ForemanCore/Sources/WorkerConfigStore.swift @@ -13,16 +13,29 @@ public struct RepoConfiguration: Codable, Equatable, Sendable { public var isFavorite: Bool /// This repo's worker options. public var options: WorkerOptions + /// Set when this repo is a copy Foreman created (a worktree or clone); + /// `nil` for ordinary discovered repos. A copy's record is therefore + /// never "standard", so it survives (it isn't dropped as a no-op). + public var provenance: CopyProvenance? - public init(isEnabled: Bool, isFavorite: Bool, options: WorkerOptions) { + /// `provenance` defaults to `nil` — the obvious "not a copy" zero value, + /// which can't change the behavior of an ordinary repo, so omitting it at + /// a call site is safe. + public init( + isEnabled: Bool, + isFavorite: Bool, + options: WorkerOptions, + provenance: CopyProvenance? = nil, + ) { self.isEnabled = isEnabled self.isFavorite = isFavorite self.options = options + self.provenance = provenance } /// The record an uncustomized repo reads as: disabled, unfavorited, - /// standard options. A repo whose state equals this needs no persisted - /// entry — absence reads identically. + /// standard options, no provenance. A repo whose state equals this needs + /// no persisted entry — absence reads identically. public static let standard = RepoConfiguration( isEnabled: false, isFavorite: false, diff --git a/Foreman/ForemanCore/Tests/ControlProtocolTests.swift b/Foreman/ForemanCore/Tests/ControlProtocolTests.swift new file mode 100644 index 00000000..7c3b95be --- /dev/null +++ b/Foreman/ForemanCore/Tests/ControlProtocolTests.swift @@ -0,0 +1,147 @@ +@_spi(Testing) import ForemanCore +import Foundation +import Testing + +/// The control socket is a hand-written contract shared with the TypeScript +/// MCP client, so these lock the exact JSON shape (keys + discriminators), not +/// just Swift-to-Swift round-tripping. +@MainActor +struct ControlProtocolTests { + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private func json(_ value: some Encodable) throws -> [String: Any] { + let data = try encoder.encode(value) + return try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any], + ) + } + + // MARK: - Requests + + @Test func describeRequestIsJustACommand() throws { + let object = try json(ControlRequest.describe) + #expect(object["command"] as? String == "describe") + #expect(object.count == 1) + } + + @Test func adoptRequestCarriesPathAndProvenance() throws { + let request = ControlRequest.adopt( + path: "/Users/dev/Development/Copy", + provenance: CopyProvenanceDTO( + kind: "worktree", + parentRepoID: "/Users/dev/Development/Main", + branch: "feature", + ), + ) + let object = try json(request) + #expect(object["command"] as? String == "adopt") + #expect(object["path"] as? String == "/Users/dev/Development/Copy") + let provenance = try #require(object["provenance"] as? [String: Any]) + #expect(provenance["kind"] as? String == "worktree") + #expect(provenance["parentRepoID"] as? String == "/Users/dev/Development/Main") + #expect(provenance["branch"] as? String == "feature") + } + + @Test func requestsRoundTrip() throws { + let requests: [ControlRequest] = [ + .describe, + .adopt( + path: "/x/Copy", + provenance: CopyProvenanceDTO(kind: "clone", parentRepoID: "/x/Main", branch: "b"), + ), + .removeCopy(path: "/x/Copy"), + ] + for request in requests { + let decoded = try decoder.decode(ControlRequest.self, from: encoder.encode(request)) + #expect(decoded == request) + } + } + + // MARK: - Responses + + @Test func successResponsesCarryOkTrueAndAKind() throws { + let describe = try json(ControlResponse.describe( + DescribeResultDTO(scanDirectory: "/x", repos: []), + )) + #expect(describe["ok"] as? Bool == true) + #expect(describe["kind"] as? String == "describe") + + let removed = try json(ControlResponse.removed(path: "/x/Copy")) + #expect(removed["ok"] as? Bool == true) + #expect(removed["kind"] as? String == "removed") + #expect(removed["path"] as? String == "/x/Copy") + } + + @Test func failureResponseCarriesOkFalseAndError() throws { + let object = try json(ControlResponse.failure(message: "nope")) + #expect(object["ok"] as? Bool == false) + #expect(object["error"] as? String == "nope") + #expect(object["kind"] == nil) + } + + @Test func responsesRoundTrip() throws { + let status = RepoStatusDTO( + id: "/x/Copy", + name: "Copy", + path: "/x/Copy", + enabled: true, + workerState: "running", + pid: 4321, + failureReason: nil, + provenance: CopyProvenanceDTO(kind: "worktree", parentRepoID: "/x/Main", branch: "b"), + ) + let responses: [ControlResponse] = [ + .describe(DescribeResultDTO(scanDirectory: "/x", repos: [status])), + .repo(status), + .removed(path: "/x/Copy"), + .failure(message: "boom"), + ] + for response in responses { + let decoded = try decoder.decode(ControlResponse.self, from: encoder.encode(response)) + #expect(decoded == response) + } + } + + // MARK: - DTO mapping + + @Test func provenanceDTORoundTripsThroughTheModel() throws { + let dto = CopyProvenanceDTO(kind: "clone", parentRepoID: "/x/Main", branch: "b") + let model = try dto.model() + #expect(model.kind == .clone) + #expect(model.parentRepoID == RepoID(rawValue: "/x/Main")) + #expect(model.branch == "b") + #expect(CopyProvenanceDTO(model) == dto) + } + + @Test func invalidProvenanceKindThrows() { + let dto = CopyProvenanceDTO(kind: "sideways", parentRepoID: "/x/Main", branch: "b") + #expect(throws: ControlError.invalidProvenanceKind("sideways")) { + try dto.model() + } + } + + @Test func repoStatusReflectsAFailedWorker() throws { + let base = try makeTemporaryDirectory() + let logs = base.appendingPathComponent("logs") + let repo = makeStubRepo( + scanned: ScannedRepo(name: "Copy", rootURL: base.appendingPathComponent("Copy")), + logDirectory: logs, + executable: URL(fileURLWithPath: "/usr/bin/true"), + provenance: CopyProvenance( + kind: .worktree, + parentRepoID: RepoID(rawValue: "/x/Main"), + branch: "b", + ), + ) + repo.worker.recordStartFailure(reason: "kaboom") + + let status = RepoStatusDTO(repo: repo) + #expect(status.name == "Copy") + #expect(status.workerState == "failed") + #expect(status.failureReason == "kaboom") + #expect(status.pid == nil) + #expect(status.provenance?.kind == "worktree") + #expect(status.provenance?.branch == "b") + } +} diff --git a/Foreman/ForemanCore/Tests/ControlRequestHandlerTests.swift b/Foreman/ForemanCore/Tests/ControlRequestHandlerTests.swift new file mode 100644 index 00000000..1f91f4a2 --- /dev/null +++ b/Foreman/ForemanCore/Tests/ControlRequestHandlerTests.swift @@ -0,0 +1,101 @@ +@_spi(Testing) import ForemanCore +import Foundation +import Testing + +/// The handler is a thin request→intent→response map; these confirm the +/// mapping and — crucially — that every failure becomes a `.failure` response +/// (localized reason) rather than a throw escaping to the socket. +@MainActor +struct ControlRequestHandlerTests { + private func makeHandler(repoNames: [String]) throws + -> (ControlRequestHandler, ControlServicesFixture) + { + let fixture = try makeControlServicesFixture(repoNames: repoNames) + fixture.services.start() + return (ControlRequestHandler(services: fixture.services), fixture) + } + + @Test func describeReportsScanDirectoryAndRepos() async throws { + let (handler, fixture) = try makeHandler(repoNames: ["Main"]) + + guard case let .describe(result) = await handler.handle(.describe) else { + Issue.record("expected a describe response") + return + } + #expect(result.scanDirectory == fixture.scanDirectory.path) + #expect(result.repos.map(\.name) == ["Main"]) + #expect(result.repos[0].provenance == nil) + } + + @Test func adoptReturnsTheRepoStatus() async throws { + let (handler, fixture) = try makeHandler(repoNames: ["Main"]) + try addGitDirectory("Copy", in: fixture.scanDirectory) + let copy = fixture.scanDirectory.appendingPathComponent("Copy") + + let response = await handler.handle(.adopt( + path: copy.path, + provenance: CopyProvenanceDTO( + kind: "worktree", + parentRepoID: fixture.scanDirectory.appendingPathComponent("Main").path, + branch: "task", + ), + )) + guard case let .repo(status) = response else { + Issue.record("expected a repo response, got \(response)") + return + } + #expect(status.name == "Copy") + #expect(status.enabled) + #expect(status.provenance?.kind == "worktree") + #expect(status.provenance?.branch == "task") + + fixture.services.stopAll() + } + + @Test func invalidProvenanceKindBecomesAFailure() async throws { + let (handler, fixture) = try makeHandler(repoNames: []) + try addGitDirectory("Copy", in: fixture.scanDirectory) + let copy = fixture.scanDirectory.appendingPathComponent("Copy") + + let response = await handler.handle(.adopt( + path: copy.path, + provenance: CopyProvenanceDTO(kind: "sideways", parentRepoID: "/x", branch: "b"), + )) + guard case let .failure(message) = response else { + Issue.record("expected a failure response, got \(response)") + return + } + #expect(message.contains("sideways")) + } + + @Test func adoptOutsideScanDirectoryBecomesAFailure() async throws { + let (handler, fixture) = try makeHandler(repoNames: []) + // A .git-bearing dir that is NOT under the scan directory. + let outside = fixture.base.appendingPathComponent("Outside") + try FileManager.default.createDirectory( + at: outside.appendingPathComponent(".git"), + withIntermediateDirectories: true, + ) + + let response = await handler.handle(.adopt( + path: outside.path, + provenance: CopyProvenanceDTO(kind: "clone", parentRepoID: "/x", branch: "b"), + )) + guard case .failure = response else { + Issue.record("expected a failure response, got \(response)") + return + } + } + + @Test func removingANonCopyBecomesAFailure() async throws { + let (handler, fixture) = try makeHandler(repoNames: ["Plain"]) + let plain = fixture.scanDirectory.appendingPathComponent("Plain") + + let response = await handler.handle(.removeCopy(path: plain.path)) + guard case .failure = response else { + Issue.record("expected a failure response, got \(response)") + return + } + #expect(fixture.remover.worktreeRemovals.isEmpty) + } +} diff --git a/Foreman/ForemanCore/Tests/CopyProvenanceTests.swift b/Foreman/ForemanCore/Tests/CopyProvenanceTests.swift new file mode 100644 index 00000000..c0b82705 --- /dev/null +++ b/Foreman/ForemanCore/Tests/CopyProvenanceTests.swift @@ -0,0 +1,54 @@ +@_spi(Testing) import ForemanCore +import Foundation +import Testing + +struct CopyProvenanceTests { + @Test func codableRoundTrip() throws { + let provenance = CopyProvenance( + kind: .worktree, + parentRepoID: RepoID(rawValue: "/Users/dev/Development/Main"), + branch: "feature/x", + ) + let data = try JSONEncoder().encode(provenance) + let decoded = try JSONDecoder().decode(CopyProvenance.self, from: data) + #expect(decoded == provenance) + } + + @Test func kindEncodesAsItsRawString() throws { + let provenance = CopyProvenance( + kind: .clone, + parentRepoID: RepoID(rawValue: "/x/Main"), + branch: "b", + ) + let object = try #require( + JSONSerialization.jsonObject( + with: JSONEncoder().encode(provenance), + ) as? [String: Any], + ) + #expect(object["kind"] as? String == "clone") + #expect(object["branch"] as? String == "b") + } + + @Test func differsByEachField() { + let base = CopyProvenance( + kind: .worktree, + parentRepoID: RepoID(rawValue: "/x/Main"), + branch: "b", + ) + #expect(base != CopyProvenance( + kind: .clone, + parentRepoID: RepoID(rawValue: "/x/Main"), + branch: "b", + )) + #expect(base != CopyProvenance( + kind: .worktree, + parentRepoID: RepoID(rawValue: "/x/Other"), + branch: "b", + )) + #expect(base != CopyProvenance( + kind: .worktree, + parentRepoID: RepoID(rawValue: "/x/Main"), + branch: "c", + )) + } +} diff --git a/Foreman/ForemanCore/Tests/ForemanCoreTestSupport.swift b/Foreman/ForemanCore/Tests/ForemanCoreTestSupport.swift index 67176a51..a0ecb864 100644 --- a/Foreman/ForemanCore/Tests/ForemanCoreTestSupport.swift +++ b/Foreman/ForemanCore/Tests/ForemanCoreTestSupport.swift @@ -57,16 +57,120 @@ final class LoginItemRecorder: LoginItemBackend { /// A stand-in error for login-item failure injection. struct LoginItemTestError: Error {} +/// A ``RepoCopyRemoving`` double that records its calls and, by default, +/// actually deletes the copy directory so a follow-up rescan behaves like a +/// real removal — without shelling out to `git` or touching the user's Trash. +/// Only touched on the main actor in practice. +final class RecordingCopyRemover: RepoCopyRemoving, @unchecked Sendable { + struct WorktreeRemoval: Equatable { + let path: URL + let parentRepoPath: URL + } + + private(set) var worktreeRemovals: [WorktreeRemoval] = [] + private(set) var cloneRemovals: [URL] = [] + /// When set, every removal throws it (before deleting), simulating a git + /// or Trash failure. + var failure: (any Error)? + /// Whether a successful removal also deletes the directory on disk. + var deletesDirectory = true + + func removeWorktree(at path: URL, parentRepoPath: URL) throws { + worktreeRemovals.append(WorktreeRemoval(path: path, parentRepoPath: parentRepoPath)) + if let failure { throw failure } + if deletesDirectory { + try? FileManager.default.removeItem(at: path) + } + } + + func removeClone(at path: URL) throws { + cloneRemovals.append(path) + if let failure { throw failure } + if deletesDirectory { + try? FileManager.default.removeItem(at: path) + } + } +} + +/// A stand-in error for copy-removal failure injection. +struct CopyRemovalTestError: Error {} + +/// A `ForemanServices` wired for control-socket tests: a real scan directory +/// with `.git`-bearing repos, a long-running stub `cursor-agent`, and a +/// ``RecordingCopyRemover`` so removals are observable and hermetic. +struct ControlServicesFixture { + let services: ForemanServices + let store: WorkerConfigStore + let base: URL + let scanDirectory: URL + let executable: URL + let remover: RecordingCopyRemover +} + +/// Builds a ``ControlServicesFixture`` with `repoNames` created as +/// `.git`-bearing directories under the scan directory. The returned services +/// have not been `start()`ed yet. +@MainActor +func makeControlServicesFixture(repoNames: [String]) throws -> ControlServicesFixture { + let base = try makeTemporaryDirectory() + let scanDirectory = base.appendingPathComponent("Development", isDirectory: true) + try FileManager.default.createDirectory(at: scanDirectory, withIntermediateDirectories: true) + for name in repoNames { + try addGitDirectory(name, in: scanDirectory) + } + let executable = try makeStubExecutable( + in: base, + script: "#!/bin/sh\nwhile true; do sleep 0.1; done\n", + ) + let store = WorkerConfigStore(directory: base.appendingPathComponent("config")) + try store.save(ForemanConfiguration( + scanDirectory: scanDirectory, + agentExecutable: executable, + repos: [:], + )) + let remover = RecordingCopyRemover() + let services = ForemanServices( + configStore: store, + logDirectory: base.appendingPathComponent("logs"), + sleepInhibitor: SleepInhibitor(backend: SleepAssertionRecorder()), + loginItem: LoginItemController(backend: LoginItemRecorder()), + copyRemover: remover, + ) + return ControlServicesFixture( + services: services, + store: store, + base: base, + scanDirectory: scanDirectory, + executable: executable, + remover: remover, + ) +} + +/// Creates `//.git` (a directory `.git`, as a plain clone +/// has), enough for discovery and adopt validation. +func addGitDirectory(_ name: String, in directory: URL) throws { + try FileManager.default.createDirectory( + at: directory.appendingPathComponent("\(name)/.git", isDirectory: true), + withIntermediateDirectories: true, + ) +} + /// Builds a live `Repo` over a fixed executable for tree-level tests: /// disabled, unfavorited, standard options, no-op persistence and /// state-change hooks. @MainActor -func makeStubRepo(scanned: ScannedRepo, logDirectory: URL, executable: URL) -> Repo { +func makeStubRepo( + scanned: ScannedRepo, + logDirectory: URL, + executable: URL, + provenance: CopyProvenance? = nil, +) -> Repo { Repo( scanned: scanned, isEnabled: false, isFavorite: false, options: .standard, + provenance: provenance, worker: Worker( name: scanned.name, workerDirectory: scanned.rootURL, diff --git a/Foreman/ForemanCore/Tests/ForemanServicesTests.swift b/Foreman/ForemanCore/Tests/ForemanServicesTests.swift index 1f76d35f..180d2c9f 100644 --- a/Foreman/ForemanCore/Tests/ForemanServicesTests.swift +++ b/Foreman/ForemanCore/Tests/ForemanServicesTests.swift @@ -396,4 +396,171 @@ struct ForemanServicesTests { #expect(fixture.sleep.ends == 1) #expect(!fixture.services.isInhibitingSleep) } + + // MARK: - Control (MCP) + + @Test func adoptRecordsProvenanceEnablesAndPersists() async throws { + let fixture = try makeControlServicesFixture(repoNames: ["Main"]) + fixture.services.start() + try addGitDirectory("Copy", in: fixture.scanDirectory) + let copy = fixture.scanDirectory.appendingPathComponent("Copy", isDirectory: true) + let parentID = RepoID(rootURL: fixture.scanDirectory.appendingPathComponent("Main")) + let provenance = CopyProvenance(kind: .worktree, parentRepoID: parentID, branch: "task") + + let status = try fixture.services.adoptAndStartWorker(at: copy, provenance: provenance) + #expect(status.name == "Copy") + #expect(status.enabled) + #expect(status.provenance?.kind == "worktree") + + let repo = try #require(fixture.services.repos.first { $0.name == "Copy" }) + #expect(repo.provenance == provenance) + #expect(repo.isEnabled) + try await waitUntil("copy worker running") { repo.worker.state.isLive } + + let saved = try #require(try fixture.store.load().repos[RepoID(rootURL: copy)]) + #expect(saved.provenance == provenance) + #expect(saved.isEnabled) + + fixture.services.stopAll() + try await waitUntil("copy worker stops") { repo.worker.state == .stopped } + } + + @Test func adoptRejectsAPathOutsideTheScanDirectory() throws { + let fixture = try makeControlServicesFixture(repoNames: []) + fixture.services.start() + let outside = fixture.base.appendingPathComponent("Outside", isDirectory: true) + try FileManager.default.createDirectory( + at: outside.appendingPathComponent(".git"), + withIntermediateDirectories: true, + ) + #expect(throws: ControlError.self) { + try fixture.services.adoptAndStartWorker( + at: outside, + provenance: CopyProvenance( + kind: .clone, + parentRepoID: RepoID(rawValue: "/x"), + branch: "b", + ), + ) + } + } + + @Test func adoptRejectsANonGitDirectory() throws { + let fixture = try makeControlServicesFixture(repoNames: []) + fixture.services.start() + let notGit = fixture.scanDirectory.appendingPathComponent("NotGit", isDirectory: true) + try FileManager.default.createDirectory(at: notGit, withIntermediateDirectories: true) + #expect(throws: ControlError.self) { + try fixture.services.adoptAndStartWorker( + at: notGit, + provenance: CopyProvenance( + kind: .clone, + parentRepoID: RepoID(rawValue: "/x"), + branch: "b", + ), + ) + } + } + + @Test func describeIncludesProvenanceForAdoptedCopies() throws { + let fixture = try makeControlServicesFixture(repoNames: ["Main"]) + fixture.services.start() + try addGitDirectory("Copy", in: fixture.scanDirectory) + let copy = fixture.scanDirectory.appendingPathComponent("Copy", isDirectory: true) + let parentID = RepoID(rootURL: fixture.scanDirectory.appendingPathComponent("Main")) + try fixture.services.adoptAndStartWorker( + at: copy, + provenance: CopyProvenance(kind: .clone, parentRepoID: parentID, branch: "b"), + ) + + let described = fixture.services.describe() + #expect(described.scanDirectory == fixture.scanDirectory.path) + let copyStatus = try #require(described.repos.first { $0.name == "Copy" }) + #expect(copyStatus.provenance?.kind == "clone") + #expect(copyStatus.provenance?.parentRepoID == parentID.rawValue) + let mainStatus = try #require(described.repos.first { $0.name == "Main" }) + #expect(mainStatus.provenance == nil) + + fixture.services.stopAll() + } + + @Test func removeCopyStopsTheWorkerRemovesTheCopyAndPrunes() async throws { + let fixture = try makeControlServicesFixture(repoNames: ["Main"]) + fixture.services.start() + try addGitDirectory("Copy", in: fixture.scanDirectory) + let copy = fixture.scanDirectory.appendingPathComponent("Copy", isDirectory: true) + let parentID = RepoID(rootURL: fixture.scanDirectory.appendingPathComponent("Main")) + try fixture.services.adoptAndStartWorker( + at: copy, + provenance: CopyProvenance(kind: .worktree, parentRepoID: parentID, branch: "task"), + ) + let repo = try #require(fixture.services.repos.first { $0.name == "Copy" }) + try await waitUntil("copy worker running") { repo.worker.state.isLive } + + try await fixture.services.removeCopy(at: copy) + + #expect(fixture.remover.worktreeRemovals.count == 1) + #expect(fixture.remover.worktreeRemovals.first?.path.lastPathComponent == "Copy") + #expect(fixture.remover.worktreeRemovals.first?.parentRepoPath.lastPathComponent == "Main") + #expect(!fixture.services.repos.contains { $0.name == "Copy" }) + #expect(try fixture.store.load().repos[RepoID(rootURL: copy)] == nil) + } + + @Test func removeCopyOfACloneUsesTheCloneRemover() async throws { + let fixture = try makeControlServicesFixture(repoNames: ["Main"]) + fixture.services.start() + try addGitDirectory("Clone", in: fixture.scanDirectory) + let clone = fixture.scanDirectory.appendingPathComponent("Clone", isDirectory: true) + let parentID = RepoID(rootURL: fixture.scanDirectory.appendingPathComponent("Main")) + try fixture.services.adoptAndStartWorker( + at: clone, + provenance: CopyProvenance(kind: .clone, parentRepoID: parentID, branch: "b"), + ) + let repo = try #require(fixture.services.repos.first { $0.name == "Clone" }) + try await waitUntil("clone worker running") { repo.worker.state.isLive } + + try await fixture.services.removeCopy(at: clone) + + #expect(fixture.remover.cloneRemovals.map(\.lastPathComponent) == ["Clone"]) + #expect(fixture.remover.worktreeRemovals.isEmpty) + #expect(!fixture.services.repos.contains { $0.name == "Clone" }) + } + + @Test func removeCopyRejectsARepoWithoutProvenance() async throws { + let fixture = try makeControlServicesFixture(repoNames: ["Plain"]) + fixture.services.start() + let plain = fixture.scanDirectory.appendingPathComponent("Plain", isDirectory: true) + + await #expect(throws: ControlError.self) { + try await fixture.services.removeCopy(at: plain) + } + #expect(fixture.remover.worktreeRemovals.isEmpty) + #expect(fixture.remover.cloneRemovals.isEmpty) + #expect(fixture.services.repos.contains { $0.name == "Plain" }) + } + + @Test func removeCopySurfacesRemoverFailureAndKeepsTheRepo() async throws { + let fixture = try makeControlServicesFixture(repoNames: ["Main"]) + fixture.services.start() + try addGitDirectory("Copy", in: fixture.scanDirectory) + let copy = fixture.scanDirectory.appendingPathComponent("Copy", isDirectory: true) + let parentID = RepoID(rootURL: fixture.scanDirectory.appendingPathComponent("Main")) + try fixture.services.adoptAndStartWorker( + at: copy, + provenance: CopyProvenance(kind: .worktree, parentRepoID: parentID, branch: "task"), + ) + let repo = try #require(fixture.services.repos.first { $0.name == "Copy" }) + try await waitUntil("copy worker running") { repo.worker.state.isLive } + fixture.remover.failure = CopyRemovalTestError() + + await #expect(throws: ControlError.self) { + try await fixture.services.removeCopy(at: copy) + } + // The worker was still stopped, and the repo survives (removal failed, + // so it wasn't silently dropped). + #expect(fixture.services.repos.contains { $0.name == "Copy" }) + try await waitUntil("worker stopped after failed removal") { + repo.worker.state == .stopped + } + } } diff --git a/Foreman/ForemanCore/Tests/RepoCopyRemoverTests.swift b/Foreman/ForemanCore/Tests/RepoCopyRemoverTests.swift new file mode 100644 index 00000000..48f74c08 --- /dev/null +++ b/Foreman/ForemanCore/Tests/RepoCopyRemoverTests.swift @@ -0,0 +1,79 @@ +@_spi(Testing) import ForemanCore +import Foundation +import Testing + +/// Exercises the production remover against real `git` in a temp repo (never +/// the user's real repos). The clone path (moving to the Trash via +/// `FileManager`) is left to the `removeCopy` flow test with a stubbed remover +/// so tests don't pollute the user's Trash. +struct RepoCopyRemoverTests { + @Test func removesARealWorktree() throws { + let base = try makeTemporaryDirectory() + let repo = base.appendingPathComponent("Main", isDirectory: true) + try makeGitRepoWithCommit(at: repo) + + let worktree = base.appendingPathComponent("Copy", isDirectory: true) + try git(["-C", repo.path, "worktree", "add", "-b", "task", worktree.path]) + #expect(FileManager.default.fileExists(atPath: worktree.path)) + #expect(FileManager.default + .fileExists(atPath: worktree.appendingPathComponent(".git").path)) + + try SystemRepoCopyRemover().removeWorktree(at: worktree, parentRepoPath: repo) + + #expect(!FileManager.default.fileExists(atPath: worktree.path)) + let list = try gitOutput(["-C", repo.path, "worktree", "list"]) + #expect(!list.contains(worktree.path)) + } + + @Test func worktreeRemovalSurfacesGitFailure() throws { + let base = try makeTemporaryDirectory() + let repo = base.appendingPathComponent("Main", isDirectory: true) + try makeGitRepoWithCommit(at: repo) + + // A directory that isn't a registered worktree — git refuses, and the + // reason must surface rather than be swallowed. + let notAWorktree = base.appendingPathComponent("Nope", isDirectory: true) + try FileManager.default.createDirectory(at: notAWorktree, withIntermediateDirectories: true) + + #expect(throws: (any Error).self) { + try SystemRepoCopyRemover().removeWorktree(at: notAWorktree, parentRepoPath: repo) + } + } + + // MARK: - Git helpers + + private func makeGitRepoWithCommit(at url: URL) throws { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + try git(["-C", url.path, "init", "-q"]) + try git(["-C", url.path, "config", "user.email", "test@example.com"]) + try git(["-C", url.path, "config", "user.name", "Test"]) + try Data("hello\n".utf8).write(to: url.appendingPathComponent("README.md")) + try git(["-C", url.path, "add", "."]) + try git(["-C", url.path, "commit", "-q", "-m", "init"]) + } + + @discardableResult + private func git(_ arguments: [String]) throws -> Int32 { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + return process.terminationStatus + } + + private func gitOutput(_ arguments: [String]) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/Foreman/ForemanCore/Tests/RepoSectionTests.swift b/Foreman/ForemanCore/Tests/RepoSectionTests.swift index b78e2418..0a3e796b 100644 --- a/Foreman/ForemanCore/Tests/RepoSectionTests.swift +++ b/Foreman/ForemanCore/Tests/RepoSectionTests.swift @@ -14,6 +14,7 @@ struct RepoSectionTests { isEnabled: enabled, isFavorite: favorite, options: .standard, + provenance: nil, worker: Worker( name: name, workerDirectory: root, diff --git a/Foreman/ForemanCore/Tests/RepoTests.swift b/Foreman/ForemanCore/Tests/RepoTests.swift index 949ce09c..62338235 100644 --- a/Foreman/ForemanCore/Tests/RepoTests.swift +++ b/Foreman/ForemanCore/Tests/RepoTests.swift @@ -20,6 +20,7 @@ struct RepoTests { isEnabled: false, isFavorite: false, options: .standard, + provenance: nil, worker: Worker( name: scanned.name, workerDirectory: scanned.rootURL, From f4712d8c364436427676ad76fa6a598835e70ba7 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:36:31 -0400 Subject: [PATCH 02/12] Serve the Foreman control socket from the app target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a thin ControlServer that owns a unix-domain socket at App Support/com.stuff.foreman/control.sock (JSON-lines), decodes each request line, calls ForemanCore's ControlRequestHandler on the main actor, and encodes the reply. A stale socket file is unlinked on start so a leftover never blocks binding. Wired through ForemanSession (startControlServer/stopControlServer) and driven by the app delegate — started in applicationDidFinishLaunching, torn down in applicationWillTerminate. ForemanSession also gains removeCopy(_:) and an actionError channel for the upcoming Remove-copy UI. Co-authored-by: Cursor --- Foreman/Foreman/Sources/ControlServer.swift | 202 +++++++++++++++++++ Foreman/Foreman/Sources/ForemanApp.swift | 3 + Foreman/Foreman/Sources/ForemanSession.swift | 47 +++++ 3 files changed, 252 insertions(+) create mode 100644 Foreman/Foreman/Sources/ControlServer.swift diff --git a/Foreman/Foreman/Sources/ControlServer.swift b/Foreman/Foreman/Sources/ControlServer.swift new file mode 100644 index 00000000..b4e51139 --- /dev/null +++ b/Foreman/Foreman/Sources/ControlServer.swift @@ -0,0 +1,202 @@ +import Darwin +import ForemanCore +import Foundation + +/// A tiny newline-delimited-JSON server over a Unix domain socket. +/// +/// Each connection carries exactly one ``ControlRequest`` line and gets one +/// ``ControlResponse`` line back, then closes. The only client is the +/// `foreman-mcp` server; access control is the socket file's `0600` +/// permissions (Foreman isn't sandboxed), so there's no port or token to +/// juggle. +/// +/// Raw socket syscalls run on a background queue; each decoded request hops to +/// the main actor to reach ``ControlRequestHandler`` (and thus +/// `ForemanServices`), and the reply is written back off the main actor. +@MainActor +final class ControlServer { + private let socketURL: URL + private let handler: ControlRequestHandler + private let queue = DispatchQueue(label: "com.stuff.foreman.control", qos: .utility) + private var listenFD: Int32 = -1 + private var acceptSource: DispatchSourceRead? + + private static let logger = ForemanLog.channel(.app) + + init(socketURL: URL, handler: ControlRequestHandler) { + self.socketURL = socketURL + self.handler = handler + } + + /// Binds and listens on the socket. A failure is logged and leaves the + /// server inert — the MCP degrades gracefully when Foreman is unreachable, + /// so a bind failure must never take the app down. + func start() { + guard listenFD < 0 else { return } + // Writing to a socket the peer already closed would otherwise raise + // SIGPIPE and kill the process. + signal(SIGPIPE, SIG_IGN) + + let path = socketURL.path + guard path.utf8.count < 104 else { + Self.logger.error("Control socket path too long for sun_path: \(path)") + return + } + + do { + try FileManager.default.createDirectory( + at: socketURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + } catch { + Self.logger.error("Couldn't create control socket directory: \(error)") + return + } + // A stale socket file from a previous run blocks bind with EADDRINUSE. + unlink(path) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + Self.logger.error("Couldn't create control socket: errno \(errno)") + return + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let sunPathCapacity = MemoryLayout.size(ofValue: address.sun_path) + _ = withUnsafeMutablePointer(to: &address.sun_path.0) { destination in + path.withCString { source in + strncpy(destination, source, sunPathCapacity - 1) + } + } + let size = socklen_t(MemoryLayout.size) + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, size) } + } + guard bound == 0 else { + Self.logger.error("Couldn't bind control socket: errno \(errno)") + close(fd) + return + } + chmod(path, 0o600) + + guard listen(fd, 8) == 0 else { + Self.logger.error("Couldn't listen on control socket: errno \(errno)") + close(fd) + unlink(path) + return + } + + listenFD = fd + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + source.setEventHandler { [weak self] in + let clientFD = accept(fd, nil, nil) + guard clientFD >= 0 else { return } + var noSigpipe: Int32 = 1 + setsockopt( + clientFD, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size), + ) + self?.serve(clientFD) + } + source.setCancelHandler { + close(fd) + } + acceptSource = source + source.resume() + Self.logger.info("Control socket listening at \(path)") + } + + /// Stops listening and removes the socket file. + func stop() { + acceptSource?.cancel() + acceptSource = nil + listenFD = -1 + unlink(socketURL.path) + } + + /// Reads one request line off `clientFD` (already on the background + /// queue), handles it on the main actor, and writes the reply back off the + /// main actor. A malformed line still gets a `.failure` reply so the + /// client never hangs waiting. + private nonisolated func serve(_ clientFD: Int32) { + guard let line = Self.readLine(clientFD), !line.isEmpty else { + close(clientFD) + return + } + + let request: ControlRequest + do { + request = try JSONDecoder().decode(ControlRequest.self, from: line) + } catch { + Self.write( + clientFD, + Self.encode(.failure(message: "Malformed request: \(error.localizedDescription)")), + ) + close(clientFD) + return + } + + Task { @MainActor [weak self] in + guard let self else { + close(clientFD) + return + } + let response = await handler.handle(request) + let data = Self.encode(response) + queue.async { + Self.write(clientFD, data) + close(clientFD) + } + } + } + + private nonisolated static func encode(_ response: ControlResponse) -> Data { + (try? JSONEncoder().encode(response)) ?? + Data(#"{"ok":false,"error":"encoding failed"}"#.utf8) + } + + /// Reads bytes up to (and consuming) a newline, returning the bytes before + /// it; `nil` on error. Requests are tiny, so byte-at-a-time is fine. + private nonisolated static func readLine(_ fd: Int32) -> Data? { + var data = Data() + var byte: UInt8 = 0 + while true { + let count = read(fd, &byte, 1) + if count == 1 { + if byte == 0x0A { return data } + data.append(byte) + if data.count > 1_048_576 { return nil } + } else if count == 0 { + return data + } else if errno == EINTR { + continue + } else { + return nil + } + } + } + + private nonisolated static func write(_ fd: Int32, _ payload: Data) { + var data = payload + data.append(0x0A) + data.withUnsafeBytes { raw in + guard var base = raw.bindMemory(to: UInt8.self).baseAddress else { return } + var remaining = raw.count + while remaining > 0 { + let written = Darwin.write(fd, base, remaining) + if written > 0 { + base += written + remaining -= written + } else if written < 0, errno == EINTR { + continue + } else { + return + } + } + } + } +} diff --git a/Foreman/Foreman/Sources/ForemanApp.swift b/Foreman/Foreman/Sources/ForemanApp.swift index 19577221..26695822 100644 --- a/Foreman/Foreman/Sources/ForemanApp.swift +++ b/Foreman/Foreman/Sources/ForemanApp.swift @@ -30,6 +30,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func applicationDidFinishLaunching(_: Notification) { session.start() + // Listen for MCP control requests (spin up / list / remove copies). + session.startControlServer() let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) item.button?.target = self @@ -50,6 +52,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { /// Stop-on-quit lifecycle: Foreman owns its worker processes, so quitting /// the app tears them all down rather than leaving orphans. func applicationWillTerminate(_: Notification) { + session.stopControlServer() session.stopAllWorkers() } diff --git a/Foreman/Foreman/Sources/ForemanSession.swift b/Foreman/Foreman/Sources/ForemanSession.swift index 167e2013..ca08201a 100644 --- a/Foreman/Foreman/Sources/ForemanSession.swift +++ b/Foreman/Foreman/Sources/ForemanSession.swift @@ -11,6 +11,11 @@ import Observation final class ForemanSession { let services: ForemanServices + /// The local control socket the `foreman-mcp` server talks to. Started + /// after launch, torn down on quit. `nil` until started (and if binding + /// fails, so a socket problem never blocks the app). + @ObservationIgnored private var controlServer: ControlServer? + /// The discovered repositories, sorted by name — the observable source /// of truth the sidebar and detail bind to. var repos: [Repo] { @@ -82,6 +87,48 @@ final class ForemanSession { services.stopAll() } + /// Starts listening on the MCP control socket. Called once after launch. + func startControlServer() { + guard controlServer == nil else { return } + let server = ControlServer( + socketURL: ForemanServices.controlSocketURL, + handler: ControlRequestHandler(services: services), + ) + server.start() + controlServer = server + } + + /// Stops the control socket and removes its file; part of the quit path. + func stopControlServer() { + controlServer?.stop() + controlServer = nil + } + + /// Removes the copy at `repo` (stops its worker, then git-removes a + /// worktree or trashes a clone). UI intent for the Remove-copy action; + /// surfaces any failure on ``actionError`` rather than throwing into the + /// view. + func removeCopy(_ repo: Repo) async { + do { + try await services.removeCopy(at: repo.rootURL) + } catch { + actionError = error.localizedDescription + } + } + + /// The most recent failure from a user-triggered action (currently + /// Remove-copy). Shown as an alert; cleared when dismissed. + private(set) var actionError: String? + + /// Two-way binding for the action-error alert: reading tells the alert + /// whether to show, setting it `false` (dismiss) clears the message. Keeps + /// ``actionError`` the single source of truth rather than a view-owned + /// mirror. + var isShowingActionError: Bool { + get { actionError != nil } + set { if !newValue { actionError = nil } } + } + /// Re-lists the scan directory, preserving live workers for repos that /// remain. func rescan() { From af504b6d2c44d2177113c8a9ef9c1fbd694da3c6 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:36:46 -0400 Subject: [PATCH 03/12] Add the foreman-mcp stdio server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Node/TypeScript MCP server (the repo's only non-Swift module) that lets a Cursor agent spin up copies of the repo it's in and hand them to Foreman. - spinup_repo_copy(mode, ...): create a git worktree or clone as a sibling under Foreman's scan directory (queried over the control socket, falling back to the source's parent), then adopt it so Foreman starts a worker. When Foreman is down the git copy still succeeds and the reply says plainly that no worker started — never a silent success. - list_repos / remove_repo_copy: delegate to Foreman so removal is identical whether triggered here or from the app UI. Creation git runs locally via execFile (git located off the known install paths, since GUI-launched processes don't inherit PATH); the control client mirrors ControlProtocol.swift over JSON-lines. stdout is reserved for JSON-RPC, so all logging goes to stderr. Covered by smoke + integration tests against a mock socket and a temp git repo. Co-authored-by: Cursor --- Foreman/foreman-mcp/.gitignore | 3 + Foreman/foreman-mcp/package-lock.json | 1215 ++++++++++++++++++++ Foreman/foreman-mcp/package.json | 32 + Foreman/foreman-mcp/src/control.ts | 147 +++ Foreman/foreman-mcp/src/git.ts | 106 ++ Foreman/foreman-mcp/src/index.ts | 260 +++++ Foreman/foreman-mcp/test/describe-live.mjs | 11 + Foreman/foreman-mcp/test/integration.mjs | 162 +++ Foreman/foreman-mcp/test/smoke.mjs | 55 + Foreman/foreman-mcp/tsconfig.json | 20 + 10 files changed, 2011 insertions(+) create mode 100644 Foreman/foreman-mcp/.gitignore create mode 100644 Foreman/foreman-mcp/package-lock.json create mode 100644 Foreman/foreman-mcp/package.json create mode 100644 Foreman/foreman-mcp/src/control.ts create mode 100644 Foreman/foreman-mcp/src/git.ts create mode 100644 Foreman/foreman-mcp/src/index.ts create mode 100644 Foreman/foreman-mcp/test/describe-live.mjs create mode 100644 Foreman/foreman-mcp/test/integration.mjs create mode 100644 Foreman/foreman-mcp/test/smoke.mjs create mode 100644 Foreman/foreman-mcp/tsconfig.json diff --git a/Foreman/foreman-mcp/.gitignore b/Foreman/foreman-mcp/.gitignore new file mode 100644 index 00000000..3c25e1e4 --- /dev/null +++ b/Foreman/foreman-mcp/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/Foreman/foreman-mcp/package-lock.json b/Foreman/foreman-mcp/package-lock.json new file mode 100644 index 00000000..02a69242 --- /dev/null +++ b/Foreman/foreman-mcp/package-lock.json @@ -0,0 +1,1215 @@ +{ + "name": "foreman-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "foreman-mcp", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.4.3" + }, + "bin": { + "foreman-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^26.1.0", + "typescript": "^6.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/Foreman/foreman-mcp/package.json b/Foreman/foreman-mcp/package.json new file mode 100644 index 00000000..9c938007 --- /dev/null +++ b/Foreman/foreman-mcp/package.json @@ -0,0 +1,32 @@ +{ + "name": "foreman-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "MCP server that spins up git worktree/clone copies of repos and hands them to Foreman to run as cursor-agent workers.", + "bin": { + "foreman-mcp": "dist/index.js" + }, + "main": "dist/index.js", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "watch": "tsc -p tsconfig.json --watch", + "start": "node dist/index.js", + "smoke": "node test/smoke.mjs", + "test": "node test/smoke.mjs && node test/integration.mjs" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^26.1.0", + "typescript": "^6.0.3" + } +} diff --git a/Foreman/foreman-mcp/src/control.ts b/Foreman/foreman-mcp/src/control.ts new file mode 100644 index 00000000..f3cbdadb --- /dev/null +++ b/Foreman/foreman-mcp/src/control.ts @@ -0,0 +1,147 @@ +import net from "node:net"; +import { homedir } from "node:os"; +import path from "node:path"; + +/// The wire contract mirrors `ForemanCore/Sources/ControlProtocol.swift`. +/// Keep the two in sync when either side changes. + +export interface CopyProvenanceWire { + kind: "worktree" | "clone"; + parentRepoID: string; + branch: string; +} + +export interface RepoStatus { + id: string; + name: string; + path: string; + enabled: boolean; + workerState: string; + pid?: number; + failureReason?: string; + provenance?: CopyProvenanceWire; +} + +export interface DescribeResult { + scanDirectory: string; + repos: RepoStatus[]; +} + +export type ControlRequest = + | { command: "describe" } + | { command: "adopt"; path: string; provenance: CopyProvenanceWire } + | { command: "removeCopy"; path: string }; + +type ControlResponse = + | { ok: true; kind: "describe"; describe: DescribeResult } + | { ok: true; kind: "repo"; repo: RepoStatus } + | { ok: true; kind: "removed"; path: string } + | { ok: false; error: string }; + +/// Thrown when Foreman isn't running / the socket can't be reached — callers +/// degrade gracefully (the git copy still succeeds, just no worker starts). +export class ForemanUnavailableError extends Error {} + +/// Thrown when Foreman replied but reported a failure (`ok: false`). +export class ForemanError extends Error {} + +export function controlSocketPath(): string { + return ( + process.env.FOREMAN_CONTROL_SOCKET ?? + path.join(homedir(), "Library/Application Support/com.stuff.foreman/control.sock") + ); +} + +function sendControlRequest( + request: ControlRequest, + timeoutMs = 20_000, +): Promise { + const socketPath = controlSocketPath(); + return new Promise((resolve, reject) => { + const socket = net.createConnection({ path: socketPath }); + let buffer = ""; + let settled = false; + + const finish = (action: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + action(); + }; + + const timer = setTimeout(() => { + finish(() => + reject(new ForemanUnavailableError(`Foreman didn't respond within ${timeoutMs}ms.`)), + ); + }, timeoutMs); + + socket.on("connect", () => { + socket.write(`${JSON.stringify(request)}\n`); + }); + + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + const newline = buffer.indexOf("\n"); + if (newline < 0) return; + const line = buffer.slice(0, newline); + finish(() => { + try { + resolve(JSON.parse(line) as ControlResponse); + } catch (error) { + reject(new ForemanError(`Couldn't parse Foreman's reply: ${String(error)}`)); + } + }); + }); + + socket.on("error", (error: NodeJS.ErrnoException) => { + finish(() => { + if (error.code === "ENOENT" || error.code === "ECONNREFUSED") { + reject( + new ForemanUnavailableError( + "Foreman isn't running (its control socket is unavailable).", + ), + ); + } else { + reject(new ForemanUnavailableError(`Couldn't reach Foreman: ${error.message}`)); + } + }); + }); + + socket.on("end", () => { + finish(() => + reject(new ForemanError("Foreman closed the connection without a reply.")), + ); + }); + }); +} + +export async function describe(): Promise { + const response = await sendControlRequest({ command: "describe" }); + if (response.ok && response.kind === "describe") return response.describe; + throw failureFor(response, "describe"); +} + +export async function adopt( + copyPath: string, + provenance: CopyProvenanceWire, +): Promise { + const response = await sendControlRequest({ + command: "adopt", + path: copyPath, + provenance, + }); + if (response.ok && response.kind === "repo") return response.repo; + throw failureFor(response, "adopt"); +} + +export async function removeCopy(copyPath: string): Promise { + const response = await sendControlRequest({ command: "removeCopy", path: copyPath }); + if (response.ok && response.kind === "removed") return response.path; + throw failureFor(response, "removeCopy"); +} + +function failureFor(response: ControlResponse, command: string): Error { + if (!response.ok) return new ForemanError(response.error); + return new ForemanError(`Unexpected reply to ${command}: ${JSON.stringify(response)}`); +} diff --git a/Foreman/foreman-mcp/src/git.ts b/Foreman/foreman-mcp/src/git.ts new file mode 100644 index 00000000..857c680c --- /dev/null +++ b/Foreman/foreman-mcp/src/git.ts @@ -0,0 +1,106 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +// GUI-launched processes (which is how Cursor spawns this server) don't +// inherit the shell PATH, so locate git at its known install paths, falling +// back to the PATH lookup. +const GIT_SEARCH_PATHS = ["/usr/bin/git", "/opt/homebrew/bin/git", "/usr/local/bin/git"]; + +let cachedGitPath: string | undefined; + +function gitPath(): string { + if (cachedGitPath) return cachedGitPath; + cachedGitPath = GIT_SEARCH_PATHS.find((candidate) => existsSync(candidate)) ?? "git"; + return cachedGitPath; +} + +/// A git invocation that exited non-zero, carrying its stderr so the reason +/// isn't swallowed. +export class GitError extends Error {} + +async function git(args: string[], cwd?: string): Promise { + try { + const { stdout } = await execFileAsync(gitPath(), args, { + cwd, + maxBuffer: 32 * 1024 * 1024, + }); + return stdout.trim(); + } catch (error) { + const stderr = (error as { stderr?: string }).stderr?.trim(); + throw new GitError(stderr && stderr.length > 0 ? stderr : String(error)); + } +} + +export async function isGitRepo(dir: string): Promise { + try { + return (await git(["-C", dir, "rev-parse", "--is-inside-work-tree"])) === "true"; + } catch { + return false; + } +} + +/// The repository root that contains `dir`. +export async function repoToplevel(dir: string): Promise { + return git(["-C", dir, "rev-parse", "--show-toplevel"]); +} + +export async function branchExists(repo: string, branch: string): Promise { + try { + await git(["-C", repo, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]); + return true; + } catch { + return false; + } +} + +export async function originURL(repo: string): Promise { + try { + return await git(["-C", repo, "remote", "get-url", "origin"]); + } catch { + return undefined; + } +} + +/// Adds a worktree at `dest`. A new branch is created (`-b`) unless `branch` +/// already exists, in which case it's checked out into the worktree. +export async function addWorktree( + repo: string, + dest: string, + branch: string, + baseRef: string | undefined, +): Promise { + if (await branchExists(repo, branch)) { + await git(["-C", repo, "worktree", "add", dest, branch]); + } else { + const args = ["-C", repo, "worktree", "add", "-b", branch, dest]; + if (baseRef) args.push(baseRef); + await git(args); + } +} + +export async function cloneRepo(source: string, dest: string): Promise { + await git(["clone", source, dest]); +} + +/// Ensures `branch` is checked out in `repo`, creating it (from `baseRef` when +/// given) if it doesn't exist yet. +export async function checkoutBranch( + repo: string, + branch: string, + baseRef: string | undefined, +): Promise { + if (await branchExists(repo, branch)) { + await git(["-C", repo, "checkout", branch]); + } else { + const args = ["-C", repo, "checkout", "-b", branch]; + if (baseRef) args.push(baseRef); + await git(args); + } +} + +export async function setOriginURL(repo: string, url: string): Promise { + await git(["-C", repo, "remote", "set-url", "origin", url]); +} diff --git a/Foreman/foreman-mcp/src/index.ts b/Foreman/foreman-mcp/src/index.ts new file mode 100644 index 00000000..048f5c19 --- /dev/null +++ b/Foreman/foreman-mcp/src/index.ts @@ -0,0 +1,260 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { z } from "zod"; +import { + adopt, + type CopyProvenanceWire, + describe, + ForemanUnavailableError, + removeCopy, +} from "./control.js"; +import { + addWorktree, + checkoutBranch, + cloneRepo, + isGitRepo, + originURL, + repoToplevel, + setOriginURL, +} from "./git.js"; + +// stdio transport: stdout carries the JSON-RPC protocol, so all diagnostics go +// to stderr only. +function log(message: string): void { + process.stderr.write(`[foreman-mcp] ${message}\n`); +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +type TextResult = { + content: { type: "text"; text: string }[]; + isError?: boolean; +}; + +function ok(text: string): TextResult { + return { content: [{ type: "text", text }] }; +} + +function fail(text: string): TextResult { + return { content: [{ type: "text", text }], isError: true }; +} + +/// Turns arbitrary text into a safe single directory-name segment. +function slug(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); +} + +function shortId(): string { + return Date.now().toString(36).slice(-6); +} + +/// Picks a destination path that doesn't collide, auto-suffixing `-2`, `-3`, … +function uniqueDestination(scanDir: string, name: string): string { + let candidate = path.join(scanDir, name); + let suffix = 2; + while (existsSync(candidate)) { + candidate = path.join(scanDir, `${name}-${suffix}`); + suffix += 1; + } + return candidate; +} + +const server = new McpServer({ name: "foreman-mcp", version: "0.1.0" }); + +server.registerTool( + "foreman_ping", + { + title: "Foreman ping", + description: + "Health check for the Foreman MCP server. Returns 'pong' so you can confirm the server is reachable from this session.", + inputSchema: { message: z.string().optional() }, + }, + async ({ message: text }) => ok(`pong${text ? `: ${text}` : ""}`), +); + +server.registerTool( + "spinup_repo_copy", + { + title: "Spin up a repo copy", + description: + "Create another working copy of a git repository — a lightweight `git worktree` or a full `git clone` — as a sibling directory under Foreman's scan directory, and (by default) have Foreman start a cursor-agent worker on it so it's immediately available for a new task. Pass `sourceRepo` as the absolute path of the repo you're working in.", + inputSchema: { + mode: z + .enum(["worktree", "clone"]) + .describe( + "worktree = fast, shares the object store, one branch per copy; clone = a fully independent copy.", + ), + sourceRepo: z + .string() + .optional() + .describe("Absolute path to the source repo (defaults to the server's working directory)."), + name: z + .string() + .optional() + .describe("Directory name for the copy (defaults to '-')."), + branch: z + .string() + .optional() + .describe("Branch to check out in the copy (defaults to 'foreman/')."), + baseRef: z + .string() + .optional() + .describe("Ref the new branch starts from (defaults to the source's current HEAD)."), + enable: z + .boolean() + .default(true) + .describe("Whether Foreman should start a worker on the copy."), + }, + }, + async ({ mode, sourceRepo, name, branch, baseRef, enable }) => { + const source = path.resolve(sourceRepo ?? process.cwd()); + if (!(await isGitRepo(source))) { + return fail( + `'${source}' isn't a git repository. Pass 'sourceRepo' as the absolute path of the repo you're working in.`, + ); + } + if (name && name.includes("/")) { + return fail(`'name' must be a single directory name, not a path (got '${name}').`); + } + + let sourceTop: string; + try { + sourceTop = await repoToplevel(source); + } catch (error) { + return fail(`Couldn't resolve the repo root of '${source}': ${message(error)}`); + } + + // Prefer Foreman's own scan directory so the copy is discovered; fall back + // to the source's parent when Foreman isn't running. + let scanDir: string; + let foremanReachable = true; + try { + scanDir = (await describe()).scanDirectory; + } catch (error) { + if (error instanceof ForemanUnavailableError) { + foremanReachable = false; + scanDir = path.dirname(sourceTop); + } else { + return fail(`Couldn't query Foreman: ${message(error)}`); + } + } + + const baseName = path.basename(sourceTop); + const resolvedName = name ?? `${baseName}-${branch ? slug(branch) : shortId()}`; + const resolvedBranch = branch ?? `foreman/${slug(resolvedName)}`; + const dest = uniqueDestination(scanDir, resolvedName); + + try { + if (mode === "worktree") { + await addWorktree(sourceTop, dest, resolvedBranch, baseRef); + } else { + await cloneRepo(sourceTop, dest); + await checkoutBranch(dest, resolvedBranch, baseRef); + // The clone's origin points at the local source; repoint it at the + // source's own upstream when there is one, so it isn't a dead local + // path. + const upstream = await originURL(sourceTop); + if (upstream) await setOriginURL(dest, upstream); + } + } catch (error) { + return fail(`Couldn't create the ${mode}: ${message(error)}`); + } + + log(`Created ${mode} at ${dest} on branch ${resolvedBranch}`); + const created = `Created a ${mode} at ${dest} on branch '${resolvedBranch}'`; + const provenance: CopyProvenanceWire = { + kind: mode, + parentRepoID: sourceTop, + branch: resolvedBranch, + }; + + if (!enable) { + return ok(`${created}. Worker not started (enable=false).`); + } + if (!foremanReachable) { + return ok( + `${created}, but Foreman isn't running, so no worker was started. Launch Foreman (or enable this repo in it) to run a worker here.`, + ); + } + try { + const status = await adopt(dest, provenance); + return ok(`${created}, and Foreman's worker for it is ${status.workerState}.`); + } catch (error) { + if (error instanceof ForemanUnavailableError) { + return ok(`${created}, but Foreman became unreachable, so no worker was started: ${message(error)}`); + } + return fail(`${created}, but Foreman couldn't start a worker: ${message(error)}`); + } + }, +); + +server.registerTool( + "list_repos", + { + title: "List Foreman repos", + description: + "List the repositories Foreman knows about, each with its worker state and (for copies) how it was created.", + inputSchema: {}, + }, + async () => { + let info; + try { + info = await describe(); + } catch (error) { + if (error instanceof ForemanUnavailableError) { + return fail(`Foreman isn't running: ${message(error)}`); + } + return fail(`Couldn't query Foreman: ${message(error)}`); + } + + const lines = [`Foreman scan directory: ${info.scanDirectory}`, `Repos (${info.repos.length}):`]; + for (const repo of info.repos) { + let line = `- ${repo.name} [${repo.workerState}${repo.pid ? ` pid ${repo.pid}` : ""}]`; + if (repo.provenance) { + const parent = path.basename(repo.provenance.parentRepoID); + line += ` — ${repo.provenance.kind} of ${parent} on ${repo.provenance.branch}`; + } + lines.push(line); + } + return ok(lines.join("\n")); + }, +); + +server.registerTool( + "remove_repo_copy", + { + title: "Remove a repo copy", + description: + "Remove a copy Foreman created: it stops the worker, then removes the worktree (git) or moves the clone to the Trash. Only works on copies made via spinup_repo_copy.", + inputSchema: { + path: z.string().describe("Absolute path of the copy to remove."), + }, + }, + async ({ path: target }) => { + const resolved = path.resolve(target); + try { + const removed = await removeCopy(resolved); + return ok(`Removed the copy at ${removed}.`); + } catch (error) { + if (error instanceof ForemanUnavailableError) { + return fail( + `Foreman must be running to remove a copy (it stops the worker first): ${message(error)}`, + ); + } + return fail(`Couldn't remove the copy: ${message(error)}`); + } + }, +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +log("server ready"); diff --git a/Foreman/foreman-mcp/test/describe-live.mjs b/Foreman/foreman-mcp/test/describe-live.mjs new file mode 100644 index 00000000..9c887acd --- /dev/null +++ b/Foreman/foreman-mcp/test/describe-live.mjs @@ -0,0 +1,11 @@ +// Dev helper: query a *running* Foreman's control socket and print describe(). +// Requires Foreman to be running. Uses FOREMAN_CONTROL_SOCKET or the default. +import { describe } from "../dist/control.js"; + +try { + const info = await describe(); + console.log(JSON.stringify(info, null, 2)); +} catch (error) { + console.error("describe failed:", error.message); + process.exit(1); +} diff --git a/Foreman/foreman-mcp/test/integration.mjs b/Foreman/foreman-mcp/test/integration.mjs new file mode 100644 index 00000000..68bd127a --- /dev/null +++ b/Foreman/foreman-mcp/test/integration.mjs @@ -0,0 +1,162 @@ +// End-to-end test of the tools against a *mock* Foreman control socket and a +// real temp git repo — no Foreman app or Cursor session required. Verifies the +// git side actually happens on disk and that the right control requests are +// sent. Run with `npm test` after `npm run build`. +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const serverEntry = path.join(here, "..", "dist", "index.js"); + +function assert(condition, msg) { + if (!condition) throw new Error(`Assertion failed: ${msg}`); +} + +function textOf(result) { + return (result.content ?? []) + .filter((b) => b.type === "text") + .map((b) => b.text) + .join(""); +} + +const tmp = mkdtempSync(path.join(tmpdir(), "foreman-mcp-it-")); +const scanDir = path.join(tmp, "Development"); +mkdirSync(scanDir, { recursive: true }); +const source = path.join(scanDir, "Main"); +const socketPath = path.join(tmp, "control.sock"); + +// A real git repo with one commit so `worktree add` works. +const git = (args, cwd) => execFileSync("/usr/bin/git", args, { cwd, stdio: "ignore" }); +mkdirSync(source, { recursive: true }); +git(["init", "-q"], source); +git(["config", "user.email", "t@example.com"], source); +git(["config", "user.name", "Test"], source); +execFileSync("/bin/sh", ["-c", "echo hi > README.md"], { cwd: source }); +git(["add", "."], source); +git(["commit", "-q", "-m", "init"], source); + +// Mock Foreman: records requests and returns plausible responses. +const received = []; +const mock = net.createServer((socket) => { + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + const nl = buffer.indexOf("\n"); + if (nl < 0) return; + const request = JSON.parse(buffer.slice(0, nl)); + received.push(request); + let response; + if (request.command === "describe") { + response = { + ok: true, + kind: "describe", + describe: { + scanDirectory: scanDir, + repos: [ + { + id: source, + name: "Main", + path: source, + enabled: false, + workerState: "stopped", + }, + ], + }, + }; + } else if (request.command === "adopt") { + response = { + ok: true, + kind: "repo", + repo: { + id: request.path, + name: path.basename(request.path), + path: request.path, + enabled: true, + workerState: "running", + pid: 4242, + provenance: request.provenance, + }, + }; + } else if (request.command === "removeCopy") { + response = { ok: true, kind: "removed", path: request.path }; + } else { + response = { ok: false, error: `unknown command ${request.command}` }; + } + socket.end(`${JSON.stringify(response)}\n`); + }); +}); + +async function run() { + await new Promise((resolve) => mock.listen(socketPath, resolve)); + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [serverEntry], + env: { ...process.env, FOREMAN_CONTROL_SOCKET: socketPath }, + }); + const client = new Client({ name: "foreman-mcp-it", version: "0.0.0" }); + await client.connect(transport); + + // 1. Spin up a worktree. + const spinup = await client.callTool({ + name: "spinup_repo_copy", + arguments: { mode: "worktree", sourceRepo: source, name: "Main-copy", branch: "task" }, + }); + const spinupText = textOf(spinup); + console.error(`spinup_repo_copy -> ${spinupText}`); + assert(!spinup.isError, "spinup did not error"); + const copyPath = path.join(scanDir, "Main-copy"); + assert(existsSync(path.join(copyPath, ".git")), "worktree .git exists on disk"); + assert(spinupText.includes("running"), "worker reported running"); + + const adoptReq = received.find((r) => r.command === "adopt"); + assert(adoptReq, "an adopt request was sent"); + assert(adoptReq.path === copyPath, `adopt path is the copy (${adoptReq.path})`); + assert(adoptReq.provenance.kind === "worktree", "adopt provenance kind is worktree"); + assert(adoptReq.provenance.branch === "task", "adopt provenance branch is task"); + assert(adoptReq.provenance.parentRepoID.endsWith("/Main"), "adopt parent is Main"); + + // Worktree really is on the requested branch. + const branch = execFileSync("/usr/bin/git", ["-C", copyPath, "branch", "--show-current"]) + .toString() + .trim(); + assert(branch === "task", `worktree is on branch task (got ${branch})`); + + // 2. List repos. + const list = await client.callTool({ name: "list_repos", arguments: {} }); + const listText = textOf(list); + console.error(`list_repos ->\n${listText}`); + assert(listText.includes("Main"), "list includes Main"); + + // 3. Remove the copy. + const remove = await client.callTool({ + name: "remove_repo_copy", + arguments: { path: copyPath }, + }); + const removeText = textOf(remove); + console.error(`remove_repo_copy -> ${removeText}`); + assert(!remove.isError, "remove did not error"); + const removeReq = received.find((r) => r.command === "removeCopy"); + assert(removeReq && removeReq.path === copyPath, "removeCopy sent for the copy path"); + + await client.close(); + console.error("INTEGRATION OK"); +} + +let exitCode = 0; +try { + await run(); +} catch (error) { + console.error("INTEGRATION FAILED:", error); + exitCode = 1; +} finally { + mock.close(); + rmSync(tmp, { recursive: true, force: true }); + process.exit(exitCode); +} diff --git a/Foreman/foreman-mcp/test/smoke.mjs b/Foreman/foreman-mcp/test/smoke.mjs new file mode 100644 index 00000000..6d842820 --- /dev/null +++ b/Foreman/foreman-mcp/test/smoke.mjs @@ -0,0 +1,55 @@ +// Smoke test: launches the built server over stdio and exercises the MCP +// handshake end to end (initialize -> tools/list -> tools/call). Run with +// `npm run smoke` after `npm run build`. Exits non-zero on any failure so it +// can gate CI. +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const serverEntry = join(here, "..", "dist", "index.js"); + +function assert(condition, message) { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} + +const transport = new StdioClientTransport({ + command: process.execPath, + args: [serverEntry], +}); +const client = new Client({ name: "foreman-mcp-smoke", version: "0.0.0" }); + +try { + await client.connect(transport); + + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name); + console.error(`tools/list -> ${names.join(", ")}`); + assert(names.includes("foreman_ping"), "foreman_ping tool is listed"); + + const result = await client.callTool({ + name: "foreman_ping", + arguments: { message: "smoke" }, + }); + const text = (result.content ?? []) + .filter((b) => b.type === "text") + .map((b) => b.text) + .join(""); + console.error(`foreman_ping -> ${text}`); + assert(text.includes("pong"), "foreman_ping returns pong"); + + console.error("SMOKE OK"); + await client.close(); + process.exit(0); +} catch (error) { + console.error("SMOKE FAILED:", error); + try { + await client.close(); + } catch { + // ignore + } + process.exit(1); +} diff --git a/Foreman/foreman-mcp/tsconfig.json b/Foreman/foreman-mcp/tsconfig.json new file mode 100644 index 00000000..b6f60176 --- /dev/null +++ b/Foreman/foreman-mcp/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": false, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +} From a0e33ebe604bd43431b5cf0ffeb90e16a5ad0db8 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:36:52 -0400 Subject: [PATCH 04/12] Show copy provenance and a Remove-copy action in Foreman MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For repos Foreman created as copies, surface their origin and offer removal — the view stays thin, with provenance mapping in CopyProvenanceDisplay and the removal logic already in Core. - Sidebar row: a worktree/clone badge whose tooltip names the parent repo and branch. - Worker detail: a Copy section (kind / copied-from / branch) plus a Remove Copy… button behind a confirmation dialog that calls ForemanSession.removeCopy(_:). - The main window shows an alert bound to the session's action-error channel when a removal fails. New user-facing copy goes through the generated-symbol catalog. Co-authored-by: Cursor --- .../Foreman/Resources/Localizable.xcstrings | 168 ++++++++++++++++++ .../Sources/CopyProvenanceDisplay.swift | 27 +++ Foreman/Foreman/Sources/MainWindowView.swift | 14 +- .../Foreman/Sources/WorkerDetailView.swift | 35 +++- Foreman/Foreman/Sources/WorkerRowView.swift | 11 ++ 5 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 Foreman/Foreman/Sources/CopyProvenanceDisplay.swift diff --git a/Foreman/Foreman/Resources/Localizable.xcstrings b/Foreman/Foreman/Resources/Localizable.xcstrings index 74ddd251..9db1f7b6 100644 --- a/Foreman/Foreman/Resources/Localizable.xcstrings +++ b/Foreman/Foreman/Resources/Localizable.xcstrings @@ -85,6 +85,174 @@ } } }, + "action.error.title" : { + "comment" : "Title of the alert shown when a user-triggered action (e.g. Remove Copy) fails.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't complete the action" + } + } + } + }, + "common.ok" : { + "comment" : "Button that dismisses an alert.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + }, + "detail.copy.branchLabel" : { + "comment" : "Label for the branch a repo copy is checked out on.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Branch" + } + } + } + }, + "detail.copy.header" : { + "comment" : "Section header for a repository copy's provenance and removal action.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy" + } + } + } + }, + "detail.copy.kindLabel" : { + "comment" : "Label for whether a copy is a worktree or a clone.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type" + } + } + } + }, + "detail.copy.parentLabel" : { + "comment" : "Label for the repository a copy was made from.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copied from" + } + } + } + }, + "detail.removeCopy" : { + "comment" : "Button that removes a repository copy Foreman created.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove Copy…" + } + } + } + }, + "detail.removeCopy.confirmButton" : { + "comment" : "Confirmation button that carries out removing a copy.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove Copy" + } + } + } + }, + "detail.removeCopy.confirmMessage" : { + "comment" : "Confirmation dialog body for removing a copy. The placeholder is the copy's name.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foreman stops the worker, then removes “%(name)@”. A worktree is removed with git; a full clone is moved to the Trash." + } + } + } + }, + "detail.removeCopy.confirmTitle" : { + "comment" : "Confirmation dialog title for removing a copy.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove this copy?" + } + } + } + }, + "detail.removeCopy.help" : { + "comment" : "Tooltip for the Remove Copy button.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop the worker and remove this copy." + } + } + } + }, + "provenance.clone" : { + "comment" : "Copy kind: a full git clone.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clone" + } + } + } + }, + "provenance.worktree" : { + "comment" : "Copy kind: a git worktree.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Worktree" + } + } + } + }, + "row.copy.help" : { + "comment" : "Tooltip on the copy badge in a sidebar row. First placeholder is the kind (Worktree/Clone), second the parent repo name, third the branch.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%(kind)@ of %(parent)@ on branch %(branch)@" + } + } + } + }, "detail.empty.description" : { "comment" : "Description of the empty detail pane shown when no repo is selected.", "extractionState" : "manual", diff --git a/Foreman/Foreman/Sources/CopyProvenanceDisplay.swift b/Foreman/Foreman/Sources/CopyProvenanceDisplay.swift new file mode 100644 index 00000000..4e8f6612 --- /dev/null +++ b/Foreman/Foreman/Sources/CopyProvenanceDisplay.swift @@ -0,0 +1,27 @@ +import ForemanCore +import Foundation + +/// Presentation helpers for a copy's provenance, shared by the sidebar row and +/// the worker detail so the mapping lives in one place and the views stay thin. +extension CopyProvenance { + /// The parent repository's display name (its directory name). + var parentName: String { + URL(fileURLWithPath: parentRepoID.rawValue).lastPathComponent + } + + /// Localized "Worktree"/"Clone". + var kindText: LocalizedStringResource { + switch kind { + case .worktree: .provenanceWorktree + case .clone: .provenanceClone + } + } + + /// SF Symbol that stands in for the copy kind. + var badgeSymbol: String { + switch kind { + case .worktree: "arrow.triangle.branch" + case .clone: "doc.on.doc" + } + } +} diff --git a/Foreman/Foreman/Sources/MainWindowView.swift b/Foreman/Foreman/Sources/MainWindowView.swift index 031f773f..35de9fae 100644 --- a/Foreman/Foreman/Sources/MainWindowView.swift +++ b/Foreman/Foreman/Sources/MainWindowView.swift @@ -5,7 +5,7 @@ import SwiftUI /// The main window: repos in the sidebar, the selected repo's worker detail /// on the right, global actions in the toolbar. struct MainWindowView: View { - let session: ForemanSession + @Bindable var session: ForemanSession @State private var selection: RepoID? @@ -15,7 +15,7 @@ struct MainWindowView: View { .navigationSplitViewColumnWidth(min: 200, ideal: 240) } detail: { if let repo = session.repos.first(where: { $0.id == selection }) { - WorkerDetailView(repo: repo) + WorkerDetailView(repo: repo, session: session) // Reset the detail's local state (options draft, log // tail) when the selection changes. .id(repo.id) @@ -30,6 +30,16 @@ struct MainWindowView: View { .frame(minWidth: 680, minHeight: 420) .safeAreaInset(edge: .top, spacing: 0) { issueBanner } .toolbar { toolbarContent } + .alert( + .actionErrorTitle, + isPresented: $session.isShowingActionError, + ) { + Button(.commonOk, role: .cancel) {} + } message: { + if let actionError = session.actionError { + Text(actionError) + } + } // First-open scan; later opens rescan via the window delegate's // windowDidBecomeKey (see AppDelegate). No file watching. .onAppear { diff --git a/Foreman/Foreman/Sources/WorkerDetailView.swift b/Foreman/Foreman/Sources/WorkerDetailView.swift index 58beb5c0..83853dd5 100644 --- a/Foreman/Foreman/Sources/WorkerDetailView.swift +++ b/Foreman/Foreman/Sources/WorkerDetailView.swift @@ -6,6 +6,9 @@ import SwiftUI /// options, and a live log tail. struct WorkerDetailView: View { @Bindable var repo: Repo + let session: ForemanSession + + @State private var isConfirmingRemoval = false var body: some View { let state = repo.worker.state @@ -38,6 +41,24 @@ struct WorkerDetailView: View { Toggle(.workerEnabledToggle, isOn: $repo.isEnabled) } + if let provenance = repo.provenance { + Section(.detailCopyHeader) { + LabeledContent(.detailCopyKindLabel) { + Text(provenance.kindText) + } + LabeledContent(.detailCopyParentLabel) { + Text(provenance.parentName).textSelection(.enabled) + } + LabeledContent(.detailCopyBranchLabel) { + Text(provenance.branch).textSelection(.enabled) + } + Button(.detailRemoveCopy, role: .destructive) { + isConfirmingRemoval = true + } + .help(.detailRemoveCopyHelp) + } + } + Section(.detailCommandHeader) { // What the next start will spawn (options apply at spawn). Text(commandPreview) @@ -63,6 +84,18 @@ struct WorkerDetailView: View { .formStyle(.grouped) .navigationTitle(repo.name) .navigationSubtitle(repo.rootURL.path) + .confirmationDialog( + .detailRemoveCopyConfirmTitle, + isPresented: $isConfirmingRemoval, + titleVisibility: .visible, + ) { + Button(.detailRemoveCopyConfirmButton, role: .destructive) { + Task { await session.removeCopy(repo) } + } + Button(.commonCancel, role: .cancel) {} + } message: { + Text(.detailRemoveCopyConfirmMessage(name: repo.name)) + } .toolbar { ToolbarItem { Button { @@ -122,7 +155,7 @@ struct WorkerDetailView: View { #if DEBUG #Preview { let session = PreviewSupport.populatedSession() - return WorkerDetailView(repo: session.repos[0]) + return WorkerDetailView(repo: session.repos[0], session: session) .frame(width: 460, height: 640) } #endif diff --git a/Foreman/Foreman/Sources/WorkerRowView.swift b/Foreman/Foreman/Sources/WorkerRowView.swift index 0d6c2ffa..0fdc8849 100644 --- a/Foreman/Foreman/Sources/WorkerRowView.swift +++ b/Foreman/Foreman/Sources/WorkerRowView.swift @@ -19,6 +19,17 @@ struct WorkerRowView: View { } } + if let provenance = repo.provenance { + Image(systemName: provenance.badgeSymbol) + .font(.caption2) + .foregroundStyle(.secondary) + .help(.rowCopyHelp( + kind: String(localized: provenance.kindText), + parent: provenance.parentName, + branch: provenance.branch, + )) + } + if repo.isFavorite { Image(systemName: "star.fill") .font(.caption2) From f7ce7cc3164d417cacd5e5d707961fbda16cddbc Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:37:02 -0400 Subject: [PATCH 05/12] Document the repo-cloning MCP and control socket Add foreman-mcp's README.md + AGENTS.md (new module) and update the Foreman app, ForemanCore, and root docs for the control socket, copy provenance, and the MCP intents. Run ./sync-agents to regenerate the (gitignored) CLAUDE.md files. Co-authored-by: Cursor --- AGENTS.md | 3 +- Foreman/Foreman/AGENTS.md | 10 +++ Foreman/Foreman/README.md | 25 ++++++- Foreman/ForemanCore/AGENTS.md | 20 +++++- Foreman/ForemanCore/README.md | 48 ++++++++++++-- Foreman/foreman-mcp/AGENTS.md | 53 +++++++++++++++ Foreman/foreman-mcp/README.md | 118 ++++++++++++++++++++++++++++++++++ 7 files changed, 267 insertions(+), 10 deletions(-) create mode 100644 Foreman/foreman-mcp/AGENTS.md create mode 100644 Foreman/foreman-mcp/README.md diff --git a/AGENTS.md b/AGENTS.md index f8c89530..e44fd4bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,8 +54,9 @@ by `./sync-agents`. ## Targets - **Package products** ([`Package.swift`](Package.swift)) — **StuffCore** ([`Shared/StuffCore/Sources/`](Shared/StuffCore/Sources/)), **LifecycleKit** ([`Shared/LifecycleKit/Sources/`](Shared/LifecycleKit/Sources/)), **LogKit** ([`Shared/LogKit/Sources/`](Shared/LogKit/Sources/), the logging facade), **LogViewerUI** ([`Shared/LogViewerUI/Sources/`](Shared/LogViewerUI/Sources/), the generic SwiftUI log viewer), and **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser) under [`Shared/`](Shared/); **WhereCore** / **WhereUI** / **WhereTesting** under [`Where/`](Where/); **ForemanCore** ([`Foreman/ForemanCore/Sources/`](Foreman/ForemanCore/Sources/), the only **macOS-only** package library, which processes a generated-symbol `Resources/Localizable.xcstrings`) under [`Foreman/`](Foreman/). -- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)), **RegionViewer** ([`Where/RegionViewer/`](Where/RegionViewer/), a thin standalone **Mac Catalyst** host for the WhereUI region-map developer tool — the only target with a `.macCatalyst` destination), **Foreman** ([`Foreman/Foreman/`](Foreman/Foreman/), the **native-macOS** menu bar app that runs Cursor local agent workers per repo; its user-facing copy is a generated-symbol `Resources/Localizable.xcstrings` via `STRING_CATALOG_GENERATE_SYMBOLS`), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), hosted **\*Tests** bundles (**StuffCoreTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **SwiftDataInspectorTests**, **WhereCoreTests**, **WhereUITests**) that depend on **StuffTestHost** + **WhereTesting** + the relevant package product, and the hostless macOS bundle **ForemanCoreTests**. +- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)), **RegionViewer** ([`Where/RegionViewer/`](Where/RegionViewer/), a thin standalone **Mac Catalyst** host for the WhereUI region-map developer tool — the only target with a `.macCatalyst` destination), **Foreman** ([`Foreman/Foreman/`](Foreman/Foreman/), the **native-macOS** menu bar app that runs Cursor local agent workers per repo; its user-facing copy is a generated-symbol `Resources/Localizable.xcstrings` via `STRING_CATALOG_GENERATE_SYMBOLS`; it also serves a local unix-domain control socket for the `foreman-mcp` companion — see below), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), hosted **\*Tests** bundles (**StuffCoreTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **SwiftDataInspectorTests**, **WhereCoreTests**, **WhereUITests**) that depend on **StuffTestHost** + **WhereTesting** + the relevant package product, and the hostless macOS bundle **ForemanCoreTests**. - Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper; macOS test bundles are declared directly, like `ForemanCoreTests`). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). +- **Non-Swift module**: **foreman-mcp** ([`Foreman/foreman-mcp/`](Foreman/foreman-mcp/)) is a Node/TypeScript **stdio MCP server** (the repo's only non-Swift module, outside Tuist/SwiftPM). It lets a Cursor agent spin up `git worktree`/`clone` copies of its repo and have Foreman start a worker on them, talking to Foreman over the control socket above (wire protocol mirrored from `ForemanCore/Sources/ControlProtocol.swift`). Build/test with `npm` in that directory; registered in `~/.cursor/mcp.json`. See its [`README.md`](Foreman/foreman-mcp/README.md). - **CI schemes**: the workspace mixes iOS and macOS targets, which no single xcodebuild destination can build, so CI runs two explicit shared schemes — **Stuff-iOS-Tests** (all iOS bundles) and **Foreman-macOS-Tests** (Foreman app + ForemanCoreTests). New iOS test bundles must be added to the `Stuff-iOS-Tests` scheme in `Project.swift` or CI won't run them. ## Deployment diff --git a/Foreman/Foreman/AGENTS.md b/Foreman/Foreman/AGENTS.md index 60c560a1..22580427 100644 --- a/Foreman/Foreman/AGENTS.md +++ b/Foreman/Foreman/AGENTS.md @@ -20,6 +20,16 @@ formatting, global conventions) and ForemanCore's - **Stop-on-quit lifecycle.** The app delegate calls `stopAllWorkers()` in `applicationWillTerminate` — Foreman owns its worker processes and must never leave orphans. +- **The control socket lives here, the protocol doesn't.** `ControlServer` is + the only socket-owning code (raw POSIX unix-domain socket, JSON-lines); it + decodes a line, calls `ControlRequestHandler` **on the main actor**, and + encodes the reply. Started in `applicationDidFinishLaunching` and torn down in + `applicationWillTerminate`, both via `ForemanSession` + (`startControlServer()` / `stopControlServer()`). All request→intent mapping + belongs in ForemanCore's `ControlRequestHandler`, not here. The Remove-copy UI + action goes through `ForemanSession.removeCopy(_:)`, which surfaces failures on + `actionError` (an alert) rather than throwing into a view; copy provenance for + the sidebar badge / detail is presented via `CopyProvenanceDisplay`. - New worker CLI flags start in ForemanCore (`WorkerOptions` field + argv rendering, with tests), then surface in `WorkerOptionsView`'s draft. - Feature-level todos live in [`../TODOs.md`](../TODOs.md) (conventional diff --git a/Foreman/Foreman/README.md b/Foreman/Foreman/README.md index 5ab2901c..fde41fc6 100644 --- a/Foreman/Foreman/README.md +++ b/Foreman/Foreman/README.md @@ -31,7 +31,9 @@ closed. Closing the window just hides it — the app keeps running until Quit. between groups as you toggle or favorite them. Each row shows a status dot (gray stopped, yellow stopping/restarting, green running, red failed — flipping a stopping worker back on queues a restart for when the old process - exits), the worker's on/off switch, and a star on favorited repos. + exits), the worker's on/off switch, and a star on favorited repos. Repos that + Foreman created as copies (via the MCP — see below) carry a small + worktree/clone badge whose tooltip names the parent repo and branch. Right-click a row to favorite or unfavorite it. - **Detail pane** (select a repo) — the worker's status, pid and live uptime, the failure reason when it died, the repo path, the exact `cursor-agent` @@ -41,7 +43,11 @@ closed. Closing the window just hides it — the app keeps running until Quit. status row offers **Retry** when an enabled worker failed and **Restart** while it's running (a fresh process with the saved options); neither changes the on/off switch. A toolbar star favorites the repo (mirrors the - sidebar's right-click toggle). + sidebar's right-click toggle). For a copy Foreman created, a **Copy** section + shows how it was made (worktree vs clone, the parent repo, the branch) and a + **Remove Copy…** button that — after a confirmation — stops the worker and + removes the worktree (git) or moves the clone to the Trash; failures surface + in an alert. - **Worker options** — mirrors the `cursor agent worker` CLI flags: display name, pool mode + pool name, `key=value` labels, idle release timeout, and verbose startup logs. Editable while the worker is stopped — options apply @@ -69,6 +75,8 @@ small editor sheet that commits only on **Save** (Cancel or Escape discards). - On launch, Foreman restores the saved configuration and restarts the workers that were enabled last time. With *Launch Foreman at login* on, this happens automatically after you log in. +- The MCP control socket is started after launch and closed (its file removed) + on quit. - Quitting stops every worker (stop-on-quit: the app owns its processes and never leaves orphans). - The repo list refreshes every time the window is opened or focused, and on @@ -79,6 +87,19 @@ small editor sheet that commits only on **Save** (Cancel or Escape discards). from the current scan directory are pruned; settings for other scan directories are kept and re-apply when you switch back. +## MCP control socket + +On launch Foreman starts a small local control server on a unix-domain socket +at `~/Library/Application Support/com.stuff.foreman/control.sock` (JSON-lines), +and tears it down on quit — both wired through `ForemanSession`. The +[`foreman-mcp`](../foreman-mcp) server connects to it so a Cursor agent can spin +up a worktree/clone copy of its repo and have Foreman start a worker on it; the +copy then appears in the sidebar like any other repo. The transport-agnostic +protocol and the intents behind it (`describe` / `adopt` / `removeCopy`) live in +[`ForemanCore`](../ForemanCore/README.md#mcp-control); this target only owns the +socket. A stale socket file (e.g. after a hard kill) is unlinked on the next +start, so a leftover file never blocks binding. + ## Localization All of Foreman's on-screen copy lives in diff --git a/Foreman/ForemanCore/AGENTS.md b/Foreman/ForemanCore/AGENTS.md index c0ddb01f..c57b2621 100644 --- a/Foreman/ForemanCore/AGENTS.md +++ b/Foreman/ForemanCore/AGENTS.md @@ -12,9 +12,16 @@ ForemanServices ── AppSettings ├────────── RepoDiscovery ── [Repo] ── Worker ├────────── WorkerConfigStore (ForemanConfiguration JSON) ├────────── SleepInhibitor - └────────── LoginItemController (SMAppService) + ├────────── LoginItemController (SMAppService) + └────────── RepoCopyRemoving (worktree remove / trash clone) ``` +The MCP-facing control surface (`ControlRequest`/`ControlResponse`, +`ControlRequestHandler`, the `describe`/`adopt`/`removeCopy` intents, and +`CopyProvenance`) is transport-agnostic and lives here; the app target owns the +socket (see [the app AGENTS](../Foreman/AGENTS.md)). The TypeScript client in +[`foreman-mcp`](../foreman-mcp) mirrors the wire types. + This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns the build system, formatting, and global conventions. Read that first. @@ -60,6 +67,14 @@ build system, formatting, and global conventions. Read that first. `ForemanConfiguration` — the system is the source of truth. Launch-at-login just relaunches the app, which reuses the existing `start()` restore of enabled workers. +- **Copies are Foreman's to own.** `removeCopy(at:)` only acts on a repo with + recorded `CopyProvenance` (throwing `ControlError.notACopy` otherwise) and + always stops + drains the worker *before* touching the filesystem — never + remove a copy out from under a live process. `adoptAndStartWorker` rejects a + path that isn't a direct child of the scan directory. The removal itself goes + through the injected `RepoCopyRemoving` so tests never trash real files. The + wire types (`ControlRequest`/`ControlResponse` and their DTOs) are the + contract with `foreman-mcp`'s `src/control.ts` — change both together. - **Absence vs failure.** Missing options read as `WorkerOptions.standard` and a missing config file is `.initial`; an unreadable/undecodable file throws. **CLI defaults are deferred to, not duplicated** — omit a flag @@ -71,7 +86,8 @@ build system, formatting, and global conventions. Read that first. User-facing strings (worker failure reasons in `Worker`, the scan/save/login errors and config-load fallback in `ForemanServices`, -`CursorAgentLocator.NotFoundError`) resolve through Xcode 26 **generated +`CursorAgentLocator.NotFoundError`, and the `ControlError` messages surfaced +back to the MCP) resolve through Xcode 26 **generated symbols** against [`Sources/Resources/Localizable.xcstrings`](Sources/Resources/Localizable.xcstrings) (a processed resource in [`Package.swift`](../../Package.swift)) — used as `String(localized: .workerExitedWithCode(code: …))` etc. The symbol already diff --git a/Foreman/ForemanCore/README.md b/Foreman/ForemanCore/README.md index 7f2ce440..061feb5f 100644 --- a/Foreman/ForemanCore/README.md +++ b/Foreman/ForemanCore/README.md @@ -58,7 +58,9 @@ let argv = repo.options.arguments(workerDirectory: repo.rootURL) `loginItemNeedsApproval` and a dedicated `loginItemError` (login failures surface here, not on the shared `issueMessage`); `refreshLoginItemStatus()` re-reads it from the OS and `openSystemSettingsLoginItems()` jumps to the - approval UI. + approval UI. The MCP-facing intents (`describe()`, + `adoptAndStartWorker(at:provenance:)`, `removeCopy(at:)`) live here too — + see [MCP control](#mcp-control). - **`Repo` / `RepoID`** — one discovered repository as an `@Observable` object: identity (`name`, `rootURL`, typed `RepoID` = canonical absolute path), the persisted intent (`isEnabled`, `isFavorite`, `options` — @@ -68,7 +70,8 @@ let argv = repo.options.arguments(workerDirectory: repo.rootURL) that don't change the persisted desired state: `retry()` (fresh attempt for an enabled repo in `.failed`) and `restart()` (respawn a running worker with the current options — the apply path for options edited while - running). + running). `provenance` is a `CopyProvenance?` recorded when the repo is an + MCP-created copy (persists on assignment); `nil` for ordinary repos. - **`Worker`** — the per-repo process: `start(options:executable:)`, `stop()`, an observable `state` enum (`stopped` / `running(pid:since:)` / `stopping(restartPending:)` / `failed(reason:)`), and `logFileURL`. @@ -105,9 +108,11 @@ let argv = repo.options.arguments(workerDirectory: repo.rootURL) `arguments(workerDirectory:)` renders the full argv; `.standard` is the CLI-default configuration. - **`RepoConfiguration` / `ForemanConfiguration`** — everything Foreman - persists. `RepoConfiguration` is the single per-repo record (`isEnabled`, - `isFavorite`, `options`) — one struct instead of parallel maps keyed by - `RepoID`, so the flags and options can't drift apart. `ForemanConfiguration` + persists. `RepoConfiguration` is the single per-repo record (`isEnabled`, + `isFavorite`, `options`, and an optional `provenance`) — one struct instead + of parallel maps keyed by `RepoID`, so the flags and options can't drift + apart. A recorded `provenance` makes the record non-`.standard`, so a copy's + origin survives even when its flags are otherwise default. `ForemanConfiguration` holds the scan directory (default `~/Development`), an explicit `cursor-agent` executable (or `nil` for auto-locate), and `repos: [RepoID: RepoConfiguration]`; a repo left at @@ -146,6 +151,39 @@ let argv = repo.options.arguments(workerDirectory: repo.rootURL) - **`ForemanLog`** — the logging facade over LogKit: `ForemanLog.channel(_:)` with a typed `Category`, subsystem `com.stuff.foreman`. +## MCP control + +The [`foreman-mcp`](../foreman-mcp) server drives Foreman over a local +unix-domain socket. ForemanCore owns the transport-agnostic half; the app +target owns the socket itself (see [the app's control server](../Foreman/README.md#mcp-control-socket)). + +- **`CopyProvenance`** — how an MCP-created copy came to be, modeled as one + value so invalid combinations can't be spelled: `kind` (`.worktree` / + `.clone`), `parentRepoID`, and `branch`. Persisted on `RepoConfiguration` + and mirrored on `Repo.provenance`; its absence is an ordinary repo. +- **`ControlRequest` / `ControlResponse`** — the `Codable` wire protocol + (JSON-lines: one request, one response). Requests are `describe`, `adopt` + (path + provenance), and `removeCopy` (path); responses carry a + `DescribeResultDTO`, a `RepoStatusDTO`, a removed path, or an error string. + `CopyProvenanceDTO` / `RepoStatusDTO` / `DescribeResultDTO` are the flat + transfer shapes; `RepoStatusDTO(repo:)` projects a live `Repo`. **This is the + contract mirrored in `foreman-mcp`'s `src/control.ts` — keep them in sync.** +- **`ControlRequestHandler`** — maps a decoded `ControlRequest` to the matching + `ForemanServices` intent on the main actor and wraps the outcome (or a + `ControlError`) in a `ControlResponse`. +- **`ForemanServices` intents** — `describe()` returns the scan directory plus + every repo's worker state and provenance; `adoptAndStartWorker(at:provenance:)` + validates the path is a direct subdirectory of the scan directory (throwing a + `ControlError` otherwise), records the provenance, `rescan()`s, and enables + the worker; `removeCopy(at:)` stops and drains the worker, then removes the + copy (via `RepoCopyRemoving`) and `rescan()`s. `removeCopy` is gated on + recorded provenance, so Foreman only ever removes copies it created. + `controlSocketURL` is the shared socket path. +- **`RepoCopyRemoving` / `SystemRepoCopyRemover`** — the removal seam: + `git worktree remove` for a worktree, move-to-Trash for a clone. Injected + into `ForemanServices` so tests substitute a recorder instead of touching the + filesystem. + ## Localization User-facing strings — worker failure reasons (`Worker`), the scan/save/login diff --git a/Foreman/foreman-mcp/AGENTS.md b/Foreman/foreman-mcp/AGENTS.md new file mode 100644 index 00000000..d44fa9d2 --- /dev/null +++ b/Foreman/foreman-mcp/AGENTS.md @@ -0,0 +1,53 @@ +# foreman-mcp – Module Shape + +A Node/TypeScript **stdio MCP server** that lets a Cursor agent spin up +worktree/clone copies of a repo and hand them to [Foreman](../Foreman) to run a +worker on — see [`README.md`](README.md) for the tools, the flow, and how to +register it. This is the only non-Swift module in the repo. + +This file complements the root [`AGENTS.md`](../../AGENTS.md) (build/format/global +rules) and the Foreman docs it talks to +([ForemanCore](../ForemanCore/AGENTS.md), [app](../Foreman/AGENTS.md)). Read +those first. + +## Scope & dependencies + +- Runtime deps are only `@modelcontextprotocol/sdk` + `zod`; git runs via + `node:child_process`. No dependency on the Swift code at build time — the only + coupling is the **wire protocol**. +- Not part of the Tuist/Swift build. Build with `npm run build` (→ `dist/`), + test with `npm test`. `dist/` and `node_modules/` are gitignored; the + committed source of truth is `src/` + `test/`. +- Boundary: this server owns **creation git** (worktree/clone). Foreman owns + **worker lifecycle and copy removal** — don't add worker-spawning or + filesystem-removal here; call the control socket instead. + +## Layering + +- [`src/index.ts`](src/index.ts) — the MCP surface: tool registration, arg + validation (`zod`), and orchestration. [`src/git.ts`](src/git.ts) — git + `execFile` helpers. [`src/control.ts`](src/control.ts) — the socket client. + Keep git and control concerns in their files; `index.ts` composes them. + +## Invariants + +- **stdout is reserved for JSON-RPC.** All logging goes to stderr (`log()`). + Never `console.log` / write to stdout. +- **The wire types in `src/control.ts` mirror + `ForemanCore/Sources/ControlProtocol.swift`.** Change one side and you must + change the other — the socket is JSON-lines (one request line, one response + line) with no versioning. +- **Foreman-down is a normal path, not an error.** A missing/refused socket or + timeout is `ForemanUnavailableError`; `spinup_repo_copy` still reports the git + copy it made and says no worker started. Never report a silent success or a + hard failure for a copy that was actually created. +- **Errors are never swallowed.** git failures surface as `GitError` with + stderr; control failures as `ForemanError`/`ForemanUnavailableError`. + +## Testing + +`npm test` runs [`test/smoke.mjs`](test/smoke.mjs) (ping over stdio) and +[`test/integration.mjs`](test/integration.mjs) (end-to-end against a mock unix +socket + a real temp git repo — never the user's `~/Development`). +[`test/describe-live.mjs`](test/describe-live.mjs) is a manual helper that talks +to a live Foreman socket. diff --git a/Foreman/foreman-mcp/README.md b/Foreman/foreman-mcp/README.md new file mode 100644 index 00000000..dd9e3d63 --- /dev/null +++ b/Foreman/foreman-mcp/README.md @@ -0,0 +1,118 @@ +# foreman-mcp + +A small [Model Context Protocol](https://modelcontextprotocol.io) **stdio +server** that lets a Cursor agent — typically one running inside a +[Foreman](../Foreman) `cursor-agent worker` session — spin up another working +copy of the repo it's in (a lightweight `git worktree` or a full `git clone`) +and have Foreman immediately start a worker on it. The new copy becomes a +normal Foreman-managed repo: work is dispatched to it exactly like every other +repo, with no separate task/prompt launcher. + +It's a Node/TypeScript package, built to `dist/` and registered in +`~/.cursor/mcp.json`. Git creation runs locally via `git`; worker lifecycle and +copy removal are delegated to Foreman over a local unix-domain **control +socket** (see [ForemanCore's control protocol](../ForemanCore/README.md)), so +the same removal path works whether triggered from here or the Foreman UI. + +## How it fits together + +```mermaid +flowchart LR + Agent["Cursor agent in a Foreman worker session"] -->|"MCP tool call"| MCP["foreman-mcp (this server)"] + MCP -->|"git worktree/clone"| Disk["new sibling dir under the scan dir"] + MCP -->|"JSON over unix socket"| Ctrl["Foreman control socket"] + Ctrl --> Svc["ForemanServices: rescan + enable"] + Svc -->|"Repo.isEnabled = true"| Worker["cursor-agent worker start"] +``` + +The MCP owns **creation** git (worktree/clone — its natural domain); Foreman +owns **worker lifecycle and copy removal**. + +## Build + +Requires Node 18+ (this repo pins Node via `mise`). From this directory: + +```bash +npm install +npm run build # tsc → dist/ +npm test # smoke + integration (mock socket + a temp git repo) +``` + +`npm run watch` rebuilds on change; `npm run smoke` runs just the ping check. + +## Registration + +Add (or merge) an entry in `~/.cursor/mcp.json` so the server is available to +any repo / any Foreman worker. Point `command` at the Node binary and `args` at +the built `dist/index.js`: + +```json +{ + "mcpServers": { + "foreman": { + "command": "/absolute/path/to/node", + "args": ["/absolute/path/to/Foreman/foreman-mcp/dist/index.js"], + "env": { + "FOREMAN_CONTROL_SOCKET": "${userHome}/Library/Application Support/com.stuff.foreman/control.sock" + } + } + } +} +``` + +`FOREMAN_CONTROL_SOCKET` is optional — it defaults to the same path — but +setting it explicitly documents the contract. Rebuild (`npm run build`) after +changing the server; Cursor re-launches the stdio process. MCP tools may need a +one-time trust/approval in Cursor. + +## Tools + +- **`foreman_ping(message?)`** — health check; returns `pong` (optionally + echoing `message`) so you can confirm the server is reachable. +- **`spinup_repo_copy(mode, sourceRepo?, name?, branch?, baseRef?, enable=true)`** + — create another copy of a git repo and (by default) start a worker on it. + - `mode`: `"worktree"` (fast, shares the object store, one branch per copy) + or `"clone"` (a fully independent copy). + - `sourceRepo`: absolute path to the source repo (defaults to the server's + working directory); must be a git repo. + - `name`: single directory name for the copy (defaults to + `-`); a path is rejected, and a collision + auto-suffixes `-2`, `-3`, …. + - `branch`: branch to check out (defaults to `foreman/`); `baseRef` is + the ref it starts from (defaults to the source's `HEAD`). + - The copy is placed as a sibling under **Foreman's** scan directory (queried + over the socket) so Foreman discovers it — falling back to the source's + parent when Foreman isn't running. + - `enable`: when true and Foreman is reachable, calls `adopt` so a worker + starts; when Foreman is down (or `enable=false`) the git copy still + succeeds and the reply says plainly that no worker was started. +- **`list_repos()`** — the repos Foreman knows about, each with its worker + state and (for copies) how it was created. +- **`remove_repo_copy(path)`** — delegates to Foreman: it stops the worker, + then removes the worktree (`git worktree remove`) or moves the clone to the + Trash. Only works on copies Foreman recorded provenance for, and requires + Foreman to be running. + +## How it works + +- **Transport** — stdio (`@modelcontextprotocol/sdk`). stdout carries JSON-RPC, + so **all** diagnostics go to stderr only (`log()`), never stdout. +- **Git** ([`src/git.ts`](src/git.ts)) — thin `execFile` wrappers. GUI-launched + processes don't inherit the shell `PATH`, so `git` is located at its known + install paths before falling back to a `PATH` lookup; non-zero exits throw a + `GitError` carrying stderr (never swallowed). +- **Control client** ([`src/control.ts`](src/control.ts)) — connects to the + unix socket, writes one JSON request line, reads one JSON response line. The + wire types mirror `ForemanCore/Sources/ControlProtocol.swift` — **keep the + two in sync**. A missing/refused socket or a timeout throws + `ForemanUnavailableError` (callers degrade gracefully); an `ok:false` reply + throws `ForemanError`. + +## Limitations + +- Requires **Foreman running** for worker start (`enable`) and for removal; git + creation works regardless. +- macOS-oriented (the socket path lives under `~/Library/Application Support`, + and clone removal trashes via Foreman on macOS). +- Discovery is shallow: the copy must land directly under the scan directory + for Foreman to see it (which is where `spinup_repo_copy` places it). From a5fb7af9ee6cb4c215bd637a116672e6a741f516 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:58:45 -0400 Subject: [PATCH 06/12] Keep the control socket responsive under a slow client The accept DispatchSource ran on a serial queue and served each connection inline with a blocking, unbounded read, so a peer that connected but never finished sending its request line stalled the whole endpoint (no further accepts, no other requests). Only the trusted foreman-mcp connects today, but a half-open or hung peer shouldn't be able to wedge it. Accept on a dedicated serial queue and hand each connection to a concurrent work queue, and set SO_RCVTIMEO on the accepted socket so a stalled read is abandoned instead of pinning a thread. Co-authored-by: Cursor --- Foreman/Foreman/Sources/ControlServer.swift | 61 +++++++++++++++++---- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/Foreman/Foreman/Sources/ControlServer.swift b/Foreman/Foreman/Sources/ControlServer.swift index b4e51139..64d42dbc 100644 --- a/Foreman/Foreman/Sources/ControlServer.swift +++ b/Foreman/Foreman/Sources/ControlServer.swift @@ -17,10 +17,27 @@ import Foundation final class ControlServer { private let socketURL: URL private let handler: ControlRequestHandler - private let queue = DispatchQueue(label: "com.stuff.foreman.control", qos: .utility) + /// Accepts run here, one at a time; each accepted connection is then served + /// on ``workQueue`` so a slow client can't stall the accept loop. + private let acceptQueue = DispatchQueue( + label: "com.stuff.foreman.control.accept", + qos: .utility, + ) + /// Per-connection read/handle/write. Concurrent so one hung client doesn't + /// block the others (paired with a receive timeout below). + private let workQueue = DispatchQueue( + label: "com.stuff.foreman.control.work", + qos: .utility, + attributes: .concurrent, + ) private var listenFD: Int32 = -1 private var acceptSource: DispatchSourceRead? + /// How long a connection may take to deliver its one request line before + /// the read is abandoned (SO_RCVTIMEO). The only client writes it + /// immediately, so this just bounds a stalled/half-open peer. + private static let readTimeout = 15.0 + private static let logger = ForemanLog.channel(.app) init(socketURL: URL, handler: ControlRequestHandler) { @@ -88,19 +105,15 @@ final class ControlServer { } listenFD = fd - let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: acceptQueue) source.setEventHandler { [weak self] in + guard let self else { return } let clientFD = accept(fd, nil, nil) guard clientFD >= 0 else { return } - var noSigpipe: Int32 = 1 - setsockopt( - clientFD, - SOL_SOCKET, - SO_NOSIGPIPE, - &noSigpipe, - socklen_t(MemoryLayout.size), - ) - self?.serve(clientFD) + Self.configureClient(clientFD) + // Hand the connection off so the accept loop stays responsive even + // if this client is slow to send. + workQueue.async { [weak self] in self?.serve(clientFD) } } source.setCancelHandler { close(fd) @@ -110,6 +123,30 @@ final class ControlServer { Self.logger.info("Control socket listening at \(path)") } + /// Suppresses SIGPIPE on writes and bounds how long a read may block, so a + /// peer that connects but never finishes sending can't pin a work thread. + private nonisolated static func configureClient(_ clientFD: Int32) { + var noSigpipe: Int32 = 1 + setsockopt( + clientFD, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size), + ) + var timeout = timeval( + tv_sec: Int(readTimeout), + tv_usec: 0, + ) + setsockopt( + clientFD, + SOL_SOCKET, + SO_RCVTIMEO, + &timeout, + socklen_t(MemoryLayout.size), + ) + } + /// Stops listening and removes the socket file. func stop() { acceptSource?.cancel() @@ -147,7 +184,7 @@ final class ControlServer { } let response = await handler.handle(request) let data = Self.encode(response) - queue.async { + workQueue.async { Self.write(clientFD, data) close(clientFD) } From be4912b855f925091229c7358df3f221d4500cc6 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 5 Jul 2026 23:59:26 -0400 Subject: [PATCH 07/12] Drop the redundant retry() in adoptAndStartWorker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For an already-adopted copy the intent path called both startIfEnabled() and retry(). startIfEnabled() already (re)starts an enabled worker whenever it isn't live — including .failed, which isn't a live state — so retry() was redundant and could produce a second spawn attempt in the failed->failed case. Keep just startIfEnabled(). Co-authored-by: Cursor --- Foreman/ForemanCore/Sources/ForemanServices.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Foreman/ForemanCore/Sources/ForemanServices.swift b/Foreman/ForemanCore/Sources/ForemanServices.swift index 0b7bae12..b4c263d8 100644 --- a/Foreman/ForemanCore/Sources/ForemanServices.swift +++ b/Foreman/ForemanCore/Sources/ForemanServices.swift @@ -272,9 +272,11 @@ public final class ForemanServices { } repo.provenance = provenance + // Enabling starts the worker; if it was already enabled (a re-adopt), + // startIfEnabled() (re)starts it when it isn't already live — .failed + // included, since that isn't a live state. if repo.isEnabled { repo.startIfEnabled() - repo.retry() } else { repo.isEnabled = true } From 6be9f8617fb24dd263df2066167a5ccb2a3a289d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 6 Jul 2026 00:00:25 -0400 Subject: [PATCH 08/12] Restore the worker when a copy removal fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeCopy stops (and drains) the worker before deleting the copy. If the worktree/clone removal — or the wait for the worker to stop — then failed, the copy was left on disk but silently stopped and disabled. Make the operation recover: on any failure after stopping, re-enable the repo so its worker starts again, leaving the copy exactly as we found it. A precise ControlError (workerDidNotStop) is preserved; only opaque remover errors are wrapped as removeFailed. Update the failure test to assert the worker is running again. Co-authored-by: Cursor --- .../ForemanCore/Sources/ForemanServices.swift | 22 ++++++++++++++----- .../Tests/ForemanServicesTests.swift | 10 +++++---- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Foreman/ForemanCore/Sources/ForemanServices.swift b/Foreman/ForemanCore/Sources/ForemanServices.swift index b4c263d8..75dfd5f9 100644 --- a/Foreman/ForemanCore/Sources/ForemanServices.swift +++ b/Foreman/ForemanCore/Sources/ForemanServices.swift @@ -298,13 +298,15 @@ public final class ForemanServices { } // A live cursor-agent holds the worktree open, so stop it and wait for - // the process to actually exit before touching the files. - if let repo, repo.worker.state.isLive { - repo.isEnabled = false - try await waitForWorkerToStop(repo, path: copy) - } - + // the process to actually exit before touching the files. If any step + // then fails, put the worker back the way we found it — a copy that + // still exists shouldn't be left silently stopped/disabled. + let wasEnabled = repo?.isEnabled ?? false do { + if let repo, repo.worker.state.isLive { + repo.isEnabled = false + try await waitForWorkerToStop(repo, path: copy) + } switch provenance.kind { case .worktree: try copyRemover.removeWorktree( @@ -315,7 +317,15 @@ public final class ForemanServices { try copyRemover.removeClone(at: copy) } } catch { + if let repo, wasEnabled, !repo.isEnabled { + repo.isEnabled = true // restart the worker we stopped + } Self.logger.error("Couldn't remove copy at \(copy.path): \(error)") + // Preserve a precise ControlError (e.g. workerDidNotStop); wrap + // only opaque remover failures. + if let controlError = error as? ControlError { + throw controlError + } throw ControlError.removeFailed(reason: error.localizedDescription) } diff --git a/Foreman/ForemanCore/Tests/ForemanServicesTests.swift b/Foreman/ForemanCore/Tests/ForemanServicesTests.swift index 180d2c9f..07e22764 100644 --- a/Foreman/ForemanCore/Tests/ForemanServicesTests.swift +++ b/Foreman/ForemanCore/Tests/ForemanServicesTests.swift @@ -556,11 +556,13 @@ struct ForemanServicesTests { await #expect(throws: ControlError.self) { try await fixture.services.removeCopy(at: copy) } - // The worker was still stopped, and the repo survives (removal failed, - // so it wasn't silently dropped). + // The removal failed, so the copy is restored to how we found it: the + // repo survives, stays enabled, and its worker is running again rather + // than being silently left stopped/disabled. #expect(fixture.services.repos.contains { $0.name == "Copy" }) - try await waitUntil("worker stopped after failed removal") { - repo.worker.state == .stopped + #expect(repo.isEnabled) + try await waitUntil("worker running again after failed removal") { + repo.worker.state.isLive } } } From a71c3392da5cd39fa3bc85037b55e5306d60b111 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 6 Jul 2026 00:01:12 -0400 Subject: [PATCH 09/12] Report a standardized scan directory from describe() adoptAndStartWorker validates a copy's parent against the standardized scan directory, but describe() returned the raw resolvedScanDirectory.path. The MCP builds the copy path from describe()'s value, so a non-normalized scan directory (trailing slash, ./.., etc.) could make a legitimately-placed copy fail adopt's parent-equality check. Return the standardized form so the two always match, and assert that form in the describe tests. Co-authored-by: Cursor --- Foreman/ForemanCore/Sources/ForemanServices.swift | 5 ++++- Foreman/ForemanCore/Tests/ControlRequestHandlerTests.swift | 2 +- Foreman/ForemanCore/Tests/ForemanServicesTests.swift | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Foreman/ForemanCore/Sources/ForemanServices.swift b/Foreman/ForemanCore/Sources/ForemanServices.swift index 75dfd5f9..6ace71ca 100644 --- a/Foreman/ForemanCore/Sources/ForemanServices.swift +++ b/Foreman/ForemanCore/Sources/ForemanServices.swift @@ -226,8 +226,11 @@ public final class ForemanServices { /// A snapshot of the scan directory and every known repo, for the MCP's /// `list_repos` and for placing new copies. Pure read of the live tree. public func describe() -> DescribeResultDTO { + // Report the standardized path: adoptAndStartWorker validates a copy's + // parent against the standardized scan directory, and the MCP builds + // the copy path from this value, so the two must match forms. DescribeResultDTO( - scanDirectory: settings.resolvedScanDirectory.path, + scanDirectory: settings.resolvedScanDirectory.standardizedFileURL.path, repos: discovery.repos.map { RepoStatusDTO(repo: $0) }, ) } diff --git a/Foreman/ForemanCore/Tests/ControlRequestHandlerTests.swift b/Foreman/ForemanCore/Tests/ControlRequestHandlerTests.swift index 1f91f4a2..8669dd04 100644 --- a/Foreman/ForemanCore/Tests/ControlRequestHandlerTests.swift +++ b/Foreman/ForemanCore/Tests/ControlRequestHandlerTests.swift @@ -22,7 +22,7 @@ struct ControlRequestHandlerTests { Issue.record("expected a describe response") return } - #expect(result.scanDirectory == fixture.scanDirectory.path) + #expect(result.scanDirectory == fixture.scanDirectory.standardizedFileURL.path) #expect(result.repos.map(\.name) == ["Main"]) #expect(result.repos[0].provenance == nil) } diff --git a/Foreman/ForemanCore/Tests/ForemanServicesTests.swift b/Foreman/ForemanCore/Tests/ForemanServicesTests.swift index 07e22764..92fc3f7a 100644 --- a/Foreman/ForemanCore/Tests/ForemanServicesTests.swift +++ b/Foreman/ForemanCore/Tests/ForemanServicesTests.swift @@ -474,7 +474,7 @@ struct ForemanServicesTests { ) let described = fixture.services.describe() - #expect(described.scanDirectory == fixture.scanDirectory.path) + #expect(described.scanDirectory == fixture.scanDirectory.standardizedFileURL.path) let copyStatus = try #require(described.repos.first { $0.name == "Copy" }) #expect(copyStatus.provenance?.kind == "clone") #expect(copyStatus.provenance?.parentRepoID == parentID.rawValue) From 660a6fbbf1968968b646ffaee8409ba8d07cb57a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 6 Jul 2026 00:01:52 -0400 Subject: [PATCH 10/12] Reject unsafe copy names in spinup_repo_copy The name argument was only checked for '/'. Tighten it: reject empty names, leading dots ('.'/'..' escape the scan dir, and a hidden dir is skipped by Foreman's discovery so the copy could never be adopted), backslashes, and NUL. The git copy is created before Foreman validates, so this stops an odd name from placing a copy in an unexpected spot. Covered by new integration cases that assert the tool errors and sends no control request. Co-authored-by: Cursor --- Foreman/foreman-mcp/src/index.ts | 21 +++++++++++++++++++-- Foreman/foreman-mcp/test/integration.mjs | 17 +++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/Foreman/foreman-mcp/src/index.ts b/Foreman/foreman-mcp/src/index.ts index 048f5c19..8000f60a 100644 --- a/Foreman/foreman-mcp/src/index.ts +++ b/Foreman/foreman-mcp/src/index.ts @@ -44,6 +44,21 @@ function fail(text: string): TextResult { return { content: [{ type: "text", text }], isError: true }; } +/// A caller-supplied copy name must be a single, visible directory segment: no +/// path separators (which would escape the scan dir), no NUL, and no leading +/// dot (`.`/`..` escape, and a hidden dir is skipped by Foreman's discovery so +/// the copy could never be adopted). Defaults derived from the repo name are +/// always safe, so only an explicit `name` is checked. +function isValidCopyName(name: string): boolean { + return ( + name.length > 0 && + !name.startsWith(".") && + !name.includes("/") && + !name.includes("\\") && + !name.includes("\0") + ); +} + /// Turns arbitrary text into a safe single directory-name segment. function slug(input: string): string { return input @@ -122,8 +137,10 @@ server.registerTool( `'${source}' isn't a git repository. Pass 'sourceRepo' as the absolute path of the repo you're working in.`, ); } - if (name && name.includes("/")) { - return fail(`'name' must be a single directory name, not a path (got '${name}').`); + if (name !== undefined && !isValidCopyName(name)) { + return fail( + `'name' must be a single visible directory name — no '/', no leading dot, not '.'/'..' (got '${name}').`, + ); } let sourceTop: string; diff --git a/Foreman/foreman-mcp/test/integration.mjs b/Foreman/foreman-mcp/test/integration.mjs index 68bd127a..b3998cb9 100644 --- a/Foreman/foreman-mcp/test/integration.mjs +++ b/Foreman/foreman-mcp/test/integration.mjs @@ -145,6 +145,23 @@ async function run() { const removeReq = received.find((r) => r.command === "removeCopy"); assert(removeReq && removeReq.path === copyPath, "removeCopy sent for the copy path"); + // 4. Reject unsafe copy names before any git/adopt happens. + for (const badName of ["../evil", ".hidden", ""]) { + const before = received.length; + const bad = await client.callTool({ + name: "spinup_repo_copy", + arguments: { mode: "worktree", sourceRepo: source, name: badName }, + }); + assert(bad.isError, `bad name ${JSON.stringify(badName)} is rejected`); + assert( + received.length === before, + `bad name ${JSON.stringify(badName)} sends no control request`, + ); + } + assert(!existsSync(path.join(scanDir, "evil")), "no dir created for '../evil'"); + assert(!existsSync(path.join(scanDir, ".hidden")), "no hidden dir created"); + console.error("bad names rejected"); + await client.close(); console.error("INTEGRATION OK"); } From 23895a9ff879164207680602ddc419396caadd8e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 6 Jul 2026 00:05:34 -0400 Subject: [PATCH 11/12] Make the control wire framing testable and cover it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app's ControlServer hid the riskiest new code — byte-level line framing, EOF/oversize handling, malformed-input replies, response encoding — behind socket setup, so it had no automated coverage. Extract that into a transport-agnostic ControlConnection in ForemanCore (readLine/writeLine + respond(to:handler:)) and have ControlServer delegate to it, keeping only the socket, accept loop, and threading in the app. Add ForemanCoreTests that drive the framing and a full request→response round trip over a real socketpair (incl. multi-line framing, EOF, oversize rejection, and malformed→failure). Update the module docs. Co-authored-by: Cursor --- Foreman/Foreman/AGENTS.md | 11 +- Foreman/Foreman/Sources/ControlServer.swift | 75 ++-------- Foreman/ForemanCore/AGENTS.md | 8 +- Foreman/ForemanCore/README.md | 6 + .../Sources/ControlConnection.swift | 84 ++++++++++++ .../Tests/ControlConnectionTests.swift | 129 ++++++++++++++++++ 6 files changed, 239 insertions(+), 74 deletions(-) create mode 100644 Foreman/ForemanCore/Sources/ControlConnection.swift create mode 100644 Foreman/ForemanCore/Tests/ControlConnectionTests.swift diff --git a/Foreman/Foreman/AGENTS.md b/Foreman/Foreman/AGENTS.md index 22580427..dcf8217e 100644 --- a/Foreman/Foreman/AGENTS.md +++ b/Foreman/Foreman/AGENTS.md @@ -21,10 +21,13 @@ formatting, global conventions) and ForemanCore's `applicationWillTerminate` — Foreman owns its worker processes and must never leave orphans. - **The control socket lives here, the protocol doesn't.** `ControlServer` is - the only socket-owning code (raw POSIX unix-domain socket, JSON-lines); it - decodes a line, calls `ControlRequestHandler` **on the main actor**, and - encodes the reply. Started in `applicationDidFinishLaunching` and torn down in - `applicationWillTerminate`, both via `ForemanSession` + the only socket-owning code (raw POSIX unix-domain socket): it accepts on a + serial queue, serves each connection on a concurrent queue (with `SO_RCVTIMEO` + so a stalled peer can't wedge it), and delegates all framing/decoding/encoding + to ForemanCore's `ControlConnection` (which dispatches through + `ControlRequestHandler` **on the main actor**). Started in + `applicationDidFinishLaunching` and torn down in `applicationWillTerminate`, + both via `ForemanSession` (`startControlServer()` / `stopControlServer()`). All request→intent mapping belongs in ForemanCore's `ControlRequestHandler`, not here. The Remove-copy UI action goes through `ForemanSession.removeCopy(_:)`, which surfaces failures on diff --git a/Foreman/Foreman/Sources/ControlServer.swift b/Foreman/Foreman/Sources/ControlServer.swift index 64d42dbc..8b09a636 100644 --- a/Foreman/Foreman/Sources/ControlServer.swift +++ b/Foreman/Foreman/Sources/ControlServer.swift @@ -155,85 +155,26 @@ final class ControlServer { unlink(socketURL.path) } - /// Reads one request line off `clientFD` (already on the background - /// queue), handles it on the main actor, and writes the reply back off the - /// main actor. A malformed line still gets a `.failure` reply so the - /// client never hangs waiting. + /// Reads one request line off `clientFD` (already on a work-queue thread), + /// dispatches it on the main actor via ``ControlConnection``, and writes + /// the reply back off the main actor. Framing, decoding, and encoding live + /// in ``ControlConnection`` so they're testable without a real socket; + /// this method owns only the threading and the fd's lifetime. private nonisolated func serve(_ clientFD: Int32) { - guard let line = Self.readLine(clientFD), !line.isEmpty else { + guard let line = ControlConnection.readLine(fd: clientFD), !line.isEmpty else { close(clientFD) return } - - let request: ControlRequest - do { - request = try JSONDecoder().decode(ControlRequest.self, from: line) - } catch { - Self.write( - clientFD, - Self.encode(.failure(message: "Malformed request: \(error.localizedDescription)")), - ) - close(clientFD) - return - } - Task { @MainActor [weak self] in guard let self else { close(clientFD) return } - let response = await handler.handle(request) - let data = Self.encode(response) + let data = await ControlConnection.respond(to: line, handler: handler) workQueue.async { - Self.write(clientFD, data) + ControlConnection.writeLine(fd: clientFD, data) close(clientFD) } } } - - private nonisolated static func encode(_ response: ControlResponse) -> Data { - (try? JSONEncoder().encode(response)) ?? - Data(#"{"ok":false,"error":"encoding failed"}"#.utf8) - } - - /// Reads bytes up to (and consuming) a newline, returning the bytes before - /// it; `nil` on error. Requests are tiny, so byte-at-a-time is fine. - private nonisolated static func readLine(_ fd: Int32) -> Data? { - var data = Data() - var byte: UInt8 = 0 - while true { - let count = read(fd, &byte, 1) - if count == 1 { - if byte == 0x0A { return data } - data.append(byte) - if data.count > 1_048_576 { return nil } - } else if count == 0 { - return data - } else if errno == EINTR { - continue - } else { - return nil - } - } - } - - private nonisolated static func write(_ fd: Int32, _ payload: Data) { - var data = payload - data.append(0x0A) - data.withUnsafeBytes { raw in - guard var base = raw.bindMemory(to: UInt8.self).baseAddress else { return } - var remaining = raw.count - while remaining > 0 { - let written = Darwin.write(fd, base, remaining) - if written > 0 { - base += written - remaining -= written - } else if written < 0, errno == EINTR { - continue - } else { - return - } - } - } - } } diff --git a/Foreman/ForemanCore/AGENTS.md b/Foreman/ForemanCore/AGENTS.md index c57b2621..177ff619 100644 --- a/Foreman/ForemanCore/AGENTS.md +++ b/Foreman/ForemanCore/AGENTS.md @@ -17,9 +17,11 @@ ForemanServices ── AppSettings ``` The MCP-facing control surface (`ControlRequest`/`ControlResponse`, -`ControlRequestHandler`, the `describe`/`adopt`/`removeCopy` intents, and -`CopyProvenance`) is transport-agnostic and lives here; the app target owns the -socket (see [the app AGENTS](../Foreman/AGENTS.md)). The TypeScript client in +`ControlRequestHandler`, `ControlConnection`'s wire framing + dispatch, the +`describe`/`adopt`/`removeCopy` intents, and `CopyProvenance`) is +transport-agnostic and lives here so it's testable over a `socketpair`; the app +target owns only the listening socket, accept loop, and threading (see [the app +AGENTS](../Foreman/AGENTS.md)). The TypeScript client in [`foreman-mcp`](../foreman-mcp) mirrors the wire types. This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns the diff --git a/Foreman/ForemanCore/README.md b/Foreman/ForemanCore/README.md index 061feb5f..8ed3471b 100644 --- a/Foreman/ForemanCore/README.md +++ b/Foreman/ForemanCore/README.md @@ -171,6 +171,12 @@ target owns the socket itself (see [the app's control server](../Foreman/README. - **`ControlRequestHandler`** — maps a decoded `ControlRequest` to the matching `ForemanServices` intent on the main actor and wraps the outcome (or a `ControlError`) in a `ControlResponse`. +- **`ControlConnection`** — the newline-delimited-JSON wire framing over a + connected file descriptor (`readLine`/`writeLine`) plus `respond(to:handler:)`, + which decodes a line, dispatches it through the handler, and returns the + encoded reply (a malformed line becomes an encoded `.failure`, never a throw). + Lives here so the byte-level behavior is unit-tested over a `socketpair`; the + app's `ControlServer` owns only the socket, accept loop, and threading. - **`ForemanServices` intents** — `describe()` returns the scan directory plus every repo's worker state and provenance; `adoptAndStartWorker(at:provenance:)` validates the path is a direct subdirectory of the scan directory (throwing a diff --git a/Foreman/ForemanCore/Sources/ControlConnection.swift b/Foreman/ForemanCore/Sources/ControlConnection.swift new file mode 100644 index 00000000..3d1db587 --- /dev/null +++ b/Foreman/ForemanCore/Sources/ControlConnection.swift @@ -0,0 +1,84 @@ +import Darwin +import Foundation + +/// Newline-delimited-JSON framing and request dispatch for Foreman's control +/// socket, split out from the app's socket server so the wire behavior (line +/// framing, malformed-input handling, response encoding) is unit-testable over +/// a `socketpair` without any socket setup. +/// +/// The app's `ControlServer` owns the listening socket, the accept loop, and +/// the connection lifecycle; it delegates every byte to/from a connected file +/// descriptor here. +public enum ControlConnection { + /// Largest request line accepted before the connection is abandoned. + public static let defaultMaxLineBytes = 1_048_576 + + /// Reads bytes up to (and consuming) a newline from `fd`, returning the + /// bytes before it. Returns whatever was read so far at EOF, and `nil` on a + /// read error or when the line exceeds `maxBytes`. Requests are tiny, so a + /// byte-at-a-time read is fine. + public static func readLine(fd: Int32, maxBytes: Int = defaultMaxLineBytes) -> Data? { + var data = Data() + var byte: UInt8 = 0 + while true { + let count = read(fd, &byte, 1) + if count == 1 { + if byte == 0x0A { return data } + data.append(byte) + if data.count > maxBytes { return nil } + } else if count == 0 { + return data + } else if errno == EINTR { + continue + } else { + return nil + } + } + } + + /// Writes `payload` followed by a newline to `fd`, retrying partial writes + /// and `EINTR`. Best-effort: a write error (e.g. the peer hung up) is + /// dropped, since there's nothing left to recover. + public static func writeLine(fd: Int32, _ payload: Data) { + var data = payload + data.append(0x0A) + data.withUnsafeBytes { raw in + guard var base = raw.bindMemory(to: UInt8.self).baseAddress else { return } + var remaining = raw.count + while remaining > 0 { + let written = Darwin.write(fd, base, remaining) + if written > 0 { + base += written + remaining -= written + } else if written < 0, errno == EINTR { + continue + } else { + return + } + } + } + } + + /// Decodes one request line, runs it through `handler`, and returns the + /// encoded response (without the trailing newline; ``writeLine(fd:_:)`` + /// adds it). A line that isn't a valid ``ControlRequest`` yields an encoded + /// `.failure` rather than throwing, so the caller always has a reply to + /// send and the client never hangs. + @MainActor + public static func respond(to line: Data, handler: ControlRequestHandler) async -> Data { + let request: ControlRequest + do { + request = try JSONDecoder().decode(ControlRequest.self, from: line) + } catch { + return encode(.failure(message: "Malformed request: \(error.localizedDescription)")) + } + return await encode(handler.handle(request)) + } + + /// Encodes a response to JSON, falling back to a minimal failure payload if + /// encoding itself somehow fails — so a socket write never sends nothing. + public static func encode(_ response: ControlResponse) -> Data { + (try? JSONEncoder().encode(response)) ?? + Data(#"{"ok":false,"error":"encoding failed"}"#.utf8) + } +} diff --git a/Foreman/ForemanCore/Tests/ControlConnectionTests.swift b/Foreman/ForemanCore/Tests/ControlConnectionTests.swift new file mode 100644 index 00000000..4c35f72b --- /dev/null +++ b/Foreman/ForemanCore/Tests/ControlConnectionTests.swift @@ -0,0 +1,129 @@ +import Darwin +@_spi(Testing) import ForemanCore +import Foundation +import Testing + +/// Exercises the control-socket wire framing + dispatch over a real connected +/// `socketpair` — the byte-level behavior that the app's `ControlServer` used +/// to hide behind socket setup (line framing, EOF, oversize, malformed input, +/// and a full request→response round trip). +@MainActor +struct ControlConnectionTests { + /// A connected pair of stream sockets; closed via ``close()``. + private struct SocketPair { + let a: Int32 + let b: Int32 + func close() { + Darwin.close(a) + Darwin.close(b) + } + } + + private func makeSocketPair() throws -> SocketPair { + var fds = [Int32](repeating: 0, count: 2) + try #require(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0) + return SocketPair(a: fds[0], b: fds[1]) + } + + private func writeRaw(_ string: String, to fd: Int32) { + let bytes = Array(string.utf8) + _ = bytes.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } + } + + // MARK: - Framing + + @Test func readLineReturnsBytesBeforeTheNewline() throws { + let pair = try makeSocketPair() + defer { pair.close() } + writeRaw("hello\n", to: pair.a) + #expect(ControlConnection.readLine(fd: pair.b) == Data("hello".utf8)) + } + + @Test func readLineFramesConsecutiveLines() throws { + let pair = try makeSocketPair() + defer { pair.close() } + writeRaw("one\ntwo\n", to: pair.a) + #expect(ControlConnection.readLine(fd: pair.b) == Data("one".utf8)) + #expect(ControlConnection.readLine(fd: pair.b) == Data("two".utf8)) + } + + @Test func readLineReturnsWhatItHasAtEOF() throws { + let pair = try makeSocketPair() + defer { pair.close() } + writeRaw("partial", to: pair.a) + Darwin.close(pair.a) // EOF without a trailing newline + #expect(ControlConnection.readLine(fd: pair.b) == Data("partial".utf8)) + Darwin.close(pair.b) + } + + @Test func readLineRejectsAnOverlongLine() throws { + let pair = try makeSocketPair() + defer { pair.close() } + writeRaw("123456789", to: pair.a) // 9 bytes, no newline + #expect(ControlConnection.readLine(fd: pair.b, maxBytes: 8) == nil) + } + + @Test func writeLineAppendsANewline() throws { + let pair = try makeSocketPair() + defer { pair.close() } + ControlConnection.writeLine(fd: pair.a, Data("hi".utf8)) + // readLine returns only if a newline terminated the payload. + #expect(ControlConnection.readLine(fd: pair.b) == Data("hi".utf8)) + } + + // MARK: - Dispatch + + @Test func respondDispatchesAValidRequest() async throws { + let fixture = try makeControlServicesFixture(repoNames: ["Main"]) + fixture.services.start() + let handler = ControlRequestHandler(services: fixture.services) + let line = try JSONEncoder().encode(ControlRequest.describe) + + let data = await ControlConnection.respond(to: line, handler: handler) + let response = try JSONDecoder().decode(ControlResponse.self, from: data) + guard case let .describe(result) = response else { + Issue.record("expected a describe response, got \(response)") + return + } + #expect(result.repos.map(\.name) == ["Main"]) + } + + @Test func respondEncodesFailureForMalformedInput() async throws { + let fixture = try makeControlServicesFixture(repoNames: []) + fixture.services.start() + let handler = ControlRequestHandler(services: fixture.services) + + let data = await ControlConnection.respond(to: Data("{ not json".utf8), handler: handler) + let response = try JSONDecoder().decode(ControlResponse.self, from: data) + guard case let .failure(message) = response else { + Issue.record("expected a failure response, got \(response)") + return + } + #expect(message.contains("Malformed")) + } + + @Test func fullRoundTripOverASocketpair() async throws { + let fixture = try makeControlServicesFixture(repoNames: ["Main"]) + fixture.services.start() + let handler = ControlRequestHandler(services: fixture.services) + let pair = try makeSocketPair() + defer { pair.close() } + + // Client writes a request; the "server" side reads it, dispatches, and + // writes the reply back; the client reads that — all through the real + // framing in both directions. + try ControlConnection.writeLine(fd: pair.a, JSONEncoder().encode(ControlRequest.describe)) + let serverLine = try #require(ControlConnection.readLine(fd: pair.b)) + let reply = await ControlConnection.respond(to: serverLine, handler: handler) + ControlConnection.writeLine(fd: pair.b, reply) + + let clientLine = try #require(ControlConnection.readLine(fd: pair.a)) + let response = try JSONDecoder().decode(ControlResponse.self, from: clientLine) + guard case let .describe(result) = response else { + Issue.record("expected a describe response, got \(response)") + return + } + #expect(result.scanDirectory == fixture.scanDirectory.standardizedFileURL.path) + #expect(result.repos.map(\.name) == ["Main"]) + } +} From 46126e788a611059be754d010cee94442ad6eef8 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 6 Jul 2026 00:06:54 -0400 Subject: [PATCH 12/12] Test SystemRepoCopyRemover.removeClone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the previously-untested clone path: a happy path that asserts the clone is moved (not hard-deleted) to the volume-appropriate Trash — cleaning the moved item up afterwards via a uniquely-named directory so no Trash litter is left — and an error path asserting a missing item surfaces rather than being swallowed. Co-authored-by: Cursor --- .../Tests/RepoCopyRemoverTests.swift | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/Foreman/ForemanCore/Tests/RepoCopyRemoverTests.swift b/Foreman/ForemanCore/Tests/RepoCopyRemoverTests.swift index 48f74c08..d211277c 100644 --- a/Foreman/ForemanCore/Tests/RepoCopyRemoverTests.swift +++ b/Foreman/ForemanCore/Tests/RepoCopyRemoverTests.swift @@ -3,9 +3,9 @@ import Foundation import Testing /// Exercises the production remover against real `git` in a temp repo (never -/// the user's real repos). The clone path (moving to the Trash via -/// `FileManager`) is left to the `removeCopy` flow test with a stubbed remover -/// so tests don't pollute the user's Trash. +/// the user's real repos). The clone path really trashes via `FileManager`, so +/// those tests locate the volume-appropriate Trash and clean up the moved item +/// afterwards rather than leaving it behind. struct RepoCopyRemoverTests { @Test func removesARealWorktree() throws { let base = try makeTemporaryDirectory() @@ -40,6 +40,44 @@ struct RepoCopyRemoverTests { } } + @Test func removesACloneToTheTrash() throws { + let base = try makeTemporaryDirectory() + // A unique name so the trashed item can't collide (and thus be renamed), + // which keeps cleanup deterministic. + let name = "Clone-\(UUID().uuidString)" + let clone = base.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory( + at: clone.appendingPathComponent(".git"), + withIntermediateDirectories: true, + ) + + // Where this volume trashes items, so we can remove the moved copy and + // not litter the Trash. + let trashDirectory = try FileManager.default.url( + for: .trashDirectory, + in: .userDomainMask, + appropriateFor: clone, + create: false, + ) + let trashedItem = trashDirectory.appendingPathComponent(name) + defer { try? FileManager.default.removeItem(at: trashedItem) } + + try SystemRepoCopyRemover().removeClone(at: clone) + + // Gone from its original spot, and moved (not hard-deleted) to the Trash. + #expect(!FileManager.default.fileExists(atPath: clone.path)) + #expect(FileManager.default.fileExists(atPath: trashedItem.path)) + } + + @Test func cloneRemovalSurfacesAMissingItem() throws { + let base = try makeTemporaryDirectory() + let missing = base.appendingPathComponent("Nope", isDirectory: true) + + #expect(throws: (any Error).self) { + try SystemRepoCopyRemover().removeClone(at: missing) + } + } + // MARK: - Git helpers private func makeGitRepoWithCommit(at url: URL) throws {