diff --git a/.asc/workflow.json b/.asc/workflow.json new file mode 100644 index 0000000..ad57e72 --- /dev/null +++ b/.asc/workflow.json @@ -0,0 +1,144 @@ +{ + "env": { + "ASC_PROFILE": "Runline", + "APP_ID": "6766299514", + "BUNDLE_ID": "com.matthewparris.runline", + "VERSION": "1.0", + "PROJECT": "CursorMobile.xcodeproj", + "SCHEME": "CursorMobile", + "SIM_DESTINATION": "platform=iOS Simulator,name=iPhone 17,OS=26.4.1", + "TESTFLIGHT_GROUP_ID": "9ddf92fa-5b17-48ca-9833-5d83c3e31d3d", + "INTERNAL_TESTFLIGHT_GROUP_ID": "a252108c-c4bc-4b6b-94cd-519b7be04c68", + "LOCALE": "en-US", + "TEST_NOTES": "Verify Cloud Agent and Cursor SDK workflows, iPhone and iPad layouts, chat composer, attachments, model and MCP controls, Settings, light mode, and dark mode." + }, + "before_all": "asc --profile $ASC_PROFILE auth status --validate --output json >/dev/null", + "after_all": "echo workflow_done >&2", + "error": "echo workflow_failed >&2", + "workflows": { + "asc-health": { + "description": "Verify ASC auth, app resolution, TestFlight groups, and the latest visible builds.", + "steps": [ + { + "name": "auth_status", + "run": "asc --profile $ASC_PROFILE auth status --validate --verbose" + }, + { + "name": "app", + "run": "asc --profile $ASC_PROFILE apps view --id $APP_ID --output json" + }, + { + "name": "groups", + "run": "asc --profile $ASC_PROFILE testflight groups list --app $APP_ID --output json" + }, + { + "name": "builds", + "run": "asc --profile $ASC_PROFILE builds list --app $APP_ID --version $VERSION --platform IOS --limit 10 --sort -uploadedDate --output json" + } + ] + }, + "preflight": { + "description": "Run local checks before archiving a TestFlight build.", + "steps": [ + { + "name": "repo_clean", + "run": "test -z \"$(git status --short)\" || { git status --short >&2; echo 'Working tree must be clean before release.' >&2; exit 1; }" + }, + { + "name": "diff_check", + "run": "git diff --check" + }, + { + "name": "orchestrator_typecheck", + "run": "npm --prefix orchestrator run typecheck" + }, + { + "name": "ios_tests", + "run": "xcodebuild test -project $PROJECT -scheme $SCHEME -destination \"$SIM_DESTINATION\"" + } + ] + }, + "testflight": { + "description": "Set an explicit build number, run checks, archive, upload, add What to Test notes, and distribute to TestFlight.", + "env": { + "BUILD_NUMBER": "" + }, + "steps": [ + { + "name": "require_build_number", + "run": "test -n \"$BUILD_NUMBER\" || { echo 'Pass BUILD_NUMBER, for example: asc workflow run testflight BUILD_NUMBER:9' >&2; exit 2; }" + }, + { + "workflow": "preflight" + }, + { + "name": "set_build_number", + "run": "ruby Tools/set_build_number.rb \"$BUILD_NUMBER\"" + }, + { + "name": "generate_project", + "run": "xcodegen generate" + }, + { + "name": "archive", + "run": "asc --profile $ASC_PROFILE xcode archive --project $PROJECT --scheme $SCHEME --configuration Release --clean --overwrite --archive-path \".asc/artifacts/Runline-${VERSION}-${BUILD_NUMBER}.xcarchive\" --xcodebuild-flag=-destination --xcodebuild-flag=generic/platform=iOS --xcodebuild-flag=-allowProvisioningUpdates --output json" + }, + { + "name": "upload", + "run": "asc --profile $ASC_PROFILE xcode export --archive-path \".asc/artifacts/Runline-${VERSION}-${BUILD_NUMBER}.xcarchive\" --export-options ExportOptions-TestFlightUpload.plist --ipa-path \".asc/artifacts/Runline-${VERSION}-${BUILD_NUMBER}.ipa\" --overwrite --wait --xcodebuild-flag=-allowProvisioningUpdates --output json" + }, + { + "name": "resolve_uploaded_build", + "run": "asc --profile $ASC_PROFILE builds info --app $APP_ID --build-number \"$BUILD_NUMBER\" --version \"$VERSION\" --platform IOS --output json", + "outputs": { + "BUILD_ID": "$.data.id" + } + }, + { + "name": "test_notes", + "run": "if [ -n \"$TEST_NOTES\" ]; then asc --profile $ASC_PROFILE builds test-notes create --build-id \"${steps.resolve_uploaded_build.BUILD_ID}\" --locale \"$LOCALE\" --whats-new \"$TEST_NOTES\" || asc --profile $ASC_PROFILE builds test-notes update --build-id \"${steps.resolve_uploaded_build.BUILD_ID}\" --locale \"$LOCALE\" --whats-new \"$TEST_NOTES\"; else echo 'Skipping TestFlight notes because TEST_NOTES is empty.' >&2; fi" + }, + { + "name": "distribute", + "run": "if [ -n \"$TESTFLIGHT_GROUP_ID\" ]; then asc --profile $ASC_PROFILE builds add-groups --build-id \"${steps.resolve_uploaded_build.BUILD_ID}\" --group \"$TESTFLIGHT_GROUP_ID\"; else echo 'Skipping TestFlight group distribution because TESTFLIGHT_GROUP_ID is empty.' >&2; fi" + }, + { + "name": "summary", + "run": "asc --profile $ASC_PROFILE builds info --build-id \"${steps.resolve_uploaded_build.BUILD_ID}\" --output json" + } + ] + }, + "distribute-existing": { + "description": "Attach notes and distribute an already uploaded build by BUILD_ID or BUILD_NUMBER.", + "env": { + "BUILD_ID": "", + "BUILD_NUMBER": "" + }, + "steps": [ + { + "name": "require_build", + "run": "test -n \"$BUILD_ID\" -o -n \"$BUILD_NUMBER\" || { echo 'Pass BUILD_ID or BUILD_NUMBER, for example: asc workflow run distribute-existing BUILD_NUMBER:8' >&2; exit 2; }" + }, + { + "name": "resolve_existing_build", + "run": "if [ -n \"$BUILD_ID\" ]; then asc --profile $ASC_PROFILE builds info --build-id \"$BUILD_ID\" --output json; else asc --profile $ASC_PROFILE builds info --app $APP_ID --build-number \"$BUILD_NUMBER\" --version \"$VERSION\" --platform IOS --output json; fi", + "outputs": { + "BUILD_ID": "$.data.id" + } + }, + { + "name": "test_notes", + "run": "if [ -n \"$TEST_NOTES\" ]; then asc --profile $ASC_PROFILE builds test-notes create --build-id \"${steps.resolve_existing_build.BUILD_ID}\" --locale \"$LOCALE\" --whats-new \"$TEST_NOTES\" || asc --profile $ASC_PROFILE builds test-notes update --build-id \"${steps.resolve_existing_build.BUILD_ID}\" --locale \"$LOCALE\" --whats-new \"$TEST_NOTES\"; else echo 'Skipping TestFlight notes because TEST_NOTES is empty.' >&2; fi" + }, + { + "name": "distribute", + "run": "if [ -n \"$TESTFLIGHT_GROUP_ID\" ]; then asc --profile $ASC_PROFILE builds add-groups --build-id \"${steps.resolve_existing_build.BUILD_ID}\" --group \"$TESTFLIGHT_GROUP_ID\"; else echo 'Skipping TestFlight group distribution because TESTFLIGHT_GROUP_ID is empty.' >&2; fi" + }, + { + "name": "summary", + "run": "asc --profile $ASC_PROFILE builds info --build-id \"${steps.resolve_existing_build.BUILD_ID}\" --output json" + } + ] + } + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..5ec75f2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,54 @@ +name: Bug report +description: Report a reproducible Runline issue. +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Do not include Cursor API keys, bridge pairing tokens, Apple credentials, private repository contents, or other secrets. + - type: input + id: version + attributes: + label: App or bridge version + description: Include the TestFlight build number or `runline-bridge --version`. + placeholder: "Runline 1.0 (11), runline-bridge 0.1.0" + validations: + required: true + - type: dropdown + id: mode + attributes: + label: Mode + options: + - Cloud Agent + - Cursor SDK with Runline Bridge + - Settings or onboarding + - Other + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: "1. Open...\n2. Tap...\n3. See..." + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + placeholder: "iPhone/iPad model, iOS version, Mac version, Node version, network setup" + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8fbb09d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: mailto:parrisdigital@gmail.com + about: Please report security vulnerabilities privately. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..c9a1c6d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,33 @@ +name: Feature request +description: Suggest a focused improvement. +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What user workflow should this improve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: What should Runline do? + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - Cloud Agent + - Cursor SDK and Runline Bridge + - iPhone UI + - iPad UI + - Notifications + - Documentation + - Other + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..af2b92e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +## Summary + +- + +## Testing + +- [ ] `npm --prefix orchestrator run typecheck` +- [ ] `npm --prefix orchestrator run build` +- [ ] iOS tests or manual simulator testing, as relevant + +## Checklist + +- [ ] Cloud Agent mode still works without Runline Bridge +- [ ] Cursor SDK mode remains optional and clearly labeled +- [ ] No secrets, signing files, generated archives, IPAs, or local artifacts are committed +- [ ] Documentation was updated if behavior changed diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ce6b0fa --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: /orchestrator + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c5577bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + bridge: + name: Runline Bridge + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: npm + cache-dependency-path: orchestrator/npm-shrinkwrap.json + - run: npm ci + working-directory: orchestrator + - run: npm run typecheck + working-directory: orchestrator + - run: npm run build + working-directory: orchestrator + - run: npm pack --pack-destination /tmp + working-directory: orchestrator + + project-metadata: + name: Project Metadata + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - run: xcodebuild -list -project CursorMobile.xcodeproj + - run: ruby -c Tools/set_build_number.rb + - run: plutil -lint ExportOptions-AppStore.plist ExportOptions-TestFlightUpload.plist CursorMobile/Resources/Info.plist CursorMobile/Resources/PrivacyInfo.xcprivacy CursorMobile/Resources/CursorMobile.entitlements diff --git a/.gitignore b/.gitignore index 800ff8b..a2210a9 100644 --- a/.gitignore +++ b/.gitignore @@ -18,13 +18,23 @@ Package.resolved # Node node_modules/ **/node_modules/ +orchestrator/dist/ # Local secrets and generated config +.asc/artifacts/ +.asc/runs/ .env .env.* +.npmrc *.local.xcconfig Secrets.plist *.p8 +*.p12 +*.pem +*.key +*.cer +*.mobileprovision +*.provisionprofile # macOS .DS_Store diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..4fce96d --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,6 @@ +[allowlist] +description = "Allow documentation placeholders that are intentionally non-secret." +regexes = [ + '''YOUR_CURSOR_API_KEY''', + '''your-cursor-key''' +] diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..96a11c0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,23 @@ +# Code of Conduct + +## Our Pledge + +We pledge to make participation in this project respectful and harassment-free for everyone. + +## Expected Behavior + +- Be direct, constructive, and respectful. +- Assume good intent while asking for clarification when needed. +- Keep technical criticism focused on the work. +- Respect privacy and do not post secrets, tokens, private keys, private repository contents, or personal data. + +## Unacceptable Behavior + +- Harassment, insults, threats, or discriminatory language. +- Publishing private information without permission. +- Publicly disclosing security vulnerabilities before maintainers have had a reasonable chance to respond. +- Repeatedly derailing issues or pull requests. + +## Enforcement + +Report concerns to `parrisdigital@gmail.com`. Maintainers may remove comments, close issues, reject pull requests, or block participants when needed to protect the project. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..538b36d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing + +Thanks for helping improve Runline. This project is in public beta, so keep changes focused and easy to review. + +Runline is independent and is not affiliated with, endorsed by, or connected to Cursor or Anysphere. + +## Development Setup + +Requirements: + +- macOS with Xcode capable of building the configured iOS target +- iOS 26+ simulator runtime for full local app testing +- Node.js 20+ +- npm + +Install bridge dependencies: + +```bash +npm --prefix orchestrator install +``` + +Run bridge checks: + +```bash +npm --prefix orchestrator run typecheck +npm --prefix orchestrator run build +``` + +Run iOS tests: + +```bash +xcodebuild test \ + -project CursorMobile.xcodeproj \ + -scheme CursorMobile \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.4.1' +``` + +## Pull Requests + +- Keep UI changes native to SwiftUI and iOS system patterns. +- Keep Cloud Agent mode working without Runline Bridge. +- Keep Cursor SDK mode optional and clearly labeled as requiring Runline Bridge. +- Add or update tests for behavior changes. +- Do not commit generated archives, IPAs, derived data, local ASC artifacts, npm tokens, Apple signing material, API keys, or private repository data. +- Run `git diff --check`, bridge typecheck/build, and relevant iOS tests before opening a PR. + +## Security + +Report vulnerabilities privately using [SECURITY.md](SECURITY.md). Do not disclose credentials or private repository data in public issues or pull requests. diff --git a/CursorMobile.xcodeproj/project.pbxproj b/CursorMobile.xcodeproj/project.pbxproj index 5aae0d9..2574443 100644 --- a/CursorMobile.xcodeproj/project.pbxproj +++ b/CursorMobile.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 31CFAA632F08E7E74221BC7D /* GitProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7F5C698839855157D73D4F /* GitProvider.swift */; }; 347AD57ABCA4D15CF3C2FCFE /* AgentProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEB0AB871CBC28B9BF2D3A0 /* AgentProvider.swift */; }; 36EA754A5E9A03C86836B932 /* AppAppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9E967D0B11FCF0E6A17FAC /* AppAppearanceMode.swift */; }; + 3CC886B6A4F9CC05223F1C84 /* CursorSDKOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15284E1FD2559981B42965C6 /* CursorSDKOnboardingView.swift */; }; 407A501808983E7BD28EFE97 /* ChatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55065E328E23820ECB0F8000 /* ChatsView.swift */; }; 5536A71994566F3564313B92 /* CursorMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BB60A17971111EFF2DDECE /* CursorMobileApp.swift */; }; 56965BD0AAEB4B9A7DE5CC15 /* MockAgentProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F5ABA55E3CC9F99D9F6E7A3 /* MockAgentProvider.swift */; }; @@ -70,6 +71,7 @@ 0E028E12BF4EB0F89573EF17 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 0E76DFB71C686BF12AA61EF2 /* NativeRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeRows.swift; sourceTree = ""; }; 119E1E9B68CE534FEDE4DBB9 /* SDKBridgeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKBridgeClientTests.swift; sourceTree = ""; }; + 15284E1FD2559981B42965C6 /* CursorSDKOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CursorSDKOnboardingView.swift; sourceTree = ""; }; 1C3B54D881A589AF2BBA2E3E /* PromptFileLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptFileLoader.swift; sourceTree = ""; }; 1D2351C5637FA72A1A5A2E04 /* CursorMobile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CursorMobile.entitlements; sourceTree = ""; }; 1ED862484103E40E496ADCA7 /* AppTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTab.swift; sourceTree = ""; }; @@ -178,6 +180,7 @@ 8F67359EDFE1E8E08AFC560C /* ChatDetailView.swift */, 55065E328E23820ECB0F8000 /* ChatsView.swift */, F412BDC57CF3ADDCC67E69BE /* ChatTimelineBuilder.swift */, + 15284E1FD2559981B42965C6 /* CursorSDKOnboardingView.swift */, 0E76DFB71C686BF12AA61EF2 /* NativeRows.swift */, CF697C1FF562FFB0BCDAEE8C /* NewChatSheet.swift */, 1C3B54D881A589AF2BBA2E3E /* PromptFileLoader.swift */, @@ -381,6 +384,7 @@ 75B29E76152396217BC0F7C8 /* CursorAgentProvider.swift in Sources */, D42DB9396C9BAA67A304C010 /* CursorDTOs.swift in Sources */, 5536A71994566F3564313B92 /* CursorMobileApp.swift in Sources */, + 3CC886B6A4F9CC05223F1C84 /* CursorSDKOnboardingView.swift in Sources */, 58C224CABDAC81AD077EAD8A /* CursorSSEParser.swift in Sources */, 223994AFA50ECB6343B85BD4 /* DomainModels.swift in Sources */, EFECB3F1EBAC7D11BC5EDCA6 /* EnterpriseDTOs.swift in Sources */, @@ -489,7 +493,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 11; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -555,7 +559,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 8; + CURRENT_PROJECT_VERSION = 11; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; diff --git a/CursorMobile/App/AppState.swift b/CursorMobile/App/AppState.swift index 4ffae82..df28012 100644 --- a/CursorMobile/App/AppState.swift +++ b/CursorMobile/App/AppState.swift @@ -10,6 +10,7 @@ final class AppState { private var enterpriseProvider: EnterpriseDataProvider? private let apiKeyStore: APIKeyStore private let enterpriseAPIKeyStore: APIKeyStore + private let sdkBridgeTokenStore: APIKeyStore private let appCache: LocalAppCache private let providerFactory: @MainActor (String) throws -> AgentProvider private var hasRestoredConnection = false @@ -17,6 +18,7 @@ final class AppState { private var streamExpiredRunIDs: Set = [] private var sdkBridgeRunIDs: Set = [] private var sdkBridgeMCPProfileIDsByAgentID: [Agent.ID: SDKBridgeMCPProfile.ID] = [:] + private var sdkBridgeToken: String? private var artifactDownloadsByKey: [String: ArtifactDownload] = [:] var selectedTab: AppTab = .chats @@ -32,6 +34,8 @@ final class AppState { var loadingEndpointIDs: Set = [] var endpointPageOverrides: [CursorAPIEndpoint.ID: Int] = [:] var sdkBridgeProfiles: [SDKBridgeMCPProfile] = [] + var sdkBridgeConnectionState: SDKBridgeConnectionState = SDKBridgePreferences.isEnabled() ? .unchecked : .disabled + var sdkBridgePairingState: SDKBridgePairingState = .idle var focusedAgentID: Agent.ID? var notificationPreferences = NotificationPreferences() var notificationAuthorizationStatus: UNAuthorizationStatus = .notDetermined @@ -44,25 +48,29 @@ final class AppState { var statusMessage: String? var launchDraft: AgentLaunchDraft - private let sdkBridgeClientFactory: @MainActor (URL, String?) -> SDKBridgeClient + private let sdkBridgeClientFactory: @MainActor (URL, String?, String?) -> SDKBridgeClient init( provider: AgentProvider? = nil, apiKeyStore: APIKeyStore = KeychainAPIKeyStore(), enterpriseAPIKeyStore: APIKeyStore = KeychainAPIKeyStore(account: .cursorEnterpriseAdmin), + sdkBridgeTokenStore: APIKeyStore = KeychainAPIKeyStore(account: .runlineBridgeToken), appCache: LocalAppCache = LocalAppCache(), providerFactory: @escaping @MainActor (String) throws -> AgentProvider = { try CursorAgentProvider(apiKey: $0) }, - sdkBridgeClientFactory: @escaping @MainActor (URL, String?) -> SDKBridgeClient = { baseURL, apiKey in - SDKBridgeClient(baseURL: baseURL, apiKey: apiKey) + sdkBridgeClientFactory: @escaping @MainActor (URL, String?, String?) -> SDKBridgeClient = { baseURL, apiKey, bridgeToken in + SDKBridgeClient(baseURL: baseURL, apiKey: apiKey, bridgeToken: bridgeToken) } ) { self.provider = provider enterpriseProvider = provider as? EnterpriseDataProvider self.apiKeyStore = apiKeyStore self.enterpriseAPIKeyStore = enterpriseAPIKeyStore + self.sdkBridgeTokenStore = sdkBridgeTokenStore self.appCache = appCache self.providerFactory = providerFactory self.sdkBridgeClientFactory = sdkBridgeClientFactory + let storedBridgeToken = try? sdkBridgeTokenStore.loadAPIKey()?.nilIfBlank + sdkBridgeToken = storedBridgeToken launchDraft = AgentLaunchDraft( prompt: AgentPrompt(text: ""), modelID: nil, @@ -72,6 +80,9 @@ final class AppState { autoCreatePullRequest: true, skipReviewerRequest: false ) + if storedBridgeToken != nil { + sdkBridgePairingState = .paired("Runline Bridge") + } } var capabilities: ProviderCapabilities { @@ -121,18 +132,44 @@ final class AppState { return true } - var sdkBridgeLaunchIssue: String? { - guard launchDraft.runMode == .sdkBridge else { return nil } + var isSDKBridgeReadyForLaunch: Bool { + sdkBridgeReadinessIssue == nil + } + + var isSDKBridgePaired: Bool { + sdkBridgeToken?.nilIfBlank != nil + } + + var sdkBridgeReadinessIssue: String? { guard account != nil else { - return "Connect a Cursor API key before using SDK Agent." + return "Connect a Cursor API key before using Cursor SDK." } guard SDKBridgePreferences.isEnabled() else { - return "Enable the SDK bridge in Settings before launching SDK Agent." + return "Enable Runline Bridge in Settings before using Cursor SDK." } guard SDKBridgePreferences.configuredBaseURL() != nil else { - return "Enter a valid SDK bridge URL in Settings." + return "Enter a valid Runline Bridge URL in Settings." + } + guard isSDKBridgePaired else { + return "Pair Runline Bridge in Settings before using Cursor SDK." + } + switch sdkBridgeConnectionState { + case .connected: + return nil + case .disabled: + return "Enable Runline Bridge in Settings before using Cursor SDK." + case .unchecked: + return "Check the Runline Bridge connection in Settings before using Cursor SDK." + case .checking: + return "Runline is checking the bridge connection." + case .failed(let message): + return "Runline Bridge is unavailable. \(message)" } - return nil + } + + var sdkBridgeLaunchIssue: String? { + guard launchDraft.runMode == .sdkBridge else { return nil } + return sdkBridgeReadinessIssue } func restoreConnectionIfAvailable() async { @@ -171,6 +208,7 @@ final class AppState { provider = cursorProvider enterpriseProvider = cursorProvider as? EnterpriseDataProvider account = validatedAccount + syncSDKBridgeConfiguration() } catch { provider = nil enterpriseProvider = nil @@ -192,6 +230,7 @@ final class AppState { do { try apiKeyStore.deleteAPIKey() try enterpriseAPIKeyStore.deleteAPIKey() + try sdkBridgeTokenStore.deleteAPIKey() } catch { errorMessage = "Could not remove the local Cursor API key." } @@ -208,7 +247,10 @@ final class AppState { artifactsByAgentID = [:] sdkBridgeRunIDs = [] sdkBridgeMCPProfileIDsByAgentID = [:] + sdkBridgeToken = nil sdkBridgeProfiles = [] + sdkBridgePairingState = .idle + sdkBridgeConnectionState = SDKBridgePreferences.isEnabled() ? .unchecked : .disabled endpointResults = [:] loadingEndpointIDs = [] endpointPageOverrides = [:] @@ -337,10 +379,22 @@ final class AppState { } func applyDefaultRunMode(_ mode: AgentRunMode) { + if mode == .sdkBridge, !isSDKBridgeReadyForLaunch { + launchDraft.runMode = .cloudAgent + saveCachedState() + return + } launchDraft.runMode = mode saveCachedState() } + func ensureLaunchRunModeIsAvailable() { + if launchDraft.runMode == .sdkBridge, !isSDKBridgeReadyForLaunch { + launchDraft.runMode = .cloudAgent + saveCachedState() + } + } + func isSDKBridgeRun(runID: AgentRun.ID) -> Bool { sdkBridgeRunIDs.contains(runID) } @@ -358,15 +412,158 @@ final class AppState { sdkBridgeMCPProfileIDsByAgentID[agent.id] } + func syncSDKBridgeConfiguration(resetConnection: Bool = false) { + guard SDKBridgePreferences.isEnabled() else { + sdkBridgeConnectionState = .disabled + sdkBridgeProfiles = [] + ensureLaunchRunModeIsAvailable() + return + } + guard SDKBridgePreferences.configuredBaseURL() != nil else { + sdkBridgeConnectionState = .failed("The bridge URL is invalid.") + sdkBridgeProfiles = [] + ensureLaunchRunModeIsAvailable() + return + } + if resetConnection || !sdkBridgeConnectionState.isConnected { + sdkBridgeConnectionState = .unchecked + sdkBridgeProfiles = [] + ensureLaunchRunModeIsAvailable() + } + } + + func startSDKBridgePairing() async { + guard SDKBridgePreferences.isEnabled() else { + sdkBridgePairingState = .failed("Enable Runline Bridge before pairing.") + return + } + guard let baseURL = SDKBridgePreferences.configuredBaseURL() else { + sdkBridgePairingState = .failed("Enter a valid Runline Bridge URL before pairing.") + return + } + + sdkBridgePairingState = .starting + do { + let response = try await sdkBridgeClientFactory(baseURL, nil, nil).startPairing(deviceName: UIDevice.current.name) + if response.pairingRequired == false { + sdkBridgePairingState = .paired("Runline Bridge") + sdkBridgeConnectionState = .unchecked + return + } + guard let pairingID = response.pairingId?.nilIfBlank else { + sdkBridgePairingState = .failed("Runline Bridge did not return a pairing session.") + return + } + sdkBridgePairingState = .waiting( + pairingID: pairingID, + expiresAt: response.expiresAt, + message: response.message + ) + } catch { + sdkBridgePairingState = .failed(sdkBridgeConnectionFailureMessage(for: error, baseURL: baseURL)) + } + } + + func completeSDKBridgePairing(code: String) async { + guard case .waiting(let pairingID, _, _) = sdkBridgePairingState else { + sdkBridgePairingState = .failed("Start pairing before entering a code.") + return + } + guard let baseURL = SDKBridgePreferences.configuredBaseURL() else { + sdkBridgePairingState = .failed("Enter a valid Runline Bridge URL before pairing.") + return + } + let pairingCode = code.trimmingCharacters(in: .whitespacesAndNewlines) + guard !pairingCode.isEmpty else { + sdkBridgePairingState = .failed("Enter the pairing code shown in the bridge terminal.") + return + } + + sdkBridgePairingState = .completing + do { + let response = try await sdkBridgeClientFactory(baseURL, nil, nil).completePairing( + pairingID: pairingID, + code: pairingCode, + deviceName: UIDevice.current.name + ) + guard let token = response.bridgeToken?.nilIfBlank else { + sdkBridgePairingState = .failed("Runline Bridge did not return a bridge token.") + return + } + try sdkBridgeTokenStore.saveAPIKey(token) + sdkBridgeToken = token + sdkBridgePairingState = .paired(response.bridgeName?.nilIfBlank ?? response.service?.nilIfBlank ?? "Runline Bridge") + sdkBridgeConnectionState = .unchecked + await checkSDKBridgeConnection() + } catch { + sdkBridgePairingState = .failed(sdkBridgeConnectionFailureMessage(for: error, baseURL: baseURL)) + } + } + + func forgetSDKBridgePairing() { + do { + try sdkBridgeTokenStore.deleteAPIKey() + } catch { + errorMessage = "Could not remove the Runline Bridge pairing token." + } + sdkBridgeToken = nil + sdkBridgeProfiles = [] + sdkBridgePairingState = .idle + sdkBridgeConnectionState = SDKBridgePreferences.isEnabled() ? .unchecked : .disabled + ensureLaunchRunModeIsAvailable() + } + + func checkSDKBridgeConnection() async { + guard SDKBridgePreferences.isEnabled() else { + sdkBridgeConnectionState = .disabled + sdkBridgeProfiles = [] + ensureLaunchRunModeIsAvailable() + return + } + guard let baseURL = SDKBridgePreferences.configuredBaseURL() else { + sdkBridgeConnectionState = .failed("The bridge URL is invalid.") + sdkBridgeProfiles = [] + ensureLaunchRunModeIsAvailable() + return + } + guard isSDKBridgePaired else { + sdkBridgeConnectionState = .failed("Pair Runline Bridge before checking Cursor SDK.") + sdkBridgeProfiles = [] + ensureLaunchRunModeIsAvailable() + return + } + + sdkBridgeConnectionState = .checking + do { + let health = try await sdkBridgeClientFactory(baseURL, nil, sdkBridgeToken).health() + if health.ok, health.paired != false { + sdkBridgeConnectionState = .connected("\(health.service) - \(health.sdk)") + await reloadSDKBridgeProfiles() + } else { + sdkBridgeConnectionState = .failed("The bridge responded but did not accept this pairing token.") + sdkBridgeProfiles = [] + ensureLaunchRunModeIsAvailable() + } + } catch { + sdkBridgeConnectionState = .failed(sdkBridgeConnectionFailureMessage(for: error, baseURL: baseURL)) + sdkBridgeProfiles = [] + ensureLaunchRunModeIsAvailable() + } + } + func reloadSDKBridgeProfiles() async { guard SDKBridgePreferences.isEnabled(), let baseURL = SDKBridgePreferences.configuredBaseURL() else { sdkBridgeProfiles = [] return } + guard isSDKBridgePaired else { + sdkBridgeProfiles = [] + return + } do { let apiKey = try apiKeyStore.loadAPIKey()?.nilIfBlank - sdkBridgeProfiles = try await sdkBridgeClientFactory(baseURL, apiKey).listMCPProfiles() + sdkBridgeProfiles = try await sdkBridgeClientFactory(baseURL, apiKey, sdkBridgeToken).listMCPProfiles() if let profileID = launchDraft.sdkMCPProfileID, !sdkBridgeProfiles.contains(where: { $0.id == profileID }) { launchDraft.sdkMCPProfileID = nil @@ -650,8 +847,8 @@ final class AppState { id: "\(result.run.id)-sdk-started", runID: result.run.id, kind: .status, - title: "SDK Agent", - message: "Session started through the Cursor SDK bridge.", + title: "Cursor SDK", + message: "Session started through Runline Bridge.", timestamp: "now" ) ] : [] @@ -767,8 +964,8 @@ final class AppState { id: "\(run.id)-sdk-\(intent.rawValue)", runID: run.id, kind: .status, - title: "SDK Agent", - message: "\(intent.title) message sent through the Cursor SDK bridge.", + title: "Cursor SDK", + message: "\(intent.title) message sent through Runline Bridge.", timestamp: "now" ), ] @@ -999,10 +1196,13 @@ final class AppState { guard let baseURL = SDKBridgePreferences.configuredBaseURL() else { throw SDKBridgeUnavailableError.invalidBridgeURL } + guard let bridgeToken = sdkBridgeToken?.nilIfBlank else { + throw SDKBridgeUnavailableError.bridgeNotPaired + } guard let apiKey = try apiKeyStore.loadAPIKey()?.nilIfBlank else { throw SDKBridgeUnavailableError.missingAPIKey } - return sdkBridgeClientFactory(baseURL, apiKey) + return sdkBridgeClientFactory(baseURL, apiKey, bridgeToken) } private func updateAgent(_ agentID: Agent.ID, mutate: (inout Agent) -> Void) { @@ -1143,10 +1343,28 @@ final class AppState { return String(firstLine.prefix(45)) + "..." } + private func sdkBridgeConnectionFailureMessage(for error: Error, baseURL: URL) -> String { + if SDKBridgePreferences.isLoopback(baseURL) { + return "Runline cannot reach \(baseURL.absoluteString). On a physical iPhone, localhost points to the phone. Use your Mac LAN URL or a hosted HTTPS bridge." + } + if let urlError = error as? URLError { + switch urlError.code { + case .cannotConnectToHost, .cannotFindHost, .networkConnectionLost, .notConnectedToInternet, .timedOut: + return "Runline cannot reach \(baseURL.absoluteString). Start Runline Bridge or update the URL." + default: + break + } + } + return error.localizedDescription + } + private func userMessage(from error: Error) -> String { if let apiError = error as? CursorAPIError { return apiError.userMessage } + if let bridgeError = error as? SDKBridgeUnavailableError { + return bridgeError.errorDescription ?? error.localizedDescription + } return error.localizedDescription } @@ -1290,16 +1508,19 @@ final class AppState { private enum SDKBridgeUnavailableError: LocalizedError { case bridgeDisabled case invalidBridgeURL + case bridgeNotPaired case missingAPIKey var errorDescription: String? { switch self { case .bridgeDisabled: - "Enable the SDK bridge in Settings before using SDK Agent." + "Enable Runline Bridge in Settings before using Cursor SDK." case .invalidBridgeURL: - "Enter a valid SDK bridge URL in Settings." + "Enter a valid Runline Bridge URL in Settings." + case .bridgeNotPaired: + "Pair Runline Bridge in Settings before using Cursor SDK." case .missingAPIKey: - "Reconnect your Cursor API key before using SDK Agent." + "Reconnect your Cursor API key before using Cursor SDK." } } } diff --git a/CursorMobile/App/RootView.swift b/CursorMobile/App/RootView.swift index aff546a..370e81e 100644 --- a/CursorMobile/App/RootView.swift +++ b/CursorMobile/App/RootView.swift @@ -82,8 +82,10 @@ struct RootView: View { private struct WorkflowModeChooserSheet: View { @Environment(\.dismiss) private var dismiss + @Environment(AppState.self) private var appState let selectedMode: AgentRunMode let choose: (AgentRunMode) -> Void + @State private var cursorSDKOnboardingSheet: CursorSDKOnboardingSheet? var body: some View { NavigationStack { @@ -91,7 +93,7 @@ private struct WorkflowModeChooserSheet: View { Section { WorkflowModeButton( mode: .cloudAgent, - isSelected: selectedMode == .cloudAgent, + isSelected: effectiveSelectedMode == .cloudAgent, symbolName: "icloud", detail: "Start a cloud run, monitor progress, preview artifacts, and follow up after completion.", choose: select @@ -99,21 +101,48 @@ private struct WorkflowModeChooserSheet: View { WorkflowModeButton( mode: .sdkBridge, - isSelected: selectedMode == .sdkBridge, + isSelected: effectiveSelectedMode == .sdkBridge, symbolName: "point.3.connected.trianglepath.dotted", - detail: "Use the SDK bridge for a richer conversation with models, MCP profiles, files, images, planning, and execution.", + detail: sdkAgentDetail, choose: select ) } footer: { - Text("You can change this later in Settings or switch modes in New Chat.") + Text("Cloud Agent is ready now. Cursor SDK needs Runline Bridge on your Mac, and you can set it up later from Settings.") } } - .navigationTitle("Choose Workflow") + .navigationTitle("Choose Runtime") .navigationBarTitleDisplayMode(.inline) } + .sheet(item: $cursorSDKOnboardingSheet) { _ in + CursorSDKOnboardingView( + onUseCloud: { + choose(.cloudAgent) + dismiss() + }, + onOpenSettings: { + choose(.cloudAgent) + appState.selectedTab = .settings + dismiss() + } + ) + } + } + + private var sdkAgentDetail: String { + appState.isSDKBridgeReadyForLaunch + ? "Use Runline Bridge for Cursor SDK chat, MCP profiles, files, images, planning, and execution." + : "Pair a Mac with Runline Bridge to unlock Cursor SDK sessions, MCP profiles, and git-aware remote work." + } + + private var effectiveSelectedMode: AgentRunMode { + selectedMode == .sdkBridge && !appState.isSDKBridgeReadyForLaunch ? .cloudAgent : selectedMode } private func select(_ mode: AgentRunMode) { + if mode == .sdkBridge, !appState.isSDKBridgeReadyForLaunch { + cursorSDKOnboardingSheet = .setup + return + } choose(mode) dismiss() } @@ -122,6 +151,7 @@ private struct WorkflowModeChooserSheet: View { private struct WorkflowModeButton: View { let mode: AgentRunMode let isSelected: Bool + var isEnabled = true let symbolName: String let detail: String let choose: (AgentRunMode) -> Void @@ -155,8 +185,10 @@ private struct WorkflowModeButton: View { } } .padding(.vertical, 4) + .opacity(isEnabled ? 1 : 0.55) } .buttonStyle(.plain) + .disabled(!isEnabled) } } @@ -380,7 +412,7 @@ private struct SettingsColumnSummary: View { Label("Account", systemImage: "person.crop.circle") Label("Appearance", systemImage: "circle.lefthalf.filled") Label("Notifications", systemImage: "bell") - Label("SDK Agent Bridge", systemImage: "point.3.connected.trianglepath.dotted") + Label("Cursor SDK", systemImage: "point.3.connected.trianglepath.dotted") } Section { diff --git a/CursorMobile/Core/DomainModels.swift b/CursorMobile/Core/DomainModels.swift index e0d25e2..96b66c0 100644 --- a/CursorMobile/Core/DomainModels.swift +++ b/CursorMobile/Core/DomainModels.swift @@ -197,7 +197,7 @@ enum AgentRunMode: String, CaseIterable, Identifiable, Hashable, Codable { case .cloudAgent: "Cloud Agent" case .sdkBridge: - "SDK Agent" + "Cursor SDK" } } @@ -206,7 +206,7 @@ enum AgentRunMode: String, CaseIterable, Identifiable, Hashable, Codable { case .cloudAgent: "Direct Cursor Cloud Agents API from iOS." case .sdkBridge: - "Multi-turn Cursor SDK session with Cloud Agent runtime." + "Cursor SDK session through Runline Bridge." } } } diff --git a/CursorMobile/Features/ChatDetailView.swift b/CursorMobile/Features/ChatDetailView.swift index c25fd55..ba8b678 100644 --- a/CursorMobile/Features/ChatDetailView.swift +++ b/CursorMobile/Features/ChatDetailView.swift @@ -33,7 +33,7 @@ struct ChatDetailView: View { List { Section { - LabeledContent("Mode", value: appState.isSDKBridgeAgent(currentAgent) ? "SDK Agent" : "Cloud Agent") + LabeledContent("Mode", value: appState.isSDKBridgeAgent(currentAgent) ? "Cursor SDK" : "Cloud Agent") LabeledContent("Repository", value: currentAgent.repository.displayName) LabeledContent("Branch", value: currentAgent.branchName) LabeledContent("Model", value: currentAgent.modelID) diff --git a/CursorMobile/Features/CursorSDKOnboardingView.swift b/CursorMobile/Features/CursorSDKOnboardingView.swift new file mode 100644 index 0000000..55f9d74 --- /dev/null +++ b/CursorMobile/Features/CursorSDKOnboardingView.swift @@ -0,0 +1,324 @@ +import SwiftUI +import UIKit + +enum CursorSDKOnboardingSheet: String, Identifiable { + case setup + + var id: String { rawValue } +} + +struct RunlineBridgeBenefit: Identifiable, Equatable { + var id: String { title } + var symbolName: String + var title: String + var detail: String + + static let all: [RunlineBridgeBenefit] = [ + RunlineBridgeBenefit( + symbolName: "macbook.and.iphone", + title: "Mac runtime", + detail: "Your Mac runs the Cursor SDK while iPhone stays the native remote." + ), + RunlineBridgeBenefit( + symbolName: "message.badge.waveform", + title: "Session chat", + detail: "Plan, continue, and execute against the same SDK session." + ), + RunlineBridgeBenefit( + symbolName: "point.3.connected.trianglepath.dotted", + title: "MCP profiles", + detail: "Expose bridge-side tools and subagents without storing them on device." + ), + RunlineBridgeBenefit( + symbolName: "key", + title: "Per-request keys", + detail: "Runline sends your Cursor key only as a bearer token for each request." + ), + ] +} + +enum RunlineBridgeOnboardingStep: String, CaseIterable, Identifiable, Equatable { + case overview + case bridge + case start + case connect + + var id: String { rawValue } + + var eyebrow: String { + switch self { + case .overview: + "Cursor SDK" + case .bridge: + "Step 1" + case .start: + "Step 2" + case .connect: + "Step 3" + } + } + + var title: String { + switch self { + case .overview: + "Use Cursor SDK from your iPhone" + case .bridge: + "Prepare the bridge" + case .start: + "Start the bridge" + case .connect: + "Connect Runline" + } + } + + var subtitle: String { + switch self { + case .overview: + "Cloud Agent remains the default. Cursor SDK is an optional mode for local SDK sessions, MCP profiles, files, images, planning, and execution." + case .bridge: + "Install Runline Bridge from npm. It keeps Cursor SDK execution on your Mac." + case .start: + "Start the bridge on your Mac. For iPhone testing, use your Mac LAN address instead of localhost." + case .connect: + "Enable Cursor SDK in Settings, enter the bridge URL, start pairing, then type the code printed in your Mac terminal." + } + } + + var symbolName: String { + switch self { + case .overview: + "terminal" + case .bridge: + "shippingbox" + case .start: + "play.circle" + case .connect: + "qrcode.viewfinder" + } + } + + var command: String? { + switch self { + case .overview, .connect: + nil + case .bridge: + "npm install -g runline-bridge@beta" + case .start: + "CURSOR_API_KEY=your-cursor-key runline-bridge up" + } + } + + var footnote: String? { + switch self { + case .overview: + nil + case .bridge: + "Only install this if you want Cursor SDK mode. Cloud Agent mode works without the bridge." + case .start: + "Simulator can use http://localhost:8787. A physical iPhone needs a reachable Mac LAN URL such as http://192.168.1.10:8787." + case .connect: + "Cloud Agent does not require this setup and remains available even when Cursor SDK is unavailable." + } + } +} + +struct CursorSDKOnboardingView: View { + @Environment(\.dismiss) private var dismiss + var onUseCloud: (() -> Void)? + var onOpenSettings: (() -> Void)? + @State private var selectedStep: RunlineBridgeOnboardingStep = .overview + @State private var copiedStepID: RunlineBridgeOnboardingStep.ID? + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + TabView(selection: $selectedStep) { + ForEach(RunlineBridgeOnboardingStep.allCases) { step in + CursorSDKOnboardingPage( + step: step, + copiedStepID: $copiedStepID + ) + .tag(step) + } + } + .tabViewStyle(.page(indexDisplayMode: .always)) + + VStack(spacing: 10) { + Button(primaryButtonTitle) { + handlePrimaryAction() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .frame(maxWidth: .infinity) + + Button("Use Cloud Agent for Now") { + onUseCloud?() + dismiss() + } + .buttonStyle(.borderless) + } + .padding(.horizontal) + .padding(.vertical, 14) + .background(.bar) + } + .navigationTitle("Cursor SDK") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + dismiss() + } + } + } + } + } + + private var primaryButtonTitle: String { + selectedStep == .connect ? "Open Bridge Settings" : "Continue" + } + + private func handlePrimaryAction() { + guard selectedStep == .connect else { + advance() + return + } + onOpenSettings?() + dismiss() + } + + private func advance() { + guard let currentIndex = RunlineBridgeOnboardingStep.allCases.firstIndex(of: selectedStep) else { + selectedStep = .connect + return + } + let nextIndex = RunlineBridgeOnboardingStep.allCases.index(after: currentIndex) + if RunlineBridgeOnboardingStep.allCases.indices.contains(nextIndex) { + withAnimation(.snappy(duration: 0.2)) { + selectedStep = RunlineBridgeOnboardingStep.allCases[nextIndex] + } + } else { + selectedStep = .connect + } + } +} + +private struct CursorSDKOnboardingPage: View { + var step: RunlineBridgeOnboardingStep + @Binding var copiedStepID: RunlineBridgeOnboardingStep.ID? + + var body: some View { + ScrollView { + VStack(spacing: 24) { + Image(systemName: step.symbolName) + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(.tint) + .frame(width: 88, height: 88) + .background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + + VStack(spacing: 8) { + Text(step.eyebrow.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.tint) + + Text(step.title) + .font(.title2.weight(.semibold)) + .multilineTextAlignment(.center) + + Text(step.subtitle) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + if step == .overview { + CursorSDKBenefitsList() + } + + if let command = step.command { + CommandCopyRow( + command: command, + isCopied: copiedStepID == step.id + ) { + UIPasteboard.general.string = command + withAnimation(.snappy(duration: 0.2)) { + copiedStepID = step.id + } + } + } + + if let footnote = step.footnote { + Text(footnote) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) + .padding(.horizontal, 24) + .padding(.top, 52) + .padding(.bottom, 96) + } + .background(Color(uiColor: .systemGroupedBackground)) + } +} + +private struct CursorSDKBenefitsList: View { + var body: some View { + VStack(alignment: .leading, spacing: 14) { + ForEach(RunlineBridgeBenefit.all) { benefit in + HStack(alignment: .top, spacing: 12) { + Image(systemName: benefit.symbolName) + .font(.headline) + .foregroundStyle(.tint) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 3) { + Text(benefit.title) + .font(.headline) + + Text(benefit.detail) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + .padding(16) + .background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +private struct CommandCopyRow: View { + var command: String + var isCopied: Bool + var copy: () -> Void + + var body: some View { + Button(action: copy) { + HStack(alignment: .center, spacing: 10) { + Text(command) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: isCopied ? "checkmark" : "doc.on.doc") + .font(.headline) + .foregroundStyle(.tint) + } + .padding(14) + .background(Color(uiColor: .tertiarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel(isCopied ? "Command copied" : "Copy command") + } +} + +#Preview { + CursorSDKOnboardingView() +} diff --git a/CursorMobile/Features/NativeRows.swift b/CursorMobile/Features/NativeRows.swift index 98b2fa3..2761f72 100644 --- a/CursorMobile/Features/NativeRows.swift +++ b/CursorMobile/Features/NativeRows.swift @@ -176,10 +176,12 @@ struct StreamEventListRow: View { .foregroundStyle(.tertiary) } - Text(item.message) - .font(isTechnical ? .caption.monospaced() : .callout) - .foregroundStyle(.secondary) - .textSelection(.enabled) + TimelineMessageText( + message: item.message, + isTechnical: isTechnical, + rendersMarkdown: rendersMarkdown, + foregroundColor: messageColor + ) } } .accessibilityElement(children: .combine) @@ -189,6 +191,24 @@ struct StreamEventListRow: View { item.kind == .toolCall || item.kind == .result || item.kind == .error } + private var rendersMarkdown: Bool { + switch item.kind { + case .assistant, .thinking, .task, .request: + true + default: + false + } + } + + private var messageColor: Color { + switch item.kind { + case .assistant, .user: + .primary + default: + .secondary + } + } + private var symbolName: String { switch item.kind { case .system: @@ -236,6 +256,218 @@ struct StreamEventListRow: View { } } +private struct TimelineMessageText: View { + var message: String + var isTechnical: Bool + var rendersMarkdown: Bool + var foregroundColor: Color + + var body: some View { + Group { + if isTechnical || !rendersMarkdown { + Text(message) + .font(isTechnical ? .caption.monospaced() : .callout) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(TimelineMarkdownParser.blocks(from: message).enumerated()), id: \.offset) { _, block in + blockView(block) + } + } + } + } + .foregroundStyle(foregroundColor) + .textSelection(.enabled) + } + + @ViewBuilder + private func blockView(_ block: TimelineMarkdownBlock) -> some View { + switch block { + case .heading(let level, let text): + InlineMarkdownText(text: text, font: headingFont(level)) + .foregroundStyle(.primary) + .padding(.top, level == 1 ? 4 : 2) + case .paragraph(let text): + InlineMarkdownText(text: text, font: .callout) + case .bullet(let text): + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("•") + .font(.callout) + .foregroundStyle(.tertiary) + InlineMarkdownText(text: text, font: .callout) + } + case .numbered(let marker, let text): + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(marker) + .font(.callout.monospacedDigit()) + .foregroundStyle(.tertiary) + InlineMarkdownText(text: text, font: .callout) + } + case .code(let text): + Text(text) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .padding(.vertical, 2) + } + } + + private func headingFont(_ level: Int) -> Font { + switch level { + case 1: + .headline + case 2: + .subheadline.weight(.semibold) + default: + .callout.weight(.semibold) + } + } +} + +private struct InlineMarkdownText: View { + var text: String + var font: Font + + var body: some View { + if let attributedText { + Text(attributedText) + .font(font) + } else { + Text(text) + .font(font) + } + } + + private var attributedText: AttributedString? { + try? AttributedString( + markdown: text, + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + } +} + +private enum TimelineMarkdownBlock: Hashable { + case heading(level: Int, text: String) + case paragraph(String) + case bullet(String) + case numbered(marker: String, text: String) + case code(String) +} + +private enum TimelineMarkdownParser { + static func blocks(from message: String) -> [TimelineMarkdownBlock] { + var blocks: [TimelineMarkdownBlock] = [] + var paragraphLines: [String] = [] + var codeLines: [String] = [] + var isInCodeBlock = false + + func flushParagraph() { + let paragraph = paragraphLines + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !paragraph.isEmpty { + blocks.append(.paragraph(paragraph)) + } + paragraphLines.removeAll() + } + + for line in normalizedLines(from: message) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("```") { + if isInCodeBlock { + blocks.append(.code(codeLines.joined(separator: "\n"))) + codeLines.removeAll() + isInCodeBlock = false + } else { + flushParagraph() + isInCodeBlock = true + } + continue + } + + if isInCodeBlock { + codeLines.append(line) + continue + } + + guard !trimmed.isEmpty else { + flushParagraph() + continue + } + + if let heading = heading(from: trimmed) { + flushParagraph() + blocks.append(heading) + continue + } + + if let bullet = bullet(from: trimmed) { + flushParagraph() + blocks.append(.bullet(bullet)) + continue + } + + if let numbered = numberedItem(from: trimmed) { + flushParagraph() + blocks.append(numbered) + continue + } + + paragraphLines.append(trimmed) + } + + if isInCodeBlock, !codeLines.isEmpty { + blocks.append(.code(codeLines.joined(separator: "\n"))) + } + flushParagraph() + + return blocks.isEmpty ? [.paragraph(message)] : blocks + } + + private static func normalizedLines(from message: String) -> [String] { + message + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .components(separatedBy: "\n") + } + + private static func heading(from line: String) -> TimelineMarkdownBlock? { + let marker = line.prefix { $0 == "#" } + guard !marker.isEmpty, marker.count <= 6 else { return nil } + + let textStart = line.index(line.startIndex, offsetBy: marker.count) + guard textStart < line.endIndex, line[textStart] == " " else { return nil } + + let text = String(line[line.index(after: textStart)...]) + .trimmingCharacters(in: .whitespaces) + guard !text.isEmpty else { return nil } + return .heading(level: marker.count, text: text) + } + + private static func bullet(from line: String) -> String? { + for marker in ["- ", "* "] where line.hasPrefix(marker) { + let text = String(line.dropFirst(marker.count)) + .trimmingCharacters(in: .whitespaces) + return text.isEmpty ? nil : text + } + return nil + } + + private static func numberedItem(from line: String) -> TimelineMarkdownBlock? { + guard let dotIndex = line.firstIndex(of: ".") else { return nil } + + let number = line[...none) ForEach(appState.sdkBridgeProfiles) { profile in @@ -100,7 +107,7 @@ struct NewChatForm: View { .foregroundStyle(.secondary) } } else if appState.sdkBridgeProfiles.isEmpty { - Text("No bridge profiles are published. Add MCP/subagent profiles on the SDK bridge to make them available here.") + Text("No bridge profiles are published. Add MCP or subagent profiles on Runline Bridge to make them available here.") .font(.footnote) .foregroundStyle(.secondary) } @@ -253,11 +260,16 @@ struct NewChatForm: View { } } .task { + appState.syncSDKBridgeConfiguration() + appState.ensureLaunchRunModeIsAvailable() seedSourceFields() if appState.launchDraft.runMode == .sdkBridge { await appState.reloadSDKBridgeProfiles() } } + .onChange(of: appState.sdkBridgeConnectionState) { _, _ in + appState.ensureLaunchRunModeIsAvailable() + } .onChange(of: appState.launchDraft.runMode) { _, mode in guard mode == .sdkBridge else { return } Task { @@ -273,6 +285,18 @@ struct NewChatForm: View { await loadPromptFiles(from: result) } } + .sheet(item: $cursorSDKOnboardingSheet) { _ in + CursorSDKOnboardingView( + onUseCloud: { + appState.launchDraft.runMode = .cloudAgent + }, + onOpenSettings: { + appState.launchDraft.runMode = .cloudAgent + appState.selectedTab = .settings + dismiss() + } + ) + } } @ViewBuilder @@ -326,10 +350,19 @@ struct NewChatForm: View { Binding { appState.launchDraft.runMode } set: { mode in + if mode == .sdkBridge, !appState.isSDKBridgeReadyForLaunch { + cursorSDKOnboardingSheet = .setup + appState.launchDraft.runMode = .cloudAgent + return + } appState.launchDraft.runMode = mode } } + private var availableRunModes: [AgentRunMode] { + AgentRunMode.allCases + } + private var modelSelectionBinding: Binding { Binding { NewChatModelPickerOptions.selection(from: appState.launchDraft.modelID) diff --git a/CursorMobile/Features/SettingsView.swift b/CursorMobile/Features/SettingsView.swift index e57cb00..70014fb 100644 --- a/CursorMobile/Features/SettingsView.swift +++ b/CursorMobile/Features/SettingsView.swift @@ -18,11 +18,13 @@ struct SettingsFormContent: View { @AppStorage(SDKBridgePreferences.isEnabledKey) private var isSDKBridgeEnabled = SDKBridgePreferences.defaultIsEnabled @AppStorage(SDKBridgePreferences.baseURLKey) private var sdkBridgeBaseURL = SDKBridgePreferences.defaultBaseURLString @State private var enterpriseAPIKey = "" - @State private var sdkBridgeHealth: SDKBridgeHealthCheckState = .idle + @State private var cursorSDKOnboardingSheet: CursorSDKOnboardingSheet? + @State private var pairingCode = "" @FocusState private var focusedField: Field? private enum Field { case bridgeURL + case pairingCode case enterpriseKey } @@ -64,13 +66,19 @@ struct SettingsFormContent: View { LabeledContent("Current default", value: RunlineWorkflowPreferences.runMode(from: defaultRunModeRawValue).detail) } header: { - Text("Default Workflow") + Text("Default Runtime") } footer: { - Text("New chats start in this mode. You can still switch between Cloud Agent and SDK Agent per chat.") + Text("Cloud Agent is the default runtime. Cursor SDK can be selected per chat once Runline Bridge is connected.") } Section { - Toggle("Enable SDK bridge", isOn: $isSDKBridgeEnabled) + Button { + cursorSDKOnboardingSheet = .setup + } label: { + Label("Cursor SDK Setup", systemImage: "point.3.connected.trianglepath.dotted") + } + + Toggle("Enable Runline Bridge", isOn: $isSDKBridgeEnabled) TextField("Bridge URL", text: $sdkBridgeBaseURL) .keyboardType(.URL) @@ -80,30 +88,92 @@ struct SettingsFormContent: View { .disabled(!isSDKBridgeEnabled) if isSDKBridgeEnabled { - LabeledContent("Status") { - Label(sdkBridgeHealth.title, systemImage: sdkBridgeHealth.systemImage) - .foregroundStyle(sdkBridgeHealth.tint) + HStack(spacing: 12) { + Text("Status") + Spacer() + Label(sdkBridgeConnectionTitle, systemImage: appState.sdkBridgeConnectionState.systemImage) + .foregroundStyle(appState.sdkBridgeConnectionState.tint) + .labelStyle(.titleAndIcon) + .multilineTextAlignment(.trailing) } LabeledContent("Profiles", value: "\(appState.sdkBridgeProfiles.count)") + LabeledContent("Pairing", value: appState.isSDKBridgePaired ? "Paired" : "Not Paired") + + if let detail = sdkBridgeConnectionDetail { + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let loopbackHelp = SDKBridgePreferences.deviceLoopbackHelp(for: SDKBridgePreferences.baseURL(from: sdkBridgeBaseURL)) { + Text(loopbackHelp) + .font(.footnote) + .foregroundStyle(.secondary) + } Button { Task { await checkSDKBridgeHealth() } } label: { - if sdkBridgeHealth == .checking { + if appState.sdkBridgeConnectionState == .checking { ProgressView() } else { - Text("Check Connection") + Text(appState.sdkBridgeConnectionState.isConnected ? "Recheck Connection" : "Check Connection") + } + } + .disabled(appState.sdkBridgeConnectionState == .checking) + + if appState.isSDKBridgePaired { + Button("Forget Pairing", role: .destructive) { + appState.forgetSDKBridgePairing() + } + } else { + Button { + Task { + await startBridgePairing() + } + } label: { + if appState.sdkBridgePairingState == .starting { + ProgressView() + } else { + Label("Start Pairing", systemImage: "link.badge.plus") + } + } + .disabled(appState.sdkBridgePairingState == .starting || appState.sdkBridgePairingState == .completing) + + if case .waiting = appState.sdkBridgePairingState { + TextField("Pairing Code", text: $pairingCode) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .focused($focusedField, equals: .pairingCode) + + Button { + Task { + await completeBridgePairing() + } + } label: { + if appState.sdkBridgePairingState == .completing { + ProgressView() + } else { + Text("Complete Pairing") + } + } + .disabled(pairingCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || appState.sdkBridgePairingState == .completing) + } + + if let pairingDetail { + Text(pairingDetail) + .font(.footnote) + .foregroundStyle(pairingDetailIsError ? .red : .secondary) } } - .disabled(sdkBridgeHealth == .checking) } } header: { - Text("Cursor SDK Agent Bridge") + Text("Cursor SDK") } footer: { - Text("Cloud Agent stays direct from iOS. SDK Agent uses this bridge for resumable SDK sessions, MCP profiles, and subagents.") + Text("Cloud Agent stays direct from iOS. Cursor SDK is available only after Runline Bridge connects.") } Section("Enterprise API") { @@ -165,27 +235,53 @@ struct SettingsFormContent: View { .scrollDismissesKeyboard(.interactively) .task { await appState.refreshNotificationStatus() + appState.syncSDKBridgeConfiguration() + ensureDefaultWorkflowSelectionIsAvailable() } .onChange(of: sdkBridgeBaseURL) { _, _ in - sdkBridgeHealth = .idle + appState.syncSDKBridgeConfiguration(resetConnection: true) + ensureDefaultWorkflowSelectionIsAvailable() } .onChange(of: isSDKBridgeEnabled) { _, _ in - sdkBridgeHealth = .idle - if isSDKBridgeEnabled { - Task { - await appState.reloadSDKBridgeProfiles() - } + appState.syncSDKBridgeConfiguration(resetConnection: true) + ensureDefaultWorkflowSelectionIsAvailable() + } + .onChange(of: appState.sdkBridgePairingState) { _, state in + if case .paired = state { + pairingCode = "" + focusedField = nil } } + .onChange(of: appState.sdkBridgeConnectionState) { _, _ in + ensureDefaultWorkflowSelectionIsAvailable() + } + .sheet(item: $cursorSDKOnboardingSheet) { _ in + CursorSDKOnboardingView( + onUseCloud: { + defaultRunModeRawValue = AgentRunMode.cloudAgent.rawValue + didChooseDefaultRunMode = true + appState.applyDefaultRunMode(.cloudAgent) + }, + onOpenSettings: {} + ) + } } private var defaultRunModeBinding: Binding { Binding { defaultRunModeRawValue } set: { rawValue in + let mode = RunlineWorkflowPreferences.runMode(from: rawValue) + if mode == .sdkBridge, !appState.isSDKBridgeReadyForLaunch { + cursorSDKOnboardingSheet = .setup + defaultRunModeRawValue = AgentRunMode.cloudAgent.rawValue + didChooseDefaultRunMode = true + appState.applyDefaultRunMode(.cloudAgent) + return + } defaultRunModeRawValue = rawValue didChooseDefaultRunMode = true - appState.applyDefaultRunMode(RunlineWorkflowPreferences.runMode(from: rawValue)) + appState.applyDefaultRunMode(mode) } } @@ -206,6 +302,67 @@ struct SettingsFormContent: View { } } + private var sdkBridgeConnectionTitle: String { + switch appState.sdkBridgeConnectionState { + case .disabled: + "Disabled" + case .unchecked: + "Not Checked" + case .checking: + "Checking" + case .connected: + "Connected" + case .failed: + "Unavailable" + } + } + + private var sdkBridgeConnectionDetail: String? { + switch appState.sdkBridgeConnectionState { + case .connected(let message), .failed(let message): + message + case .unchecked: + "Check the bridge before selecting Cursor SDK. The bridge must be reachable from this device." + case .disabled, .checking: + nil + } + } + + private var pairingDetail: String? { + switch appState.sdkBridgePairingState { + case .idle: + "Start pairing, then enter the six-digit code printed in the Runline Bridge terminal." + case .starting: + "Starting a pairing session..." + case .waiting(_, let expiresAt, let message): + [message, expiresAt.map { "Expires at \($0)." }] + .compactMap { $0 } + .joined(separator: " ") + case .completing: + "Completing pairing..." + case .paired(let name): + "Paired with \(name)." + case .failed(let message): + message + } + } + + private var pairingDetailIsError: Bool { + if case .failed = appState.sdkBridgePairingState { + return true + } + return false + } + + private func ensureDefaultWorkflowSelectionIsAvailable() { + guard RunlineWorkflowPreferences.runMode(from: defaultRunModeRawValue) == .sdkBridge, + !appState.isSDKBridgeReadyForLaunch else { + return + } + defaultRunModeRawValue = AgentRunMode.cloudAgent.rawValue + appState.applyDefaultRunMode(.cloudAgent) + } + private func saveEnterpriseKey() { let key = enterpriseAPIKey enterpriseAPIKey = "" @@ -217,23 +374,18 @@ struct SettingsFormContent: View { private func checkSDKBridgeHealth() async { focusedField = nil - guard let baseURL = SDKBridgePreferences.baseURL(from: sdkBridgeBaseURL) else { - sdkBridgeHealth = .failed("Invalid URL") - return - } + await appState.checkSDKBridgeConnection() + } - sdkBridgeHealth = .checking - do { - let health = try await SDKBridgeClient(baseURL: baseURL).health() - sdkBridgeHealth = health.ok - ? .healthy("\(health.service) - \(health.sdk)") - : .failed("Bridge responded unhealthy") - if health.ok { - await appState.reloadSDKBridgeProfiles() - } - } catch { - sdkBridgeHealth = .failed(error.localizedDescription) - } + private func startBridgePairing() async { + focusedField = nil + pairingCode = "" + await appState.startSDKBridgePairing() + } + + private func completeBridgePairing() async { + focusedField = nil + await appState.completeSDKBridgePairing(code: pairingCode) } private func notificationBinding(_ keyPath: WritableKeyPath) -> Binding { @@ -247,30 +399,14 @@ struct SettingsFormContent: View { } } -private enum SDKBridgeHealthCheckState: Equatable { - case idle - case checking - case healthy(String) - case failed(String) - - var title: String { - switch self { - case .idle: - "Not Checked" - case .checking: - "Checking" - case .healthy(let message), .failed(let message): - message - } - } - +private extension SDKBridgeConnectionState { var systemImage: String { switch self { - case .idle: + case .disabled, .unchecked: "circle" case .checking: "clock" - case .healthy: + case .connected: "checkmark.circle" case .failed: "exclamationmark.circle" @@ -279,11 +415,11 @@ private enum SDKBridgeHealthCheckState: Equatable { var tint: Color { switch self { - case .healthy: + case .connected: .green case .failed: .red - case .checking, .idle: + case .checking, .disabled, .unchecked: .secondary } } diff --git a/CursorMobile/Networking/SDKBridgeClient.swift b/CursorMobile/Networking/SDKBridgeClient.swift index 613ec88..ad029df 100644 --- a/CursorMobile/Networking/SDKBridgeClient.swift +++ b/CursorMobile/Networking/SDKBridgeClient.swift @@ -7,9 +7,9 @@ enum SDKBridgeError: LocalizedError, Equatable { var errorDescription: String? { switch self { case .invalidURL: - "The SDK bridge URL could not be created." + "The Runline Bridge URL could not be created." case .requestFailed(let statusCode, let message): - "SDK bridge returned \(statusCode): \(message)" + "Runline Bridge returned \(statusCode): \(message)" } } } @@ -18,6 +18,32 @@ struct SDKBridgeHealthResponse: Decodable, Equatable { var ok: Bool var service: String var sdk: String + var pairingRequired: Bool? + var paired: Bool? +} + +enum SDKBridgeConnectionState: Equatable { + case disabled + case unchecked + case checking + case connected(String) + case failed(String) + + var isConnected: Bool { + if case .connected = self { + return true + } + return false + } +} + +enum SDKBridgePairingState: Equatable { + case idle + case starting + case waiting(pairingID: String, expiresAt: String?, message: String?) + case completing + case paired(String) + case failed(String) } enum SDKBridgePreferences { @@ -48,6 +74,16 @@ enum SDKBridgePreferences { } return url } + + static func isLoopback(_ url: URL?) -> Bool { + guard let host = url?.host?.lowercased() else { return false } + return host == "localhost" || host == "127.0.0.1" || host == "::1" + } + + static func deviceLoopbackHelp(for url: URL?) -> String? { + guard isLoopback(url) else { return nil } + return "On a physical iPhone, localhost points to the phone. For device testing, run Runline Bridge on your Mac and use your Mac LAN URL, for example http://192.168.1.10:8787." + } } struct SDKBridgePromptImageDimensionRequest: Encodable, Equatable { @@ -86,6 +122,30 @@ struct SDKBridgeMCPProfilesResponse: Decodable, Equatable { var profiles: [SDKBridgeMCPProfile] } +struct SDKBridgePairingStartRequest: Encodable, Equatable { + var deviceName: String +} + +struct SDKBridgePairingStartResponse: Decodable, Equatable { + var pairingId: String? + var expiresAt: String? + var message: String? + var pairingRequired: Bool? +} + +struct SDKBridgePairingCompleteRequest: Encodable, Equatable { + var pairingId: String + var code: String + var deviceName: String +} + +struct SDKBridgePairingCompleteResponse: Decodable, Equatable { + var bridgeToken: String? + var bridgeName: String? + var service: String? + var sdk: String? +} + struct SDKBridgeRunStartResponse: Decodable, Equatable { var sessionId: String? var agentId: String @@ -121,13 +181,15 @@ final class SDKBridgeClient: @unchecked Sendable { private let baseURL: URL private let apiKey: String? + private let bridgeToken: String? private let session: URLSession private let encoder = JSONEncoder() private let decoder = JSONDecoder() - init(baseURL: URL, apiKey: String? = nil, session: URLSession = .shared) { + init(baseURL: URL, apiKey: String? = nil, bridgeToken: String? = nil, session: URLSession = .shared) { self.baseURL = baseURL self.apiKey = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfBlank + self.bridgeToken = bridgeToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfBlank self.session = session } @@ -143,6 +205,26 @@ final class SDKBridgeClient: @unchecked Sendable { try await request("/sdk/sessions", method: .post, body: body) } + func startPairing(deviceName: String) async throws -> SDKBridgePairingStartResponse { + try await request( + "/pair/start", + method: .post, + body: SDKBridgePairingStartRequest(deviceName: deviceName) + ) + } + + func completePairing(pairingID: String, code: String, deviceName: String) async throws -> SDKBridgePairingCompleteResponse { + try await request( + "/pair/complete", + method: .post, + body: SDKBridgePairingCompleteRequest( + pairingId: pairingID, + code: code, + deviceName: deviceName + ) + ) + } + func sendSessionMessage( sessionID: String, body: SDKBridgeSessionMessageRequest @@ -282,6 +364,9 @@ final class SDKBridgeClient: @unchecked Sendable { if let apiKey { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") } + if let bridgeToken { + request.setValue(bridgeToken, forHTTPHeaderField: "X-Runline-Bridge-Token") + } if let body { request.httpBody = try encoder.encode(AnyEncodable(body)) request.setValue("application/json", forHTTPHeaderField: "Content-Type") diff --git a/CursorMobile/Security/APIKeyStore.swift b/CursorMobile/Security/APIKeyStore.swift index a474543..510cef0 100644 --- a/CursorMobile/Security/APIKeyStore.swift +++ b/CursorMobile/Security/APIKeyStore.swift @@ -10,6 +10,7 @@ protocol APIKeyStore { enum APIKeyStoreAccount: String { case cursorCloudAgent = "cursor-cloud-agent" case cursorEnterpriseAdmin = "cursor-enterprise-admin" + case runlineBridgeToken = "runline-bridge-token" } enum APIKeyStoreError: LocalizedError { diff --git a/CursorMobileTests/CursorMobileTests.swift b/CursorMobileTests/CursorMobileTests.swift index f0dbcf7..4ddac99 100644 --- a/CursorMobileTests/CursorMobileTests.swift +++ b/CursorMobileTests/CursorMobileTests.swift @@ -59,6 +59,20 @@ final class CursorMobileTests: XCTestCase { XCTAssertEqual(RunlineWorkflowPreferences.runMode(from: "unknown"), .cloudAgent) } + func testRunModesExposeCloudDefaultAndCursorSDKCopy() { + XCTAssertEqual(AgentRunMode.cloudAgent.title, "Cloud Agent") + XCTAssertEqual(AgentRunMode.sdkBridge.title, "Cursor SDK") + XCTAssertTrue(AgentRunMode.sdkBridge.detail.contains("Runline Bridge")) + } + + func testCursorSDKOnboardingStepsExposeCurrentBridgeCommands() { + XCTAssertEqual(RunlineBridgeOnboardingStep.allCases.first, .overview) + XCTAssertEqual(RunlineBridgeOnboardingStep.allCases.last, .connect) + XCTAssertEqual(RunlineBridgeOnboardingStep.bridge.command, "npm install -g runline-bridge@beta") + XCTAssertEqual(RunlineBridgeOnboardingStep.start.command, "CURSOR_API_KEY=your-cursor-key runline-bridge up") + XCTAssertNil(RunlineBridgeOnboardingStep.connect.command) + } + func testSDKMessageIntentsExposeBridgeValuesAndSymbols() { XCTAssertEqual(SDKMessageIntent.continueConversation.bridgeValue, "continue") XCTAssertEqual(SDKMessageIntent.plan.bridgeValue, "plan") @@ -124,6 +138,64 @@ final class CursorMobileTests: XCTestCase { XCTAssertTrue(appState.canLaunchAgent) } + @MainActor + func testSDKLaunchRequiresPairingAndConnectedBridge() { + withSDKBridgeDefaults(enabled: true, baseURL: "http://localhost:8787") { + let unpairedAppState = AppState(provider: MockAgentProvider(), apiKeyStore: InMemoryAPIKeyStore(apiKey: "cursor-test-key")) + unpairedAppState.account = ProviderAccount( + apiKeyName: "Runline Test Key", + userEmail: "test@example.com", + createdAt: .now + ) + unpairedAppState.launchDraft.prompt.text = "Plan the settings cleanup" + unpairedAppState.launchDraft.runMode = .sdkBridge + unpairedAppState.sdkBridgeConnectionState = .connected("runline-bridge - @cursor/sdk") + + XCTAssertFalse(unpairedAppState.canLaunchAgent) + XCTAssertEqual(unpairedAppState.sdkBridgeLaunchIssue, "Pair Runline Bridge in Settings before using Cursor SDK.") + + let appState = AppState( + provider: MockAgentProvider(), + apiKeyStore: InMemoryAPIKeyStore(apiKey: "cursor-test-key"), + sdkBridgeTokenStore: InMemoryAPIKeyStore(apiKey: "bridge-token") + ) + appState.account = ProviderAccount( + apiKeyName: "Runline Test Key", + userEmail: "test@example.com", + createdAt: .now + ) + appState.launchDraft.prompt.text = "Plan the settings cleanup" + appState.launchDraft.runMode = .sdkBridge + appState.sdkBridgeConnectionState = .unchecked + + XCTAssertFalse(appState.canLaunchAgent) + XCTAssertEqual(appState.sdkBridgeLaunchIssue, "Check the Runline Bridge connection in Settings before using Cursor SDK.") + + appState.sdkBridgeConnectionState = .connected("runline-orchestrator - @cursor/sdk") + + XCTAssertTrue(appState.canLaunchAgent) + XCTAssertNil(appState.sdkBridgeLaunchIssue) + } + } + + @MainActor + func testUnavailableSDKModeFallsBackToCloudAgent() { + withSDKBridgeDefaults(enabled: true, baseURL: "http://localhost:8787") { + let appState = AppState(provider: MockAgentProvider(), apiKeyStore: InMemoryAPIKeyStore(apiKey: "cursor-test-key")) + appState.account = ProviderAccount( + apiKeyName: "Runline Test Key", + userEmail: "test@example.com", + createdAt: .now + ) + appState.launchDraft.runMode = .sdkBridge + appState.sdkBridgeConnectionState = .failed("Runline cannot reach the bridge.") + + appState.ensureLaunchRunModeIsAvailable() + + XCTAssertEqual(appState.launchDraft.runMode, .cloudAgent) + } + } + @MainActor func testWorkspaceRefreshCancellationDoesNotShowGlobalAlert() async { let appState = AppState(provider: CancellingAgentProvider(), apiKeyStore: InMemoryAPIKeyStore()) @@ -185,6 +257,31 @@ final class CursorMobileTests: XCTestCase { let remaining = try await provider.listAgents() XCTAssertFalse(remaining.contains { $0.id == agent.id }) } + + @MainActor + private func withSDKBridgeDefaults(enabled: Bool, baseURL: String, run test: () -> Void) { + let defaults = UserDefaults.standard + let previousEnabled = defaults.object(forKey: SDKBridgePreferences.isEnabledKey) + let previousBaseURL = defaults.object(forKey: SDKBridgePreferences.baseURLKey) + + defaults.set(enabled, forKey: SDKBridgePreferences.isEnabledKey) + defaults.set(baseURL, forKey: SDKBridgePreferences.baseURLKey) + defer { + if let previousEnabled { + defaults.set(previousEnabled, forKey: SDKBridgePreferences.isEnabledKey) + } else { + defaults.removeObject(forKey: SDKBridgePreferences.isEnabledKey) + } + + if let previousBaseURL { + defaults.set(previousBaseURL, forKey: SDKBridgePreferences.baseURLKey) + } else { + defaults.removeObject(forKey: SDKBridgePreferences.baseURLKey) + } + } + + test() + } } @MainActor diff --git a/CursorMobileTests/SDKBridgeClientTests.swift b/CursorMobileTests/SDKBridgeClientTests.swift index 8cb3a11..fb71584 100644 --- a/CursorMobileTests/SDKBridgeClientTests.swift +++ b/CursorMobileTests/SDKBridgeClientTests.swift @@ -17,8 +17,10 @@ final class SDKBridgeClientTests: XCTestCase { MockBridgeURLProtocol.handler = { request in try Self.jsonResponse(for: request, body: [ "ok": true, - "service": "runline-orchestrator", - "sdk": "@cursor/sdk" + "service": "runline-bridge", + "sdk": "@cursor/sdk", + "pairingRequired": true, + "paired": true ]) } @@ -27,12 +29,24 @@ final class SDKBridgeClientTests: XCTestCase { XCTAssertTrue(health.ok) XCTAssertEqual(health.sdk, "@cursor/sdk") + XCTAssertEqual(health.pairingRequired, true) + XCTAssertEqual(health.paired, true) let request = try XCTUnwrap(MockBridgeURLProtocol.capturedRequests.first) XCTAssertEqual(request.method, "GET") XCTAssertEqual(request.url?.path, "/health") XCTAssertEqual(request.header("Accept"), "application/json") } + func testLoopbackBridgeURLShowsDeviceGuidance() throws { + let localhost = try XCTUnwrap(URL(string: "http://localhost:8787")) + let lanURL = try XCTUnwrap(URL(string: "http://192.168.1.10:8787")) + + XCTAssertTrue(SDKBridgePreferences.isLoopback(localhost)) + XCTAssertNotNil(SDKBridgePreferences.deviceLoopbackHelp(for: localhost)) + XCTAssertFalse(SDKBridgePreferences.isLoopback(lanURL)) + XCTAssertNil(SDKBridgePreferences.deviceLoopbackHelp(for: lanURL)) + } + func testCreateCloudRunSendsBearerKeyAndLaunchBody() async throws { MockBridgeURLProtocol.handler = { request in try Self.jsonResponse(for: request, body: [ @@ -77,6 +91,62 @@ final class SDKBridgeClientTests: XCTestCase { XCTAssertEqual(body["skipReviewerRequest"] as? Bool, false) } + func testBridgeTokenHeaderIsSentOnProtectedRequests() async throws { + MockBridgeURLProtocol.handler = { request in + try Self.jsonResponse(for: request, body: [ + "profiles": [] + ]) + } + + let client = makeClient(apiKey: "cursor-test-key", bridgeToken: "bridge-token") + _ = try await client.listMCPProfiles() + + let request = try XCTUnwrap(MockBridgeURLProtocol.capturedRequests.first) + XCTAssertEqual(request.header("Authorization"), "Bearer cursor-test-key") + XCTAssertEqual(request.header("X-Runline-Bridge-Token"), "bridge-token") + } + + func testPairingStartAndCompleteUsePairingEndpoints() async throws { + MockBridgeURLProtocol.handler = { request in + switch request.url?.path { + case "/pair/start": + return try Self.jsonResponse(for: request, body: [ + "pairingId": "pair-123", + "expiresAt": "2026-05-06T20:00:00Z", + "message": "Check the terminal running Runline Bridge for the pairing code." + ]) + case "/pair/complete": + return try Self.jsonResponse(for: request, body: [ + "bridgeToken": "bridge-token", + "bridgeName": "Runline Bridge", + "service": "runline-bridge", + "sdk": "@cursor/sdk" + ]) + default: + throw URLError(.badURL) + } + } + + let client = makeClient() + let start = try await client.startPairing(deviceName: "Matthew's iPhone") + let complete = try await client.completePairing( + pairingID: "pair-123", + code: "123456", + deviceName: "Matthew's iPhone" + ) + + XCTAssertEqual(start.pairingId, "pair-123") + XCTAssertEqual(complete.bridgeToken, "bridge-token") + XCTAssertEqual(MockBridgeURLProtocol.capturedRequests.map { $0.url?.path }, ["/pair/start", "/pair/complete"]) + + let startBody = try XCTUnwrap(MockBridgeURLProtocol.capturedRequests.first?.jsonBody) + XCTAssertEqual(startBody["deviceName"] as? String, "Matthew's iPhone") + + let completeBody = try XCTUnwrap(MockBridgeURLProtocol.capturedRequests.last?.jsonBody) + XCTAssertEqual(completeBody["pairingId"] as? String, "pair-123") + XCTAssertEqual(completeBody["code"] as? String, "123456") + } + func testCreateSessionUsesSDKSessionEndpointAndProfile() async throws { MockBridgeURLProtocol.handler = { request in try Self.jsonResponse(for: request, body: [ @@ -268,10 +338,11 @@ final class SDKBridgeClientTests: XCTestCase { XCTAssertEqual(request.header("Accept"), "text/event-stream") } - private func makeClient(apiKey: String? = nil) -> SDKBridgeClient { + private func makeClient(apiKey: String? = nil, bridgeToken: String? = nil) -> SDKBridgeClient { SDKBridgeClient( baseURL: URL(string: "http://localhost:8787")!, apiKey: apiKey, + bridgeToken: bridgeToken, session: makeSession() ) } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6bc52fc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Matthew Parris + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 51a5af4..5b272fa 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,88 @@ # Runline for Cursor -Runline is a native iOS 26+ client for managing Cursor Cloud Agents from iPhone. +Runline is a native iOS 26+ client for managing Cursor Cloud Agents from iPhone and iPad. The app uses a proven Cloud Agents foundation with a chat-first, system-native iOS interface: stock navigation, lists, forms, sheets, toolbars, and settings surfaces. Runline is independent and is not affiliated with, endorsed by, or connected to Cursor or Anysphere. -## Current Foundation +## Status + +Runline is in public beta. Cloud Agent mode is the default path and works directly from iOS. Cursor SDK mode is optional and requires Runline Bridge on the user's Mac. + +## Features - iOS 26+ SwiftUI app target -- Chat-first Cloud Agents navigation +- Chat-first Cloud Agents navigation for iPhone and iPad - Cursor Cloud Agents v1 provider for account, repositories, models, agents, runs, streams, artifacts, archive, unarchive, and delete -- Optional `@cursor/sdk` bridge client for SDK Agent sessions, MCP profiles, subagents, and multi-turn follow-ups -- First-run and Settings workflow selection between Cloud Agent and SDK Agent defaults -- Native SDK Agent composer controls for intent, model, MCP profile, image context, and file context +- Optional `@cursor/sdk` bridge client for Cursor SDK sessions, MCP profiles, subagents, and multi-turn follow-ups +- Runline Bridge pairing with one-time terminal codes and Keychain-backed bridge tokens +- First-run and Settings runtime selection between Cloud Agent and Cursor SDK defaults +- Native Cursor SDK composer controls for intent, model, MCP profile, image context, and file context - Keychain-backed Cursor API key storage - Local cache for account, repositories, models, agents, runs, stream events, artifacts, notification preferences, and launch draft - Unit tests for Cursor v1 request contracts, SSE parsing, cache persistence, app routing, push payloads, chat event cleanup, file attachment loading, and SDK bridge request mapping + +## Cursor SDK Mode + +Cloud Agent mode works directly from iOS. Cursor SDK mode is optional and requires Runline Bridge on the user's Mac: + +```bash +npm install -g runline-bridge@beta +export CURSOR_API_KEY="replace-with-your-cursor-key" +runline-bridge up +``` + +Runline Bridge prints the iPhone-reachable URL and pairing instructions. The iOS app stores the bridge token in Keychain after pairing. + +## Development + +Install bridge dependencies: + +```bash +npm --prefix orchestrator install +``` + +Run bridge checks: + +```bash +npm --prefix orchestrator run typecheck +npm --prefix orchestrator run build +``` + +Run iOS tests: + +```bash +xcodebuild test \ + -project CursorMobile.xcodeproj \ + -scheme CursorMobile \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.4.1' +``` + +## Release Workflow + +TestFlight and App Store releases are maintainer-only and are not required for contributors. + +Local ASC automation expects a private `Runline` App Store Connect profile in the maintainer's keychain. Keep Apple API keys, signing files, archives, IPAs, and ASC run artifacts out of git. + +Run local preflight checks: + +```bash +asc workflow run preflight +``` + +Upload a TestFlight build with an explicit build number: + +```bash +asc workflow run testflight BUILD_NUMBER: +``` + +Use explicit build numbers so TestFlight stays aligned with the active Runline sequence. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). + +## License + +Runline is released under the [MIT License](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..cece597 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Security Policy + +Runline is a public beta project. Please report security issues privately before opening a public issue. + +## Supported Versions + +Security fixes target: + +- the latest commit on `main` +- the latest TestFlight beta build +- the latest published `runline-bridge` npm package + +Older TestFlight builds and old npm package versions may be unsupported during beta. + +## Reporting a Vulnerability + +Email `parrisdigital@gmail.com` with: + +- a clear description of the issue +- affected app, bridge, or repository version +- reproduction steps +- expected impact +- any logs or screenshots that do not contain secrets + +Do not include Cursor API keys, Apple credentials, npm tokens, GitHub tokens, private repository contents, or bridge pairing tokens in public issues. + +## Credential Handling + +- Cursor API keys are stored on iOS in Keychain. +- Runline Bridge pairing tokens are stored on iOS in Keychain. +- Runline Bridge accepts a Cursor API key per request or from `CURSOR_API_KEY` in the user's local environment. +- Runline Bridge must not store or log user Cursor API keys. +- The repository must not contain `.p8`, `.p12`, `.mobileprovision`, `.env`, `.npmrc`, private keys, or signing certificates. + +If a credential is ever committed, revoke and rotate it before opening the repository publicly. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..e600a90 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,22 @@ +# Support + +Runline is in public beta. + +## General Help + +Open a GitHub issue for: + +- reproducible app bugs +- Runline Bridge installation problems +- documentation issues +- focused feature requests + +Include the app build number, iOS version, device type, bridge version, and clear reproduction steps when relevant. + +## Security Issues + +Do not open public issues for security reports. Use [SECURITY.md](SECURITY.md). + +## Cursor or Apple Account Issues + +Runline is independent and cannot provide support for Cursor accounts, Apple IDs, App Store Connect, or TestFlight account access. diff --git a/Tools/set_build_number.rb b/Tools/set_build_number.rb new file mode 100644 index 0000000..4e28b6e --- /dev/null +++ b/Tools/set_build_number.rb @@ -0,0 +1,24 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +build_number = ARGV.fetch(0, "").strip + +unless build_number.match?(/\A[1-9][0-9]*\z/) + warn "Usage: ruby Tools/set_build_number.rb BUILD_NUMBER" + warn "BUILD_NUMBER must be a positive integer." + exit 2 +end + +project_path = File.expand_path("../project.yml", __dir__) +contents = File.read(project_path) + +pattern = /^(\s*CURRENT_PROJECT_VERSION:\s*)"?[0-9]+"?$/ + +unless contents.match?(pattern) + warn "Could not find CURRENT_PROJECT_VERSION in #{project_path}." + exit 1 +end + +updated = contents.sub(pattern, "\\1\"#{build_number}\"") +File.write(project_path, updated) +warn "Set CURRENT_PROJECT_VERSION to #{build_number} in project.yml." diff --git a/orchestrator/README.md b/orchestrator/README.md index b1a3015..7303062 100644 --- a/orchestrator/README.md +++ b/orchestrator/README.md @@ -1,24 +1,55 @@ -# Runline Orchestrator +# Runline Bridge -This is an optional TypeScript backend for Cursor SDK-only workflows. The iOS app does not depend on this service for the core Cloud Agent path; Runline keeps using Cursor's v1 REST API directly for account, repository, model, agent, run, stream, lifecycle, and artifact basics. +Runline Bridge is the optional Mac-side CLI for Cursor SDK workflows. The iOS app does not depend on this service for the core Cloud Agent path; Runline keeps using Cursor's v1 REST API directly for account, repository, model, agent, run, stream, lifecycle, and artifact basics. Use this service only for work that benefits from `@cursor/sdk`: -- Resumable SDK Agent sessions with multi-turn follow-up messages. +- Resumable Cursor SDK sessions with multi-turn follow-up messages. - SDK-normalized event streams and conversation state. - Launch payloads that include MCP server profiles or subagents. - Service-account workflows for teams. - Future automation or APNs backend jobs that should not run in the iOS app. -## Run Locally +## Install + +```bash +npm install -g runline-bridge@beta +export CURSOR_API_KEY="replace-with-your-cursor-key" +runline-bridge up +``` + +Only install Runline Bridge if you want Cursor SDK mode. Cloud Agent mode in the iOS app works without this package. + +## Run From This Repo ```bash cd orchestrator npm install -CURSOR_API_KEY=your-cursor-key npm run dev +npm run build +export CURSOR_API_KEY="replace-with-your-cursor-key" +npm run bridge +``` + +For Simulator testing, `http://localhost:8787` is usually enough. For a physical iPhone or TestFlight build on the same Wi-Fi network, use your Mac's LAN address instead: + +```bash +ipconfig getifaddr en0 ``` -The iOS app can also send a per-request `Authorization: Bearer ` header. Prefer that for user-owned keys; the bridge does not need to store keys server-side. +Then set the app's bridge URL to `http://:8787`. For broader TestFlight use, deploy the bridge behind HTTPS and use that hosted URL. + +The iOS app can also send the user's Cursor API key as a per-request bearer token. Prefer that for user-owned keys; the bridge does not need to store keys server-side. + +## Pairing + +Runline Bridge requires a local pairing token by default. In the iOS app, open Settings, enable Runline Bridge, enter the bridge URL, and tap **Start Pairing**. The bridge prints a six-digit code in the terminal. Enter that code in the app to store a bridge token in the iOS Keychain. + +For local development only, you can disable pairing: + +```bash +export CURSOR_API_KEY="replace-with-your-cursor-key" +RUNLINE_BRIDGE_DISABLE_PAIRING=true runline-bridge up +``` ## MCP Profiles @@ -48,6 +79,8 @@ export RUNLINE_SDK_MCP_PROFILES='[ ## Endpoints - `GET /health` +- `POST /pair/start` +- `POST /pair/complete` - `GET /sdk/mcp-profiles` - `POST /sdk/sessions` - `POST /sdk/sessions/:sessionId/messages` @@ -57,14 +90,14 @@ export RUNLINE_SDK_MCP_PROFILES='[ - `GET /agents/:agentId/runs/:runId/state` - `GET /agents/:agentId/runs/:runId/events` -Requests may pass a Cursor API key with `Authorization: Bearer `. If omitted, the service uses `CURSOR_API_KEY`. Do not put user keys in logs or long-lived storage. +Requests may pass a Cursor API key with bearer authentication. If omitted, the service uses `CURSOR_API_KEY`. Do not put user keys in logs or long-lived storage. ## SDK Session Example ```bash curl http://localhost:8787/sdk/sessions \ -H 'Content-Type: application/json' \ - -H 'Authorization: Bearer YOUR_CURSOR_API_KEY' \ + --oauth2-bearer "$CURSOR_API_KEY" \ -d '{ "prompt": "Create an implementation plan for the failing tests.", "intent": "plan", @@ -83,7 +116,7 @@ The response includes `sessionId`, `agentId`, `runId`, `sessionEventsURL`, and ` ```bash curl http://localhost:8787/sdk/sessions/bc-example/messages \ -H 'Content-Type: application/json' \ - -H 'Authorization: Bearer YOUR_CURSOR_API_KEY' \ + --oauth2-bearer "$CURSOR_API_KEY" \ -d '{ "prompt": "Execute the approved plan.", "intent": "execute", diff --git a/orchestrator/bin/runline-bridge.js b/orchestrator/bin/runline-bridge.js new file mode 100755 index 0000000..df02d71 --- /dev/null +++ b/orchestrator/bin/runline-bridge.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +import { networkInterfaces } from "node:os"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const command = process.argv[2] ?? "up"; +const args = process.argv.slice(3); + +if (command === "--help" || command === "-h" || command === "help") { + printHelp(); + process.exit(0); +} + +if (command === "--version" || command === "-v" || command === "version") { + const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + console.log(packageJson.version); + process.exit(0); +} + +if (command !== "up") { + console.error(`Unknown command: ${command}`); + printHelp(); + process.exit(2); +} + +const port = valueAfter("--port") ?? process.env.PORT ?? "8787"; +process.env.PORT = port; + +printStartup(port); +await import("../dist/server.js"); + +function valueAfter(name) { + const index = args.indexOf(name); + if (index === -1) { + return undefined; + } + return args[index + 1]; +} + +function printHelp() { + console.log(` +Runline Bridge + +Usage: + runline-bridge up [--port 8787] + +Environment: + CURSOR_API_KEY Cursor API key used when the iOS app does not send one per request. + RUNLINE_SDK_MCP_PROFILES JSON array of MCP/subagent profiles exposed to Runline. + RUNLINE_BRIDGE_DISABLE_PAIRING Set to true for local development only. +`); +} + +function printStartup(port) { + const lanURL = firstLANAddress() + ? `http://${firstLANAddress()}:${port}` + : undefined; + + console.log("Runline Bridge"); + console.log(""); + console.log(`Local URL: http://localhost:${port}`); + if (lanURL) { + console.log(`iPhone URL: ${lanURL}`); + } + console.log(""); + console.log("In Runline on iPhone:"); + console.log(" Settings -> Cursor SDK -> Enable Runline Bridge"); + console.log(" Enter the iPhone URL, tap Start Pairing, then enter the terminal code."); + console.log(""); +} + +function firstLANAddress() { + for (const addresses of Object.values(networkInterfaces())) { + for (const address of addresses ?? []) { + if (address.family === "IPv4" && !address.internal) { + return address.address; + } + } + } + return undefined; +} diff --git a/orchestrator/package-lock.json b/orchestrator/npm-shrinkwrap.json similarity index 54% rename from orchestrator/package-lock.json rename to orchestrator/npm-shrinkwrap.json index 9bf711f..a73de4c 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/npm-shrinkwrap.json @@ -1,14 +1,18 @@ { - "name": "runline-orchestrator", - "version": "0.1.0", + "name": "runline-bridge", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "runline-orchestrator", - "version": "0.1.0", + "name": "runline-bridge", + "version": "0.1.1", + "license": "MIT", "dependencies": { - "@cursor/sdk": "^1.0.11" + "@cursor/sdk": "^1.0.12" + }, + "bin": { + "runline-bridge": "bin/runline-bridge.js" }, "devDependencies": { "@types/node": "^24.0.0", @@ -16,7 +20,7 @@ "typescript": "^5.8.0" }, "engines": { - "node": ">=22" + "node": ">=20" } }, "node_modules/@bufbuild/protobuf": { @@ -51,9 +55,9 @@ } }, "node_modules/@cursor/sdk": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@cursor/sdk/-/sdk-1.0.11.tgz", - "integrity": "sha512-DkTwOAuao9wIeUioaM0aQi6hkWLC8oLAnqlR4HR9hn5xytd9A4cEB2fZpSHd8pJ2YRN0VJVkxnggxLRNT7ghuQ==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk/-/sdk-1.0.12.tgz", + "integrity": "sha512-jGx0wFY1N9uIdIKr303CfM6m/dLXmRCUnU/0yNP/oiOpkBXqgqaThGbgYbcOeVrYonMZc/DZJ9EydXOEPJLcbg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@bufbuild/protobuf": "1.10.0", @@ -67,17 +71,17 @@ "node": ">=18" }, "optionalDependencies": { - "@cursor/sdk-darwin-arm64": "1.0.11", - "@cursor/sdk-darwin-x64": "1.0.11", - "@cursor/sdk-linux-arm64": "1.0.11", - "@cursor/sdk-linux-x64": "1.0.11", - "@cursor/sdk-win32-x64": "1.0.11" + "@cursor/sdk-darwin-arm64": "1.0.12", + "@cursor/sdk-darwin-x64": "1.0.12", + "@cursor/sdk-linux-arm64": "1.0.12", + "@cursor/sdk-linux-x64": "1.0.12", + "@cursor/sdk-win32-x64": "1.0.12" } }, "node_modules/@cursor/sdk-darwin-arm64": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-arm64/-/sdk-darwin-arm64-1.0.11.tgz", - "integrity": "sha512-jbbdt4k1Wjjzsye9kfJtn7nPHd1QgBtOA1tbmLVbXIVb5UeAu+q7uT/8aggm8qN8R151m/GNW2ntK29+Q8y/XQ==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-arm64/-/sdk-darwin-arm64-1.0.12.tgz", + "integrity": "sha512-AOFx+aX+4SntAeC66YncHACXk5duxp+HzDrxxF4Tl93N6nLjHaHEKSAXbt87ivL34MCHop4v/3c70QzBhamB2g==", "cpu": [ "arm64" ], @@ -88,9 +92,9 @@ ] }, "node_modules/@cursor/sdk-darwin-x64": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-x64/-/sdk-darwin-x64-1.0.11.tgz", - "integrity": "sha512-2352S+tGbaDgj2qb3oNN2FUG5250cn3cD+aKluETFd7jI7Pm3ctwInFN+/NWWnzwftibjKnwcc8ghm9q4xYfWg==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-x64/-/sdk-darwin-x64-1.0.12.tgz", + "integrity": "sha512-/ZDAYFUrnPd8hAGRky9ZGcROqZSZ2b5W+aEjTdINzLhJ8x5ZNXtjaz0ZYSHabOn2BeErjXgTcq+4bX2/To4C1A==", "cpu": [ "x64" ], @@ -101,9 +105,9 @@ ] }, "node_modules/@cursor/sdk-linux-arm64": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-arm64/-/sdk-linux-arm64-1.0.11.tgz", - "integrity": "sha512-SGnwU1caprU6L7XCMUH48pyGdrZz1YQhPNUzrUyixHpdfM951KJmAQyuW9Hj2J4J3C1PG4XwIYRHsGN8/EOF2g==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-arm64/-/sdk-linux-arm64-1.0.12.tgz", + "integrity": "sha512-kAxNqiB3dPtlW9fVjjIZEdbIGEGLA9moOM3zYwsXh8J1Qw942nJYMGDGR4o8x0zglwZ24a1JpovvZamrCaC3Yw==", "cpu": [ "arm64" ], @@ -114,9 +118,9 @@ ] }, "node_modules/@cursor/sdk-linux-x64": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-x64/-/sdk-linux-x64-1.0.11.tgz", - "integrity": "sha512-zzVwEMc9ykyyFgxaXwfiB0Nuqnp0PkKqiWSt6Iubmi7ADY87dtVS67qwtmVQ+FJVA7iXV+c7LY2sQ2qfQ4aP2w==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-x64/-/sdk-linux-x64-1.0.12.tgz", + "integrity": "sha512-RmBiBCPKMZC5McDerGk2Rk4P47xz2A+uzRoRgH6sMoOjklc33ry11iAZC0D5F5xH85chgY878086A/Q8+XrAuA==", "cpu": [ "x64" ], @@ -127,9 +131,9 @@ ] }, "node_modules/@cursor/sdk-win32-x64": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@cursor/sdk-win32-x64/-/sdk-win32-x64-1.0.11.tgz", - "integrity": "sha512-iWvGDFhpW+C6/zah7feY3oURozJxQ78qjld+9ejOaRuuC6p33Q6D/3l6Ihst18lEH9WSjEJClydDFUbm7aPf5A==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-win32-x64/-/sdk-win32-x64-1.0.12.tgz", + "integrity": "sha512-uH4shdHrKOdtNLapy1uuScJ9lL2Pc8zc9I9ZKC6b6bx+0UX6xLAqjPP7dqVPfO6D9u61yLq1Hs86XOLs5ZVkPA==", "cpu": [ "x64" ], @@ -581,46 +585,16 @@ "node": ">=18" } }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "license": "MIT", - "optional": true - }, - "node_modules/@npmcli/fs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", - "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "license": "ISC", - "optional": true, - "dependencies": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "node_modules/@npmcli/move-file": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", - "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "license": "MIT", - "optional": true, "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" + "minipass": "^7.0.4" }, "engines": { - "node": ">=10" + "node": ">=18.0.0" } }, "node_modules/@statsig/client-core": { @@ -638,16 +612,6 @@ "@statsig/client-core": "3.31.0" } }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/@types/node": { "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", @@ -659,91 +623,15 @@ } }, "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC", - "optional": true - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "license": "MIT", - "optional": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC", - "optional": true - }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "deprecated": "This package is no longer supported.", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "license": "ISC", "optional": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT", - "optional": true - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -784,17 +672,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "license": "MIT", - "optional": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -819,95 +696,13 @@ "ieee754": "^1.1.13" } }, - "node_modules/cacache": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", - "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", - "optional": true, - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT", - "optional": true - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC", - "optional": true - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.1.3" - }, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=18" } }, "node_modules/decompress-response": { @@ -934,13 +729,6 @@ "node": ">=4.0.0" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT", - "optional": true - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -950,23 +738,6 @@ "node": ">=8" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT", - "optional": true - }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -986,13 +757,6 @@ "node": ">=6" } }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "license": "MIT", - "optional": true - }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -1044,6 +808,31 @@ "node": ">=6" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1056,25 +845,6 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC", - "optional": true - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1090,27 +860,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/get-tsconfig": { "version": "4.14.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", @@ -1130,28 +879,6 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "optional": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1159,72 +886,6 @@ "license": "ISC", "optional": true }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC", - "optional": true - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "license": "BSD-2-Clause", - "optional": true - }, - "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1245,45 +906,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "license": "ISC", - "optional": true - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "optional": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1296,79 +918,14 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.1.tgz", - "integrity": "sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "license": "MIT", - "optional": true - }, "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "optional": true - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-fetch-happen": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", - "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "license": "ISC", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", "optional": true, - "dependencies": { - "agentkeepalive": "^4.1.3", - "cacache": "^15.2.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^6.0.0", - "minipass": "^3.1.3", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^1.3.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.2", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.0.0", - "ssri": "^8.0.0" - }, "engines": { - "node": ">= 10" + "node": ">=20" } }, "node_modules/mimic-response": { @@ -1383,19 +940,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "optional": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1406,110 +950,24 @@ } }, "node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-fetch": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", - "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^3.1.0", - "minipass-sized": "^1.0.3", - "minizlib": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "optionalDependencies": { - "encoding": "^0.1.12" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" + "minipass": "^7.1.2" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" + "node": ">= 18" } }, "node_modules/mkdirp-classic": { @@ -1518,29 +976,12 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, - "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", - "optional": true - }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-abi": { "version": "3.89.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", @@ -1554,67 +995,53 @@ } }, "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } }, "node_modules/node-gyp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", - "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", "license": "MIT", "optional": true, "dependencies": { "env-paths": "^2.2.0", - "glob": "^7.1.4", + "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^9.1.0", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": ">= 10.12.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "license": "ISC", "optional": true, "dependencies": { - "abbrev": "1" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": ">=6" - } - }, - "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/once": { @@ -1626,30 +1053,17 @@ "wrappy": "1" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "optional": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/prebuild-install": { @@ -1679,25 +1093,14 @@ "node": ">=10" } }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "license": "ISC", - "optional": true - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "license": "MIT", "optional": true, - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, "engines": { - "node": ">=10" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/pump": { @@ -1749,33 +1152,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "optional": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1796,13 +1172,6 @@ ], "license": "MIT" }, - "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", - "optional": true - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -1815,20 +1184,6 @@ "node": ">=10" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC", - "optional": true - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC", - "optional": true - }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -1874,64 +1229,26 @@ "simple-concat": "^1.0.0" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", - "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", - "license": "MIT", - "optional": true, - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", - "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/sqlite3": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", - "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-6.0.1.tgz", + "integrity": "sha512-X0czUUMG2tmSqJpEQa3tCuZSHKIx8PwM53vLZzKp/o6Rpy25fiVfjdbnZ988M8+O3ZWR1ih0K255VumCb3MAnQ==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "bindings": "^1.5.0", - "node-addon-api": "^7.0.0", - "prebuild-install": "^7.1.1", - "tar": "^6.1.11" + "node-addon-api": "^8.0.0", + "prebuild-install": "^7.1.3", + "tar": "^7.5.10" + }, + "engines": { + "node": ">=20.17.0" }, "optionalDependencies": { - "node-gyp": "8.x" + "node-gyp": "12.x" }, "peerDependencies": { - "node-gyp": "8.x" + "node-gyp": "12.x" }, "peerDependenciesMeta": { "node-gyp": { @@ -1939,19 +1256,6 @@ } } }, - "node_modules/ssri": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", - "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -1961,34 +1265,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "optional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -1999,21 +1275,19 @@ } }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", + "version": "7.5.14", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.14.tgz", + "integrity": "sha512-/7sHKgQO3JLP9ESlwTYUUftHUadOURUqq23xs1vjcnp8Vss6k0wCfzulyEtk5g91pjvnuriimGlyG7k6msrzRw==", + "license": "BlueOak-1.0.0", "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/tar-fs": { @@ -2050,13 +1324,21 @@ "node": ">=6" } }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "optional": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=8" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, "node_modules/tsx": { @@ -2106,15 +1388,12 @@ } }, "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, "engines": { - "node": ">=14.0" + "node": ">=18.17" } }, "node_modules/undici-types": { @@ -2124,26 +1403,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unique-filename": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", - "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "unique-slug": "^2.0.0" - } - }, - "node_modules/unique-slug": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", - "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "license": "ISC", - "optional": true, - "dependencies": { - "imurmurhash": "^0.1.4" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2151,29 +1410,19 @@ "license": "MIT" }, "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "license": "ISC", "optional": true, "dependencies": { - "isexe": "^2.0.0" + "isexe": "^4.0.0" }, "bin": { - "node-which": "bin/node-which" + "node-which": "bin/which.js" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "optional": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/wrappy": { @@ -2183,10 +1432,13 @@ "license": "ISC" }, "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } }, "node_modules/zod": { "version": "3.25.76", diff --git a/orchestrator/package.json b/orchestrator/package.json index f51bd59..4173947 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -1,14 +1,51 @@ { - "name": "runline-orchestrator", - "version": "0.1.0", - "private": true, + "name": "runline-bridge", + "version": "0.1.1", + "description": "Local Mac bridge for Runline Cursor SDK sessions.", + "license": "MIT", "type": "module", + "homepage": "https://github.com/parrisdigital/cursor_mobile#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/parrisdigital/cursor_mobile.git", + "directory": "orchestrator" + }, + "bugs": { + "url": "https://github.com/parrisdigital/cursor_mobile/issues" + }, + "keywords": [ + "cursor", + "cursor-sdk", + "ios", + "runline", + "bridge" + ], + "bin": { + "runline-bridge": "bin/runline-bridge.js" + }, + "files": [ + "bin", + "dist", + "README.md", + "npm-shrinkwrap.json" + ], "scripts": { + "build": "tsc", + "bridge": "node dist/server.js", "dev": "tsx src/server.ts", + "prepack": "npm run build", "typecheck": "tsc --noEmit" }, + "publishConfig": { + "tag": "beta" + }, "dependencies": { - "@cursor/sdk": "^1.0.11" + "@cursor/sdk": "^1.0.12" + }, + "overrides": { + "sqlite3": "^6.0.1", + "tar": "^7.5.14", + "undici": "^6.24.0" }, "devDependencies": { "@types/node": "^24.0.0", @@ -16,6 +53,6 @@ "typescript": "^5.8.0" }, "engines": { - "node": ">=22" + "node": ">=20" } } diff --git a/orchestrator/src/server.ts b/orchestrator/src/server.ts index a28f88e..147964d 100644 --- a/orchestrator/src/server.ts +++ b/orchestrator/src/server.ts @@ -1,7 +1,11 @@ +import { randomBytes, randomInt, randomUUID } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { Agent, type McpServerConfig } from "@cursor/sdk"; const port = Number(process.env.PORT ?? 8787); +const serviceName = "runline-bridge"; +const sdkName = "@cursor/sdk"; +const pairingTTLMs = Number(process.env.RUNLINE_BRIDGE_PAIRING_TTL_MS ?? 5 * 60 * 1000); type CloudRunRequest = { prompt?: string; @@ -43,6 +47,30 @@ type SDKMCPProfile = { agents?: unknown; }; +type PairingSession = { + id: string; + code: string; + deviceName?: string; + expiresAt: number; +}; + +type PairingStartRequest = { + deviceName?: string; +}; + +type PairingCompleteRequest = { + pairingId?: string; + code?: string; + deviceName?: string; +}; + +const pairingSessions = new Map(); +const issuedBridgeTokens = new Set( + emptyToUndefined(process.env.RUNLINE_BRIDGE_TOKEN) + ? [process.env.RUNLINE_BRIDGE_TOKEN!.trim()] + : [] +); + const server = createServer(async (request, response) => { try { const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`); @@ -50,34 +78,51 @@ const server = createServer(async (request, response) => { if (request.method === "GET" && url.pathname === "/health") { sendJSON(response, 200, { ok: true, - service: "runline-orchestrator", - sdk: "@cursor/sdk", + service: serviceName, + sdk: sdkName, + pairingRequired: isBridgeAuthRequired(), + paired: isAuthorizedBridgeRequest(request), }); return; } + if (request.method === "POST" && url.pathname === "/pair/start") { + await startPairing(request, response); + return; + } + + if (request.method === "POST" && url.pathname === "/pair/complete") { + await completePairing(request, response); + return; + } + if (request.method === "GET" && url.pathname === "/sdk/mcp-profiles") { + if (!requireBridgeAuth(request, response)) { return; } sendJSON(response, 200, { profiles: publicMCPProfiles() }); return; } if (request.method === "POST" && url.pathname === "/sdk/sessions") { + if (!requireBridgeAuth(request, response)) { return; } await createSDKSession(request, response); return; } const sessionParams = matchSessionRoute(url.pathname); if (sessionParams && request.method === "POST" && url.pathname.endsWith("/messages")) { + if (!requireBridgeAuth(request, response)) { return; } await sendSDKSessionMessage(request, response, sessionParams); return; } if (sessionParams && request.method === "GET" && url.pathname.endsWith("/state")) { + if (!requireBridgeAuth(request, response)) { return; } await getSDKSessionState(request, response, sessionParams, url); return; } if (sessionParams && request.method === "GET" && sessionParams.runId && url.pathname.endsWith("/events")) { + if (!requireBridgeAuth(request, response)) { return; } await streamRunEvents(request, response, { agentId: sessionParams.sessionId, runId: sessionParams.runId, @@ -86,17 +131,20 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && url.pathname === "/runs/cloud") { + if (!requireBridgeAuth(request, response)) { return; } await createSDKSession(request, response); return; } const runParams = matchRunRoute(url.pathname); if (request.method === "GET" && runParams && url.pathname.endsWith("/state")) { + if (!requireBridgeAuth(request, response)) { return; } await getRunState(request, response, runParams); return; } if (request.method === "GET" && runParams && url.pathname.endsWith("/events")) { + if (!requireBridgeAuth(request, response)) { return; } await streamRunEvents(request, response, runParams); return; } @@ -108,9 +156,96 @@ const server = createServer(async (request, response) => { }); server.listen(port, () => { - console.log(`Runline orchestrator listening on http://localhost:${port}`); + console.log(`Runline Bridge listening on http://localhost:${port}`); + if (isBridgeAuthRequired()) { + console.log("Pairing is enabled. Start pairing from Runline Settings to print a one-time code here."); + } }); +async function startPairing(request: IncomingMessage, response: ServerResponse) { + if (!isBridgeAuthRequired()) { + sendJSON(response, 200, { + pairingRequired: false, + message: "Runline Bridge pairing is disabled by environment configuration.", + }); + return; + } + + cleanupExpiredPairings(); + const body = await readJSON(request); + const session: PairingSession = { + id: randomUUID(), + code: String(randomInt(100000, 1_000_000)), + deviceName: emptyToUndefined(body.deviceName), + expiresAt: Date.now() + pairingTTLMs, + }; + pairingSessions.set(session.id, session); + + const device = session.deviceName ? ` for ${session.deviceName}` : ""; + console.log(`\nRunline pairing code${device}: ${session.code}`); + console.log(`This code expires at ${new Date(session.expiresAt).toLocaleTimeString()}.\n`); + + sendJSON(response, 202, { + pairingId: session.id, + expiresAt: new Date(session.expiresAt).toISOString(), + message: "Check the terminal running Runline Bridge for the pairing code.", + }); +} + +async function completePairing(request: IncomingMessage, response: ServerResponse) { + cleanupExpiredPairings(); + const body = await readJSON(request); + const pairingId = emptyToUndefined(body.pairingId); + const submittedCode = emptyToUndefined(body.code); + if (!pairingId || !submittedCode) { + sendJSON(response, 400, { + error: "pairing_id_and_code_required", + message: "Pairing ID and code are required.", + }); + return; + } + + const session = pairingSessions.get(pairingId); + if (!session) { + sendJSON(response, 404, { + error: "pairing_not_found", + message: "Start a new pairing session from Runline Settings.", + }); + return; + } + + if (session.expiresAt <= Date.now()) { + pairingSessions.delete(pairingId); + sendJSON(response, 410, { + error: "pairing_expired", + message: "The pairing code expired. Start pairing again.", + }); + return; + } + + if (session.code !== submittedCode.trim()) { + sendJSON(response, 401, { + error: "invalid_pairing_code", + message: "The pairing code did not match.", + }); + return; + } + + const token = randomBytes(32).toString("base64url"); + issuedBridgeTokens.add(token); + pairingSessions.delete(pairingId); + + const deviceName = emptyToUndefined(body.deviceName) ?? session.deviceName ?? "Runline device"; + console.log(`Runline paired ${deviceName}.`); + + sendJSON(response, 200, { + bridgeToken: token, + bridgeName: "Runline Bridge", + service: serviceName, + sdk: sdkName, + }); +} + async function createSDKSession(request: IncomingMessage, response: ServerResponse) { const body = await readJSON(request); const apiKey = apiKeyFrom(request); @@ -401,6 +536,46 @@ function apiKeyFrom(request: IncomingMessage): string | undefined { return emptyToUndefined(process.env.CURSOR_API_KEY); } +function requireBridgeAuth(request: IncomingMessage, response: ServerResponse) { + if (isAuthorizedBridgeRequest(request)) { + return true; + } + sendJSON(response, 401, { + error: "bridge_pairing_required", + message: "Pair Runline Bridge from Settings before using Cursor SDK.", + }); + return false; +} + +function isAuthorizedBridgeRequest(request: IncomingMessage) { + if (!isBridgeAuthRequired()) { + return true; + } + const token = bridgeTokenFrom(request); + return Boolean(token && issuedBridgeTokens.has(token)); +} + +function bridgeTokenFrom(request: IncomingMessage): string | undefined { + const raw = request.headers["x-runline-bridge-token"]; + if (Array.isArray(raw)) { + return emptyToUndefined(raw[0]); + } + return emptyToUndefined(raw); +} + +function isBridgeAuthRequired() { + return process.env.RUNLINE_BRIDGE_DISABLE_PAIRING?.toLowerCase() !== "true"; +} + +function cleanupExpiredPairings() { + const now = Date.now(); + for (const [id, session] of pairingSessions) { + if (session.expiresAt <= now) { + pairingSessions.delete(id); + } + } +} + function mcpProfile(id: string | undefined): SDKMCPProfile | undefined { const profileId = emptyToUndefined(id); if (!profileId) { diff --git a/orchestrator/tsconfig.json b/orchestrator/tsconfig.json index f7a125b..c15501e 100644 --- a/orchestrator/tsconfig.json +++ b/orchestrator/tsconfig.json @@ -7,6 +7,8 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, + "rootDir": "src", + "outDir": "dist", "noUncheckedIndexedAccess": true }, "include": ["src/**/*.ts"] diff --git a/project.yml b/project.yml index 4701104..255c401 100644 --- a/project.yml +++ b/project.yml @@ -10,7 +10,7 @@ settings: base: SWIFT_VERSION: "6.0" MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "8" + CURRENT_PROJECT_VERSION: "11" ENABLE_USER_SCRIPT_SANDBOXING: YES targets: