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..dcf8217e 100644 --- a/Foreman/Foreman/AGENTS.md +++ b/Foreman/Foreman/AGENTS.md @@ -20,6 +20,19 @@ 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): 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 + `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/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/ControlServer.swift b/Foreman/Foreman/Sources/ControlServer.swift new file mode 100644 index 00000000..8b09a636 --- /dev/null +++ b/Foreman/Foreman/Sources/ControlServer.swift @@ -0,0 +1,180 @@ +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 + /// 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) { + 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: acceptQueue) + source.setEventHandler { [weak self] in + guard let self else { return } + let clientFD = accept(fd, nil, nil) + guard clientFD >= 0 else { return } + 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) + } + acceptSource = source + source.resume() + 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() + acceptSource = nil + listenFD = -1 + unlink(socketURL.path) + } + + /// 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 = ControlConnection.readLine(fd: clientFD), !line.isEmpty else { + close(clientFD) + return + } + Task { @MainActor [weak self] in + guard let self else { + close(clientFD) + return + } + let data = await ControlConnection.respond(to: line, handler: handler) + workQueue.async { + ControlConnection.writeLine(fd: clientFD, data) + close(clientFD) + } + } + } +} 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/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() { 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) diff --git a/Foreman/ForemanCore/AGENTS.md b/Foreman/ForemanCore/AGENTS.md index c0ddb01f..177ff619 100644 --- a/Foreman/ForemanCore/AGENTS.md +++ b/Foreman/ForemanCore/AGENTS.md @@ -12,9 +12,18 @@ 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`, `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 build system, formatting, and global conventions. Read that first. @@ -60,6 +69,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 +88,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..8ed3471b 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,45 @@ 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`. +- **`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 + `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/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/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..6ace71ca 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,138 @@ 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 { + // 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.standardizedFileURL.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 + + // 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() + } 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 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( + at: copy, + parentRepoPath: URL(fileURLWithPath: provenance.parentRepoID.rawValue), + ) + case .clone: + 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) + } + + 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 +375,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 +396,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/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"]) + } +} 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..8669dd04 --- /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.standardizedFileURL.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..92fc3f7a 100644 --- a/Foreman/ForemanCore/Tests/ForemanServicesTests.swift +++ b/Foreman/ForemanCore/Tests/ForemanServicesTests.swift @@ -396,4 +396,173 @@ 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.standardizedFileURL.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 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" }) + #expect(repo.isEnabled) + try await waitUntil("worker running again after failed removal") { + repo.worker.state.isLive + } + } } diff --git a/Foreman/ForemanCore/Tests/RepoCopyRemoverTests.swift b/Foreman/ForemanCore/Tests/RepoCopyRemoverTests.swift new file mode 100644 index 00000000..d211277c --- /dev/null +++ b/Foreman/ForemanCore/Tests/RepoCopyRemoverTests.swift @@ -0,0 +1,117 @@ +@_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 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() + 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) + } + } + + @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 { + 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, 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/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). 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..8000f60a --- /dev/null +++ b/Foreman/foreman-mcp/src/index.ts @@ -0,0 +1,277 @@ +#!/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 }; +} + +/// 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 + .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 !== undefined && !isValidCopyName(name)) { + return fail( + `'name' must be a single visible directory name — no '/', no leading dot, not '.'/'..' (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..b3998cb9 --- /dev/null +++ b/Foreman/foreman-mcp/test/integration.mjs @@ -0,0 +1,179 @@ +// 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"); + + // 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"); +} + +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/**/*"] +}