diff --git a/Distribution/ExportOptions.plist b/Distribution/ExportOptions.plist new file mode 100644 index 0000000..8b777c9 --- /dev/null +++ b/Distribution/ExportOptions.plist @@ -0,0 +1,6 @@ + + + +methoddeveloper-id +signingStyleautomatic + diff --git a/Distribution/notarize.sh b/Distribution/notarize.sh new file mode 100755 index 0000000..c25a85c --- /dev/null +++ b/Distribution/notarize.sh @@ -0,0 +1,33 @@ +#!/bin/zsh +set -euo pipefail + +APP_PATH="${1:-}" +PROFILE="${NOTARY_PROFILE:-SonarDesk-Notary}" + +if [[ -z "$APP_PATH" || ! -d "$APP_PATH" ]]; then + echo "Usage: NOTARY_PROFILE=SonarDesk-Notary $0 /path/to/SonarDesk.app" >&2 + exit 64 +fi + +APP_PATH="$(cd "$(dirname "$APP_PATH")" && pwd)/$(basename "$APP_PATH")" +WORK_DIR="$(mktemp -d)" +ZIP_PATH="$WORK_DIR/SonarDesk.zip" +trap 'rm -rf "$WORK_DIR"' EXIT + +echo "Verifying Developer ID signature and hardened runtime..." +codesign --verify --deep --strict --verbose=2 "$APP_PATH" +spctl --assess --type execute --verbose=4 "$APP_PATH" || true + +echo "Preparing notarization upload..." +ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH" + +echo "Submitting to Apple notary service..." +xcrun notarytool submit "$ZIP_PATH" --keychain-profile "$PROFILE" --wait + +echo "Stapling notarization ticket..." +xcrun stapler staple "$APP_PATH" +xcrun stapler validate "$APP_PATH" + +echo "Final Gatekeeper assessment..." +spctl --assess --type execute --verbose=4 "$APP_PATH" +echo "SonarDesk is signed, notarized, and stapled." diff --git a/README.md b/README.md index 2e8ece6..8911977 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,31 @@ Open SonarDesk > Settings, add your SonarQube Server URL and a SonarQube user to - Project metric navigation replaces the old View label/buttons; every metric card has a destination. - Professional semantic colour system across dashboard/project metrics. - New SonarDesk application icon and branded launch splash. + +## v2.0.2 branding fix +- AppIcon is explicitly selected by the target and `CFBundleIconName`. +- SonarDeskLogo is bundled as a normal image asset and applied to `NSApplication.applicationIconImage` at launch, including development runs. +- Startup now begins on a dedicated branded launch view for at least 1.6 seconds while the initial SonarQube refresh runs, then transitions into the main UI. + +## v2.1.0 — Direct distribution hardening +- Hardened Runtime explicitly enabled for Debug and Release. +- App Sandbox retained with only outbound network access. +- No hardened-runtime exception entitlements (no JIT, unsigned executable memory, DYLD injection, or disabled library validation). +- Release builds do not inject Xcode debug entitlements. +- SonarQube tokens remain in Keychain and are marked ThisDeviceOnly. +- Networking uses an ephemeral URLSession with no URL cache or cookie store. +- Production SonarQube endpoints must use HTTPS; plain HTTP is limited to localhost development. +- Server error response bodies are not surfaced to the UI. +- `Distribution/ExportOptions.plist` is included for Developer ID export. +- `Distribution/notarize.sh` verifies signing, submits with `notarytool`, staples the ticket, and runs Gatekeeper assessment. + +### Direct release workflow +1. In Xcode, set your Apple Developer Team and ensure a **Developer ID Application** certificate is available. +2. Product > Archive. +3. Organizer > Distribute App > Developer ID / Direct Distribution and export the signed app. +4. Store notarization credentials once, for example: + `xcrun notarytool store-credentials SonarDesk-Notary --apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID --password YOUR_APP_SPECIFIC_PASSWORD` +5. Run: `NOTARY_PROFILE=SonarDesk-Notary ./Distribution/notarize.sh /path/to/SonarDesk.app` +6. Distribute the stapled app in a DMG/ZIP. Do not re-sign or modify the app after notarization. + +Before every release, test the exported build on a clean macOS user account and run `codesign --verify --deep --strict`, `spctl --assess --type execute`, and `xcrun stapler validate`. diff --git a/SonarDesk.xcodeproj/project.pbxproj b/SonarDesk.xcodeproj/project.pbxproj index f6c9abd..9056460 100644 --- a/SonarDesk.xcodeproj/project.pbxproj +++ b/SonarDesk.xcodeproj/project.pbxproj @@ -17,8 +17,8 @@ A8 = {isa = XCConfigurationList; buildConfigurations = (A14,A15); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release;}; A12 = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0;}; name = Debug;}; A13 = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0;}; name = Release;}; - A14 = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0;}; name = Debug;}; - A15 = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0;}; name = Release;}; + A14 = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0;}; name = Debug;}; + A15 = {isa = XCBuildConfiguration; buildSettings = {ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SonarDesk/SonarDesk.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 5; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = SonarDesk; INFOPLIST_KEY_CFBundleIconName = AppIcon; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.SonarDesk; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = targeted; SWIFT_VERSION = 5.0;}; name = Release;}; }; rootObject = A1; } diff --git a/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate b/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate index 6945a89..f521cc7 100644 Binary files a/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate and b/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/SonarDesk/App/SonarDeskAboutPanel.swift b/SonarDesk/App/SonarDeskAboutPanel.swift new file mode 100644 index 0000000..1ae0a80 --- /dev/null +++ b/SonarDesk/App/SonarDeskAboutPanel.swift @@ -0,0 +1,25 @@ +import AppKit + +@MainActor +enum SonarDeskAboutPanel { + static func show() { + var options: [NSApplication.AboutPanelOptionKey: Any] = [ + .applicationName: "SonarDesk", + .applicationVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "2.0.4", + .version: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1", + .credits: NSAttributedString( + string: "Native SonarQube monitoring for macOS", + attributes: [.foregroundColor: NSColor.secondaryLabelColor] + ) + ] + + // Supplying the icon directly makes the About panel deterministic in both + // Xcode development runs and installed/release builds. + if let icon = NSImage(named: "SonarDeskLogo") ?? NSApplication.shared.applicationIconImage { + options[.applicationIcon] = icon + } + + NSApplication.shared.orderFrontStandardAboutPanel(options: options) + NSApplication.shared.activate(ignoringOtherApps: true) + } +} diff --git a/SonarDesk/App/SonarDeskApp.swift b/SonarDesk/App/SonarDeskApp.swift index 69356f4..48cf0f8 100644 --- a/SonarDesk/App/SonarDeskApp.swift +++ b/SonarDesk/App/SonarDeskApp.swift @@ -1,30 +1,73 @@ import SwiftUI +import AppKit -@main -struct SonarDeskApp: App { - @StateObject private var appState = AppState() - @State private var showingLaunch = true - - var body: some Scene { - WindowGroup { - ZStack { - RootView() - .environmentObject(appState) - .opacity(showingLaunch ? 0 : 1) - if showingLaunch { LaunchView().transition(.opacity) } - } - .frame(minWidth: 1040, minHeight: 680) - .task { - if appState.projects.isEmpty && appState.selectedServer != nil { await appState.refresh() } - try? await Task.sleep(for: .milliseconds(900)) - withAnimation(.easeOut(duration: 0.28)) { showingLaunch = false } - } - } - .windowStyle(.titleBar) - .commands { SidebarCommands() } - - Settings { - SettingsView().environmentObject(appState).frame(width: 620, height: 430) +final class SonarDeskAppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + // Force the branded icon for development builds as well as installed builds. + // The asset-catalog AppIcon remains the packaged Finder/Dock icon. + if let icon = NSImage(named: "SonarDeskLogo") { + NSApplication.shared.applicationIconImage = icon + } + } +} + +@main +struct SonarDeskApp: App { + @NSApplicationDelegateAdaptor(SonarDeskAppDelegate.self) private var appDelegate + @StateObject private var appState = AppState() + @State private var launchPhase: LaunchPhase = .splash + + private enum LaunchPhase { + case splash + case application + } + + var body: some Scene { + WindowGroup { + Group { + switch launchPhase { + case .splash: + LaunchView() + .task { await startApplication() } + case .application: + RootView() + .environmentObject(appState) + .transition(.opacity) + } + } + .frame(minWidth: 1040, minHeight: 680) + .background(Color(nsColor: .windowBackgroundColor)) + } + .windowStyle(.hiddenTitleBar) + .commands { + SidebarCommands() + CommandGroup(replacing: .appInfo) { + Button("About SonarDesk") { + SonarDeskAboutPanel.show() + } + } + } + + Settings { + SettingsView() + .environmentObject(appState) + .frame(width: 620, height: 430) + } + } + + @MainActor + private func startApplication() async { + // Keep the branded launch experience visible long enough to be perceptible, + // while using the time to perform the first SonarQube refresh. + async let minimumSplash: Void = Task.sleep(for: .seconds(1.6)) + + if appState.projects.isEmpty, appState.selectedServer != nil { + await appState.refresh() + } + + _ = try? await minimumSplash + withAnimation(.easeInOut(duration: 0.32)) { + launchPhase = .application } } } diff --git a/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/Contents.json b/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/Contents.json new file mode 100644 index 0000000..3a1990c --- /dev/null +++ b/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/Contents.json @@ -0,0 +1,7 @@ +{ + "images" : [ + { "filename" : "SonarDeskLogo.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "SonarDeskLogo@2x.png", "idiom" : "universal", "scale" : "2x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/SonarDeskLogo.png b/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/SonarDeskLogo.png new file mode 100644 index 0000000..2f2d498 Binary files /dev/null and b/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/SonarDeskLogo.png differ diff --git a/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/SonarDeskLogo@2x.png b/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/SonarDeskLogo@2x.png new file mode 100644 index 0000000..3614192 Binary files /dev/null and b/SonarDesk/Assets.xcassets/SonarDeskLogo.imageset/SonarDeskLogo@2x.png differ diff --git a/SonarDesk/Services/KeychainService.swift b/SonarDesk/Services/KeychainService.swift index 833f229..bc947cf 100644 --- a/SonarDesk/Services/KeychainService.swift +++ b/SonarDesk/Services/KeychainService.swift @@ -5,24 +5,23 @@ enum KeychainService { static let service = "net.absdev.SonarDesk" static func saveToken(_ token: String, serverID: UUID) throws { + let cleanToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanToken.isEmpty else { throw KeychainError.emptyToken } let account = serverID.uuidString - let data = Data(token.utf8) - let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account] + let data = Data(cleanToken.utf8) + let query = baseQuery(account: account) SecItemDelete(query as CFDictionary) var add = query add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly let status = SecItemAdd(add as CFDictionary, nil) guard status == errSecSuccess else { throw KeychainError.status(status) } } static func token(serverID: UUID) -> String? { - let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: serverID.uuidString, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne] + var query = baseQuery(account: serverID.uuidString) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil } @@ -30,15 +29,25 @@ enum KeychainService { } static func deleteToken(serverID: UUID) { - let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: serverID.uuidString] - SecItemDelete(query as CFDictionary) + SecItemDelete(baseQuery(account: serverID.uuidString) as CFDictionary) + } + + private static func baseQuery(account: String) -> [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account] } } enum KeychainError: LocalizedError { + case emptyToken case status(OSStatus) - var errorDescription: String? { "Keychain error (\(statusCode))" } - private var statusCode: OSStatus { if case .status(let s) = self { return s }; return -1 } + + var errorDescription: String? { + switch self { + case .emptyToken: return "The SonarQube token cannot be empty." + case .status(let status): + return SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error (\(status))." + } + } } diff --git a/SonarDesk/Services/SonarQubeClient.swift b/SonarDesk/Services/SonarQubeClient.swift index da0ba75..c77bcef 100644 --- a/SonarDesk/Services/SonarQubeClient.swift +++ b/SonarDesk/Services/SonarQubeClient.swift @@ -8,9 +8,14 @@ actor SonarQubeClient { init(server: SonarServer, token: String) { self.server = server self.token = token - let config = URLSessionConfiguration.default + let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 30 config.timeoutIntervalForResource = 60 + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.urlCache = nil + config.httpCookieStorage = nil + config.httpShouldSetCookies = false + config.httpAdditionalHeaders = ["Accept": "application/json"] self.session = URLSession(configuration: config) } @@ -37,30 +42,45 @@ actor SonarQubeClient { } private func get(_ path: String, query: [String:String] = [:]) async throws -> T { - guard var components = URLComponents(string: server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/" + path) else { throw APIError.invalidURL } - if !query.isEmpty { components.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } } + let base = server.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard var components = URLComponents(string: base + "/" + path), + let scheme = components.scheme?.lowercased(), + scheme == "https" || (scheme == "http" && Self.isLocalDevelopmentHost(components.host)) else { + throw APIError.insecureURL + } + if !query.isEmpty { components.queryItems = query.sorted(by: { $0.key < $1.key }).map { URLQueryItem(name: $0.key, value: $0.value) } } guard let url = components.url else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.cachePolicy = .reloadIgnoringLocalCacheData request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") + let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } guard (200...299).contains(http.statusCode) else { - let body = String(data: data, encoding: .utf8) ?? "" - throw APIError.http(http.statusCode, body) + // Never surface the response body: proxies and servers may echo sensitive data. + throw APIError.http(http.statusCode) } do { return try JSONDecoder().decode(T.self, from: data) } catch { throw APIError.decoding(error.localizedDescription) } } + + private static func isLocalDevelopmentHost(_ host: String?) -> Bool { + guard let host = host?.lowercased() else { return false } + return host == "localhost" || host == "127.0.0.1" || host == "::1" + } } enum APIError: LocalizedError { - case invalidURL, invalidResponse, http(Int, String), decoding(String), notConfigured + case invalidURL, insecureURL, invalidResponse, http(Int), decoding(String), notConfigured var errorDescription: String? { switch self { case .invalidURL: return "The server URL is invalid." + case .insecureURL: return "Use an HTTPS SonarQube URL. HTTP is allowed only for localhost development." case .invalidResponse: return "The server returned an invalid response." - case .http(let code, _): return "SonarQube returned HTTP \(code)." + case .http(let code): return "SonarQube returned HTTP \(code)." case .decoding(let text): return "Could not read the SonarQube response: \(text)" case .notConfigured: return "Configure a SonarQube server in Settings." } diff --git a/SonarDesk/SonarDesk.entitlements b/SonarDesk/SonarDesk.entitlements index 65a5b81..1981043 100644 --- a/SonarDesk/SonarDesk.entitlements +++ b/SonarDesk/SonarDesk.entitlements @@ -1,3 +1,10 @@ -com.apple.security.app-sandboxcom.apple.security.network.client + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/SonarDesk/Stores/AppState.swift b/SonarDesk/Stores/AppState.swift index f940377..8d65be4 100644 --- a/SonarDesk/Stores/AppState.swift +++ b/SonarDesk/Stores/AppState.swift @@ -67,7 +67,9 @@ final class AppState: ObservableObject { func addServer(name: String, url: String, token: String) throws { let normalized = url.trimmingCharacters(in: .whitespacesAndNewlines) - guard URL(string: normalized)?.scheme != nil else { throw APIError.invalidURL } + guard let parsed = URL(string: normalized), let scheme = parsed.scheme?.lowercased(), parsed.host != nil else { throw APIError.invalidURL } + let localHost = ["localhost", "127.0.0.1", "::1"].contains(parsed.host?.lowercased() ?? "") + guard scheme == "https" || (scheme == "http" && localHost) else { throw APIError.insecureURL } let server = SonarServer(name: name.isEmpty ? "SonarQube" : name, baseURL: normalized) try KeychainService.saveToken(token, serverID: server.id) servers.append(server); selectedServerID = server.id diff --git a/SonarDesk/Views/Components/MetricCard.swift b/SonarDesk/Views/Components/MetricCard.swift index e34d460..c251b02 100644 --- a/SonarDesk/Views/Components/MetricCard.swift +++ b/SonarDesk/Views/Components/MetricCard.swift @@ -4,47 +4,44 @@ struct MetricCard: View { let title: String let value: String let systemImage: String - var tint: Color = .accentColor - var action: (() -> Void)? + let tint: Color + var subtitle: String? = nil + var action: (() -> Void)? = nil + + @State private var hovering = false var body: some View { - Button { - action?() - } label: { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - Image(systemName: systemImage) - .foregroundStyle(tint) - Text(title) - .foregroundStyle(.secondary) - Spacer() - if action != nil { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) + Button { action?() } label: { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 9) { + ZStack { + RoundedRectangle(cornerRadius: 8).fill(tint.opacity(0.16)).frame(width: 32, height: 32) + Image(systemName: systemImage).foregroundStyle(tint) } + Text(title).font(.subheadline.weight(.semibold)).foregroundStyle(.secondary) + Spacer() + if action != nil { Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary) } } - .font(.subheadline.weight(.medium)) - - Text(value) - .font(.system(size: 30, weight: .semibold, design: .rounded)) - .foregroundStyle(.primary) - .contentTransition(.numericText()) + Text(value).font(.system(size: 34, weight: .bold, design: .rounded)).foregroundStyle(.primary).contentTransition(.numericText()) + if let subtitle { Text(subtitle).font(.caption).foregroundStyle(.secondary).lineLimit(1) } } - .frame(maxWidth: .infinity, minHeight: 105, alignment: .leading) + .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) .padding(18) .background { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(tint.opacity(0.10)) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(tint.opacity(0.22), lineWidth: 1) - } + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous).fill(.regularMaterial) + LinearGradient(colors: [tint.opacity(hovering ? 0.20 : 0.13), tint.opacity(0.025)], startPoint: .topLeading, endPoint: .bottomTrailing) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(tint.opacity(hovering ? 0.55 : 0.28), lineWidth: 1) } + .shadow(color: tint.opacity(hovering ? 0.12 : 0.04), radius: 14, y: 5) } - .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .scaleEffect(hovering && action != nil ? 1.012 : 1) + .animation(.easeOut(duration: 0.16), value: hovering) + .contentShape(RoundedRectangle(cornerRadius: 16)) } - .buttonStyle(.plain) - .disabled(action == nil) + .buttonStyle(.plain).disabled(action == nil) + .onHover { hovering = $0 } } } diff --git a/SonarDesk/Views/Dashboard/DashboardView.swift b/SonarDesk/Views/Dashboard/DashboardView.swift index 3355143..7708834 100644 --- a/SonarDesk/Views/Dashboard/DashboardView.swift +++ b/SonarDesk/Views/Dashboard/DashboardView.swift @@ -3,114 +3,113 @@ import SwiftUI struct DashboardView: View { @EnvironmentObject var app: AppState let onNavigate: (Destination) -> Void - @State private var projectPage = 1 @State private var pageSize = 10 @State private var failedOnly = false + @State private var search = "" - private var total: (String) -> Int { - { key in app.projects.reduce(0) { $0 + (Int($1.measures[key] ?? "0") ?? 0) } } - } - + private func total(_ key: String) -> Int { app.projects.reduce(0) { $0 + (Int($1.measures[key] ?? "0") ?? 0) } } private var failed: Int { app.projects.filter { $0.qualityGate != "OK" }.count } - private var visibleProjects: [ProjectSnapshot] { failedOnly ? app.projects.filter { $0.qualityGate != "OK" } : app.projects } - private var pageCount: Int { max(1, Int(ceil(Double(visibleProjects.count) / Double(pageSize)))) } - private var pagedProjects: [ProjectSnapshot] { - let safePage = min(max(projectPage, 1), pageCount) - let start = (safePage - 1) * pageSize - guard start < visibleProjects.count else { return [] } - return Array(visibleProjects[start.. pageSize { - Divider().padding(.top, 4) - HStack(spacing: 12) { - Picker("Rows", selection: $pageSize) { - Text("10").tag(10); Text("25").tag(25); Text("50").tag(50) - } - .pickerStyle(.menu).frame(width: 105) - .onChange(of: pageSize) { _, _ in projectPage = 1 } - Spacer() - Text("Page \(projectPage) of \(pageCount)").font(.caption).foregroundStyle(.secondary) - Button { projectPage = max(1, projectPage - 1) } label: { Image(systemName: "chevron.left") } - .disabled(projectPage <= 1) - Button { projectPage = min(pageCount, projectPage + 1) } label: { Image(systemName: "chevron.right") } - .disabled(projectPage >= pageCount) - }.padding(.top, 10) - } - }.padding(.horizontal, 4) - } - }.padding(24) + projectPanel + }.padding(26) } - .navigationTitle("Dashboard") - .onChange(of: app.projects.count) { _, _ in projectPage = min(projectPage, pageCount) } + .background(Color(nsColor: .windowBackgroundColor)) + .onChange(of: pageSize) { _, _ in projectPage = 1 } + .onChange(of: search) { _, _ in projectPage = 1 } + } + + private var header: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 5) { + Text("Dashboard").font(.system(size: 32, weight: .bold)) + Text("Overview of your SonarQube instance").foregroundStyle(.secondary) + } + Spacer() + if let date = app.lastRefresh { Text("Updated \(date, style: .relative) ago").font(.caption).foregroundStyle(.secondary).padding(.top, 7) } + } + } + + private var projectPanel: some View { + SonarPanel { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(failedOnly ? "Projects — Failed Quality Gates" : "Projects").font(.title3.bold()) + Text("Your SonarQube projects in your preferred order").font(.caption).foregroundStyle(.secondary) + } + Spacer() + if failedOnly { Button("Show All") { failedOnly = false; projectPage = 1 }.buttonStyle(.bordered) } + TextField("Search projects…", text: $search).textFieldStyle(.roundedBorder).frame(width: 210) + Picker("Rows", selection: $pageSize) { Text("10 per page").tag(10); Text("25 per page").tag(25); Text("50 per page").tag(50) }.labelsHidden().frame(width: 120) + }.padding(16) + Divider() + HStack { + Text("PROJECT").frame(maxWidth: .infinity, alignment: .leading) + Text("BUGS").frame(width: 65, alignment: .trailing) + Text("VULNS").frame(width: 65, alignment: .trailing) + Text("SMELLS").frame(width: 80, alignment: .trailing) + Text("COVERAGE").frame(width: 80, alignment: .trailing) + Text("QUALITY GATE").frame(width: 110, alignment: .trailing) + Color.clear.frame(width: 18) + }.font(.caption2.bold()).foregroundStyle(.tertiary).padding(.horizontal, 16).padding(.vertical, 9) + Divider() + ForEach(Array(pageRows.enumerated()), id: \.element.id) { index, item in + Button { onNavigate(.project(item.project.key)) } label: { + HStack { + HStack(spacing: 11) { + Image(systemName: "shippingbox.fill").foregroundStyle(.blue) + VStack(alignment: .leading, spacing: 1) { Text(item.project.name).fontWeight(.semibold); Text(item.project.key).font(.caption).foregroundStyle(.secondary) } + }.frame(maxWidth: .infinity, alignment: .leading) + metricPill(item.measures["bugs"] ?? "0", .red, 65) + metricPill(item.measures["vulnerabilities"] ?? "0", .orange, 65) + metricPill(item.measures["code_smells"] ?? "0", .yellow, 80) + metricPill((item.measures["coverage"] ?? "0") + "%", .green, 80) + GateBadge(status: item.qualityGate).frame(width: 110, alignment: .trailing) + Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary).frame(width: 18) + }.padding(.horizontal, 16).padding(.vertical, 10).contentShape(Rectangle()) + }.buttonStyle(.plain) + if index < pageRows.count - 1 { Divider().padding(.leading, 16) } + } + if filtered.isEmpty { ContentUnavailableView("No projects", systemImage: "shippingbox", description: Text("No projects match the current view.")).padding(30) } + Divider() + HStack { + let start = filtered.isEmpty ? 0 : ((projectPage - 1) * pageSize + 1) + Text("Showing \(start)–\(min(projectPage * pageSize, filtered.count)) of \(filtered.count) projects").font(.caption).foregroundStyle(.secondary) + Spacer() + Button { projectPage -= 1 } label: { Image(systemName: "chevron.left") }.disabled(projectPage <= 1) + Text("\(projectPage) / \(pageCount)").font(.caption.monospacedDigit()).frame(minWidth: 48) + Button { projectPage += 1 } label: { Image(systemName: "chevron.right") }.disabled(projectPage >= pageCount) + }.padding(12) + } + } + } + + private func metricPill(_ text: String, _ color: Color, _ width: CGFloat) -> some View { + Text(text).font(.caption.monospacedDigit().weight(.semibold)).foregroundStyle(color).padding(.horizontal, 7).padding(.vertical, 4) + .background(color.opacity(0.10), in: Capsule()).frame(width: width, alignment: .trailing) } } diff --git a/SonarDesk/Views/Design/SonarTheme.swift b/SonarDesk/Views/Design/SonarTheme.swift new file mode 100644 index 0000000..d7f1d74 --- /dev/null +++ b/SonarDesk/Views/Design/SonarTheme.swift @@ -0,0 +1,40 @@ +import SwiftUI + +enum SonarTheme { + static let accent = Color(red: 0.05, green: 0.48, blue: 0.96) + static let info = Color(red: 0.12, green: 0.64, blue: 0.95) + static let success = Color(red: 0.18, green: 0.78, blue: 0.43) + static let danger = Color(red: 0.96, green: 0.27, blue: 0.32) + static let warning = Color(red: 0.98, green: 0.52, blue: 0.16) + static let smell = Color(red: 0.93, green: 0.72, blue: 0.12) + static let purple = Color(red: 0.62, green: 0.39, blue: 0.94) + static let teal = Color(red: 0.10, green: 0.72, blue: 0.67) + + static let canvas = Color(nsColor: .windowBackgroundColor) + static let panel = Color(nsColor: .controlBackgroundColor) + static let border = Color.primary.opacity(0.10) + + static func tint(for metric: MetricDestination) -> Color { + switch metric { + case .projects, .lines: accent + case .failedGates, .bugs: danger + case .vulnerabilities: warning + case .codeSmells: smell + case .securityHotspots: purple + case .coverage: success + case .duplication: purple + case .qualityGates: success + case .measures: teal + } + } +} + +struct SonarPanel: View { + private let content: Content + init(@ViewBuilder content: () -> Content) { self.content = content() } + var body: some View { + content + .background(SonarTheme.panel.opacity(0.72), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(SonarTheme.border) } + } +} diff --git a/SonarDesk/Views/Issues/IssuesView.swift b/SonarDesk/Views/Issues/IssuesView.swift index dcb5d2c..d4b3b7f 100644 --- a/SonarDesk/Views/Issues/IssuesView.swift +++ b/SonarDesk/Views/Issues/IssuesView.swift @@ -17,28 +17,28 @@ enum IssueFilter: String, CaseIterable, Identifiable, Hashable { } var icon: String { switch self { - case .all: "exclamationmark.triangle" - case .bugs: "ladybug" - case .vulnerabilities: "lock.trianglebadge.exclamationmark" - case .codeSmells: "exclamationmark.bubble" + case .all: "exclamationmark.triangle.fill" + case .bugs: "ladybug.fill" + case .vulnerabilities: "lock.trianglebadge.exclamationmark.fill" + case .codeSmells: "exclamationmark.bubble.fill" } } var tint: Color { switch self { - case .all: .blue - case .bugs: .red - case .vulnerabilities: .orange - case .codeSmells: .yellow + case .all: SonarTheme.info + case .bugs: SonarTheme.danger + case .vulnerabilities: SonarTheme.warning + case .codeSmells: SonarTheme.smell } } } struct IssuesView: View { - @EnvironmentObject var app: AppState + @EnvironmentObject private var app: AppState let projectKey: String? let initialFilter: IssueFilter + @State private var search = "" - @State private var selection: Issue.ID? @State private var filter: IssueFilter @State private var page = 1 @State private var pageSize = 25 @@ -50,66 +50,137 @@ struct IssuesView: View { } private var visibleIssues: [Issue] { - guard !search.isEmpty else { return app.issues } - return app.issues.filter { + let source = app.issues + guard !search.isEmpty else { return source } + return source.filter { ($0.message ?? "").localizedCaseInsensitiveContains(search) || ($0.rule ?? "").localizedCaseInsensitiveContains(search) || - ($0.component ?? "").localizedCaseInsensitiveContains(search) + ($0.component ?? "").localizedCaseInsensitiveContains(search) || + ($0.severity ?? "").localizedCaseInsensitiveContains(search) } } + private var pageCount: Int { max(1, Int(ceil(Double(app.issueTotal) / Double(pageSize)))) } + private var requestID: String { "\(projectKey ?? "all")|\(filter.rawValue)|\(page)|\(pageSize)" } var body: some View { VStack(spacing: 0) { - HStack(spacing: 12) { - Label(filter.rawValue, systemImage: filter.icon) - .font(.title2.bold()).foregroundStyle(filter.tint) - if app.issueTotal > 0 { Text(app.issueTotal.formatted()).font(.title3.monospacedDigit()).foregroundStyle(.secondary) } - Spacer() - Picker("Type", selection: $filter) { - ForEach(IssueFilter.allCases) { item in Label(item.rawValue, systemImage: item.icon).tag(item) } - } - .pickerStyle(.menu).frame(width: 170) - Picker("Per page", selection: $pageSize) { - Text("25 per page").tag(25); Text("50 per page").tag(50); Text("100 per page").tag(100) - } - .pickerStyle(.menu).frame(width: 125) - } - .padding(.horizontal, 18).padding(.vertical, 12) - .background(filter.tint.opacity(0.055)) + issueHeader + Divider().opacity(0.55) + columnHeader + Divider().opacity(0.55) - Table(visibleIssues, selection: $selection) { - TableColumn("Severity") { issue in Text(issue.severity ?? "—").font(.caption.weight(.semibold)) }.width(80) - TableColumn("Issue") { issue in - VStack(alignment: .leading, spacing: 2) { - Text(issue.message ?? "Unknown issue").lineLimit(2) - Text(issue.rule ?? "").font(.caption).foregroundStyle(.secondary) + if app.isLoading && app.issues.isEmpty { + Spacer() + ProgressView("Loading \(filter.rawValue.lowercased())…") + Spacer() + } else if visibleIssues.isEmpty { + Spacer() + ContentUnavailableView("No \(filter.rawValue.lowercased())", systemImage: filter.icon, + description: Text(search.isEmpty ? "SonarQube returned no matching issues." : "No issues on this page match your search.")) + Spacer() + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(visibleIssues.enumerated()), id: \.offset) { index, issue in + issueRow(issue) + if index < visibleIssues.count - 1 { Divider().padding(.leading, 20).opacity(0.45) } + } } - }.width(min: 280, ideal: 500) - TableColumn("File") { issue in Text(shortComponent(issue.component)).lineLimit(1) }.width(min: 160, ideal: 280) - TableColumn("Line") { issue in Text(issue.line.map(String.init) ?? "—") }.width(50) - TableColumn("Type") { issue in Text(issue.type ?? "—") }.width(110) + } } - .searchable(text: $search, prompt: "Search this page") - Divider() - HStack(spacing: 10) { - Text(app.issueTotal == 0 ? "No issues" : "Showing \(((page - 1) * pageSize) + 1)–\(min(page * pageSize, app.issueTotal)) of \(app.issueTotal)") - .font(.caption).foregroundStyle(.secondary) - Spacer() - Button { page = max(1, page - 1) } label: { Label("Previous", systemImage: "chevron.left") }.disabled(page <= 1) - Text("Page \(page) of \(pageCount)").font(.caption.monospacedDigit()).frame(minWidth: 90) - Button { page = min(pageCount, page + 1) } label: { Label("Next", systemImage: "chevron.right") }.disabled(page >= pageCount) - } - .padding(12) + Divider().opacity(0.55) + pagination } + .background(SonarTheme.canvas) .navigationTitle(filter.rawValue) - .task(id: requestID) { await load() } - .onChange(of: filter) { _, _ in page = 1 } + .task(id: requestID) { await app.loadIssues(projectKey: projectKey, filter: filter, page: page, pageSize: pageSize) } + .onChange(of: filter) { _, _ in page = 1; search = "" } .onChange(of: pageSize) { _, _ in page = 1 } } - private var requestID: String { "\(projectKey ?? "all")|\(filter.rawValue)|\(page)|\(pageSize)" } - private func load() async { await app.loadIssues(projectKey: projectKey, filter: filter, page: page, pageSize: pageSize) } + private var issueHeader: some View { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 12, style: .continuous).fill(filter.tint.opacity(0.15)) + Image(systemName: filter.icon).font(.system(size: 18, weight: .semibold)).foregroundStyle(filter.tint) + }.frame(width: 44, height: 44) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(filter.rawValue).font(.title2.bold()) + Text(app.issueTotal.formatted()).font(.subheadline.monospacedDigit().weight(.semibold)) + .foregroundStyle(filter.tint).padding(.horizontal, 8).padding(.vertical, 3) + .background(filter.tint.opacity(0.12), in: Capsule()) + } + Text(projectKey == nil ? "Across all SonarQube projects" : "Project issues") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + TextField("Search this page", text: $search).textFieldStyle(.roundedBorder).frame(width: 220) + Picker("Issue type", selection: $filter) { + ForEach(IssueFilter.allCases) { item in Label(item.rawValue, systemImage: item.icon).tag(item) } + }.labelsHidden().pickerStyle(.menu).frame(width: 165) + Picker("Page size", selection: $pageSize) { + Text("25 per page").tag(25); Text("50 per page").tag(50); Text("100 per page").tag(100) + }.labelsHidden().pickerStyle(.menu).frame(width: 125) + } + .padding(.horizontal, 22).padding(.vertical, 16) + .background(LinearGradient(colors: [filter.tint.opacity(0.09), .clear], startPoint: .leading, endPoint: .trailing)) + } + + private var columnHeader: some View { + HStack(spacing: 12) { + Text("SEVERITY").frame(width: 88, alignment: .leading) + Text("ISSUE").frame(maxWidth: .infinity, alignment: .leading) + Text("FILE").frame(width: 230, alignment: .leading) + Text("LINE").frame(width: 52, alignment: .trailing) + Text("TYPE").frame(width: 115, alignment: .leading) + } + .font(.caption2.bold()).foregroundStyle(.tertiary) + .padding(.horizontal, 20).padding(.vertical, 9) + .background(SonarTheme.panel.opacity(0.55)) + } + + private func issueRow(_ issue: Issue) -> some View { + HStack(spacing: 12) { + severityBadge(issue.severity ?? "UNKNOWN").frame(width: 88, alignment: .leading) + VStack(alignment: .leading, spacing: 3) { + Text(issue.message ?? "Unknown issue").font(.subheadline.weight(.medium)).lineLimit(2) + Text(issue.rule ?? "No rule").font(.caption).foregroundStyle(.secondary).lineLimit(1) + }.frame(maxWidth: .infinity, alignment: .leading) + Text(shortComponent(issue.component)).font(.caption).foregroundStyle(.secondary).lineLimit(1).frame(width: 230, alignment: .leading) + Text(issue.line.map(String.init) ?? "—").font(.caption.monospacedDigit()).frame(width: 52, alignment: .trailing) + Text(displayType(issue.type)).font(.caption.weight(.semibold)).foregroundStyle(filter.tint).frame(width: 115, alignment: .leading) + } + .padding(.horizontal, 20).padding(.vertical, 12) + .contentShape(Rectangle()) + } + + private var pagination: some View { + HStack(spacing: 10) { + let start = app.issueTotal == 0 ? 0 : ((page - 1) * pageSize) + 1 + Text(app.issueTotal == 0 ? "No issues" : "Showing \(start)–\(min(page * pageSize, app.issueTotal)) of \(app.issueTotal)") + .font(.caption).foregroundStyle(.secondary) + Spacer() + Button { page = max(1, page - 1) } label: { Label("Previous", systemImage: "chevron.left") }.disabled(page <= 1 || app.isLoading) + Text("Page \(page) of \(pageCount)").font(.caption.monospacedDigit()).frame(minWidth: 92) + Button { page = min(pageCount, page + 1) } label: { Label("Next", systemImage: "chevron.right") }.disabled(page >= pageCount || app.isLoading) + }.padding(.horizontal, 18).padding(.vertical, 12).background(SonarTheme.panel.opacity(0.45)) + } + + private func severityBadge(_ severity: String) -> some View { + let color: Color = switch severity.uppercased() { + case "BLOCKER", "CRITICAL": SonarTheme.danger + case "MAJOR": SonarTheme.warning + case "MINOR": SonarTheme.info + default: .secondary + } + return Text(severity.capitalized).font(.caption2.bold()).foregroundStyle(color) + .padding(.horizontal, 7).padding(.vertical, 4).background(color.opacity(0.12), in: Capsule()) + } + + private func displayType(_ value: String?) -> String { (value ?? "—").replacingOccurrences(of: "_", with: " ").capitalized } private func shortComponent(_ value: String?) -> String { guard let value else { return "—" }; return value.split(separator: ":").last.map(String.init) ?? value } } diff --git a/SonarDesk/Views/Launch/LaunchView.swift b/SonarDesk/Views/Launch/LaunchView.swift index e109b95..7fd8b2e 100644 --- a/SonarDesk/Views/Launch/LaunchView.swift +++ b/SonarDesk/Views/Launch/LaunchView.swift @@ -1,24 +1,76 @@ import SwiftUI -import AppKit struct LaunchView: View { @State private var glow = false + @State private var progress = 0.18 var body: some View { ZStack { - LinearGradient(colors: [Color(red: 0.025, green: 0.055, blue: 0.11), Color(red: 0.035, green: 0.10, blue: 0.19), .black], startPoint: .topLeading, endPoint: .bottomTrailing) - .ignoresSafeArea() - Circle().fill(Color.blue.opacity(0.16)).frame(width: 430, height: 430).blur(radius: 80).scaleEffect(glow ? 1.12 : 0.9) - VStack(spacing: 18) { - Image(nsImage: NSApplication.shared.applicationIconImage) - .resizable().interpolation(.high).frame(width: 132, height: 132) - .shadow(color: .blue.opacity(0.45), radius: 28, y: 10) - Text("SonarDesk").font(.system(size: 42, weight: .bold, design: .rounded)) - Text("SonarQube Monitor").font(.title3).foregroundStyle(.secondary) - ProgressView().controlSize(.small).tint(.blue).padding(.top, 10) - Text("Connecting to SonarQube…").font(.caption).foregroundStyle(.tertiary) + LinearGradient( + colors: [ + Color(red: 0.018, green: 0.039, blue: 0.080), + Color(red: 0.025, green: 0.085, blue: 0.165), + Color(red: 0.010, green: 0.018, blue: 0.035) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + Circle() + .fill(Color.cyan.opacity(0.13)) + .frame(width: 480, height: 480) + .blur(radius: 90) + .scaleEffect(glow ? 1.08 : 0.88) + + VStack(spacing: 0) { + Spacer() + + Image("SonarDeskLogo") + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(width: 150, height: 150) + .shadow(color: .cyan.opacity(0.30), radius: 34, y: 14) + + Text("SonarDesk") + .font(.system(size: 44, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .padding(.top, 22) + + Text("SonarQube Monitor") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.white.opacity(0.62)) + .padding(.top, 5) + + VStack(spacing: 12) { + ProgressView(value: progress) + .progressViewStyle(.linear) + .tint(.cyan) + .frame(width: 260) + + Text("Connecting to SonarQube…") + .font(.caption) + .foregroundStyle(.white.opacity(0.48)) + } + .padding(.top, 34) + + Spacer() + + Text("SONARDESK • CODE QUALITY AT A GLANCE") + .font(.system(size: 10, weight: .semibold)) + .tracking(1.4) + .foregroundStyle(.white.opacity(0.28)) + .padding(.bottom, 34) + } + } + .onAppear { + withAnimation(.easeInOut(duration: 1.15).repeatForever(autoreverses: true)) { + glow = true + } + withAnimation(.easeInOut(duration: 1.35)) { + progress = 0.86 } } - .onAppear { withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { glow = true } } } } diff --git a/SonarDesk/Views/Metrics/GlobalMetricView.swift b/SonarDesk/Views/Metrics/GlobalMetricView.swift new file mode 100644 index 0000000..7ed85b3 --- /dev/null +++ b/SonarDesk/Views/Metrics/GlobalMetricView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct GlobalMetricView: View { + @EnvironmentObject var app: AppState + let metric: MetricDestination + let onNavigate: (Destination) -> Void + @State private var page = 1 + @State private var pageSize = 25 + @State private var search = "" + + private var title: String { + switch metric { case .projects: "Projects"; case .failedGates: "Failed Quality Gates"; case .securityHotspots: "Security Hotspots"; case .qualityGates: "Quality Gates"; case .measures: "Measures"; case .coverage: "Coverage"; case .duplication: "Duplication"; case .lines: "Lines of Code"; case .bugs: "Bugs"; case .vulnerabilities: "Vulnerabilities"; case .codeSmells: "Code Smells" } + } + private var rows: [ProjectSnapshot] { app.projects.filter { search.isEmpty || $0.project.name.localizedCaseInsensitiveContains(search) || $0.project.key.localizedCaseInsensitiveContains(search) } } + private var pages: Int { max(1, Int(ceil(Double(rows.count) / Double(pageSize)))) } + private var visible: [ProjectSnapshot] { let s = (min(page,pages)-1)*pageSize; return s < rows.count ? Array(rows[s..= pages) }.padding(12) + } + .onChange(of: search) { _,_ in page = 1 }.onChange(of: pageSize) { _,_ in page = 1 } + } + + private var tint: Color { SonarTheme.tint(for: metric) } + private var icon: String { switch metric { case .securityHotspots: "shield.lefthalf.filled.badge.checkmark"; case .qualityGates,.failedGates: "checkmark.seal.fill"; case .coverage: "chart.bar.fill"; case .duplication: "square.on.square.fill"; case .lines: "chevron.left.forwardslash.chevron.right"; case .projects: "shippingbox.fill"; default: "chart.bar.xaxis" } } + private func value(for p: ProjectSnapshot) -> Text { + switch metric { + case .securityHotspots: return Text(p.measures["security_hotspots"] ?? "0") + case .coverage: return Text((p.measures["coverage"] ?? "0") + "%") + case .duplication: return Text((p.measures["duplicated_lines_density"] ?? "0") + "%") + case .lines: return Text(p.measures["ncloc"] ?? "0") + case .measures: return Text("\(p.measures.count) metrics") + case .projects: return Text(p.measures["ncloc"] ?? "—") + case .qualityGates,.failedGates: return Text("") + case .bugs: return Text(p.measures["bugs"] ?? "0") + case .vulnerabilities: return Text(p.measures["vulnerabilities"] ?? "0") + case .codeSmells: return Text(p.measures["code_smells"] ?? "0") + } + } +} diff --git a/SonarDesk/Views/Projects/ProjectDetailView.swift b/SonarDesk/Views/Projects/ProjectDetailView.swift index dbb9e5b..e274116 100644 --- a/SonarDesk/Views/Projects/ProjectDetailView.swift +++ b/SonarDesk/Views/Projects/ProjectDetailView.swift @@ -4,75 +4,83 @@ struct ProjectDetailView: View { @EnvironmentObject var app: AppState let projectKey: String @State private var section: ProjectSection = .overview - private var item: ProjectSnapshot? { app.projects.first { $0.project.key == projectKey } } var body: some View { if let item { VStack(spacing: 0) { HStack { - VStack(alignment: .leading, spacing: 3) { - Text(item.project.name).font(.largeTitle.bold()) - Text(item.project.key).foregroundStyle(.secondary) - } + VStack(alignment: .leading, spacing: 4) { Text(item.project.name).font(.system(size: 30, weight: .bold)); Text(item.project.key).foregroundStyle(.secondary) } Spacer(); GateBadge(status: item.qualityGate) - }.padding(24) + }.padding(.horizontal, 26).padding(.top, 22).padding(.bottom, 14) ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - navButton(.overview, icon: "square.grid.2x2.fill", tint: .blue) - navButton(.bugs, icon: "ladybug.fill", tint: .red) - navButton(.vulnerabilities, icon: "lock.trianglebadge.exclamationmark.fill", tint: .orange) - navButton(.codeSmells, icon: "exclamationmark.bubble.fill", tint: .yellow) - navButton(.coverage, icon: "chart.bar.fill", tint: .green) - navButton(.duplication, icon: "square.on.square.fill", tint: .purple) - navButton(.lines, icon: "chevron.left.forwardslash.chevron.right", tint: .blue) - }.padding(.horizontal, 24).padding(.bottom, 16) + HStack(spacing: 7) { + projectNav(.overview, "square.grid.2x2.fill", .blue) + projectNav(.bugs, "ladybug.fill", .red) + projectNav(.vulnerabilities, "lock.trianglebadge.exclamationmark.fill", .orange) + projectNav(.codeSmells, "exclamationmark.bubble.fill", .yellow) + projectNav(.securityHotspots, "shield.lefthalf.filled.badge.checkmark", .purple) + projectNav(.coverage, "chart.bar.fill", .green) + projectNav(.duplication, "square.on.square.fill", .purple) + projectNav(.lines, "chevron.left.forwardslash.chevron.right", .blue) + projectNav(.qualityGate, "checkmark.seal.fill", .green) + projectNav(.measures, "chart.bar.xaxis", .cyan) + }.padding(.horizontal, 26).padding(.bottom, 14) } Divider() switch section { - case .overview: - ScrollView { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 210), spacing: 14)], spacing: 14) { - MetricCard(title: "Bugs", value: item.measures["bugs"] ?? "—", systemImage: "ladybug.fill", tint: .red) { section = .bugs } - MetricCard(title: "Vulnerabilities", value: item.measures["vulnerabilities"] ?? "—", systemImage: "lock.trianglebadge.exclamationmark.fill", tint: .orange) { section = .vulnerabilities } - MetricCard(title: "Code Smells", value: item.measures["code_smells"] ?? "—", systemImage: "exclamationmark.bubble.fill", tint: .yellow) { section = .codeSmells } - MetricCard(title: "Coverage", value: (item.measures["coverage"].map { $0 + "%" }) ?? "—", systemImage: "chart.bar.fill", tint: .green) { section = .coverage } - MetricCard(title: "Duplication", value: (item.measures["duplicated_lines_density"].map { $0 + "%" }) ?? "—", systemImage: "square.on.square.fill", tint: .purple) { section = .duplication } - MetricCard(title: "Lines of Code", value: item.measures["ncloc"] ?? "—", systemImage: "chevron.left.forwardslash.chevron.right", tint: .blue) { section = .lines } - }.padding(24) - } + case .overview: overview(item) case .bugs: IssuesView(projectKey: projectKey, initialFilter: .bugs).id("\(projectKey)-bugs") case .vulnerabilities: IssuesView(projectKey: projectKey, initialFilter: .vulnerabilities).id("\(projectKey)-vulnerabilities") case .codeSmells: IssuesView(projectKey: projectKey, initialFilter: .codeSmells).id("\(projectKey)-smells") - case .coverage: metricDetail("Coverage", value: (item.measures["coverage"].map { $0 + "%" }) ?? "—", icon: "chart.bar.fill", tint: .green, description: "Test coverage reported by SonarQube for this project.") - case .duplication: metricDetail("Duplication", value: (item.measures["duplicated_lines_density"].map { $0 + "%" }) ?? "—", icon: "square.on.square.fill", tint: .purple, description: "Duplicated lines density reported by SonarQube.") - case .lines: metricDetail("Lines of Code", value: item.measures["ncloc"] ?? "—", icon: "chevron.left.forwardslash.chevron.right", tint: .blue, description: "Non-comment lines of code measured by SonarQube.") + case .securityHotspots: metricDetail("Security Hotspots", item.measures["security_hotspots"] ?? "0", "shield.lefthalf.filled.badge.checkmark", .purple, "Security-sensitive code requiring review in SonarQube.") + case .coverage: metricDetail("Coverage", (item.measures["coverage"] ?? "0") + "%", "chart.bar.fill", .green, "Test coverage reported by SonarQube for this project.") + case .duplication: metricDetail("Duplication", (item.measures["duplicated_lines_density"] ?? "0") + "%", "square.on.square.fill", .purple, "Duplicated lines density reported by SonarQube.") + case .lines: metricDetail("Lines of Code", item.measures["ncloc"] ?? "0", "chevron.left.forwardslash.chevron.right", .blue, "Non-comment lines of code measured by SonarQube.") + case .qualityGate: metricDetail("Quality Gate", item.qualityGate == "OK" ? "Passed" : item.qualityGate.capitalized, item.qualityGate == "OK" ? "checkmark.seal.fill" : "xmark.seal.fill", item.qualityGate == "OK" ? .green : .red, "Current quality-gate result for this project.") + case .measures: measures(item) } } } else { ContentUnavailableView("Project unavailable", systemImage: "shippingbox") } } - private func navButton(_ target: ProjectSection, icon: String, tint: Color) -> some View { - Button { section = target } label: { Label(target.title, systemImage: icon).padding(.horizontal, 4) } - .buttonStyle(.borderedProminent).tint(section == target ? tint : .gray.opacity(0.35)) + private func overview(_ item: ProjectSnapshot) -> some View { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), spacing: 14)], spacing: 14) { + MetricCard(title: "Bugs", value: item.measures["bugs"] ?? "0", systemImage: "ladybug.fill", tint: .red) { section = .bugs } + MetricCard(title: "Vulnerabilities", value: item.measures["vulnerabilities"] ?? "0", systemImage: "lock.trianglebadge.exclamationmark.fill", tint: .orange) { section = .vulnerabilities } + MetricCard(title: "Code Smells", value: item.measures["code_smells"] ?? "0", systemImage: "exclamationmark.bubble.fill", tint: .yellow) { section = .codeSmells } + MetricCard(title: "Security Hotspots", value: item.measures["security_hotspots"] ?? "0", systemImage: "shield.lefthalf.filled.badge.checkmark", tint: .purple) { section = .securityHotspots } + MetricCard(title: "Coverage", value: (item.measures["coverage"] ?? "0") + "%", systemImage: "chart.bar.fill", tint: .green) { section = .coverage } + MetricCard(title: "Duplication", value: (item.measures["duplicated_lines_density"] ?? "0") + "%", systemImage: "square.on.square.fill", tint: .purple) { section = .duplication } + MetricCard(title: "Lines of Code", value: item.measures["ncloc"] ?? "0", systemImage: "chevron.left.forwardslash.chevron.right", tint: .blue) { section = .lines } + MetricCard(title: "Quality Gate", value: item.qualityGate == "OK" ? "Passed" : item.qualityGate.capitalized, systemImage: "checkmark.seal.fill", tint: item.qualityGate == "OK" ? .green : .red) { section = .qualityGate } + MetricCard(title: "Measures", value: "\(item.measures.count)", systemImage: "chart.bar.xaxis", tint: .cyan) { section = .measures } + }.padding(26) + } } - private func metricDetail(_ title: String, value: String, icon: String, tint: Color, description: String) -> some View { - VStack(spacing: 18) { - Image(systemName: icon).font(.system(size: 48)).foregroundStyle(tint) - Text(value).font(.system(size: 64, weight: .bold, design: .rounded)) - Text(title).font(.title2.bold()); Text(description).foregroundStyle(.secondary) - }.frame(maxWidth: .infinity, maxHeight: .infinity).background(tint.opacity(0.045)) + private func projectNav(_ target: ProjectSection, _ icon: String, _ tint: Color) -> some View { + Button { section = target } label: { + Label(target.title, systemImage: icon).font(.subheadline.weight(.semibold)).foregroundStyle(section == target ? Color.white : tint) + .padding(.horizontal, 12).padding(.vertical, 8) + .background(section == target ? tint : tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 8)) + }.buttonStyle(.plain) + } + + private func metricDetail(_ title: String, _ value: String, _ icon: String, _ tint: Color, _ description: String) -> some View { + VStack(spacing: 20) { Image(systemName: icon).font(.system(size: 48)).foregroundStyle(tint); Text(value).font(.system(size: 62, weight: .bold, design: .rounded)); Text(title).font(.title2.bold()); Text(description).foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, maxHeight: .infinity).background(LinearGradient(colors: [tint.opacity(0.07), .clear], startPoint: .top, endPoint: .center)) + } + + private func measures(_ item: ProjectSnapshot) -> some View { + ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) { ForEach(item.measures.keys.sorted(), id: \.self) { key in MetricCard(title: key.replacingOccurrences(of: "_", with: " ").capitalized, value: item.measures[key] ?? "—", systemImage: "chart.bar.xaxis", tint: .cyan) } }.padding(26) } } } private enum ProjectSection: Hashable { - case overview, bugs, vulnerabilities, codeSmells, coverage, duplication, lines - var title: String { - switch self { - case .overview: "Overview"; case .bugs: "Bugs"; case .vulnerabilities: "Vulnerabilities"; case .codeSmells: "Code Smells"; case .coverage: "Coverage"; case .duplication: "Duplication"; case .lines: "Lines of Code" - } - } + case overview, bugs, vulnerabilities, codeSmells, securityHotspots, coverage, duplication, lines, qualityGate, measures + var title: String { switch self { case .overview:"Overview"; case .bugs:"Bugs"; case .vulnerabilities:"Vulnerabilities"; case .codeSmells:"Code Smells"; case .securityHotspots:"Hotspots"; case .coverage:"Coverage"; case .duplication:"Duplication"; case .lines:"Lines"; case .qualityGate:"Quality Gate"; case .measures:"Measures" } } } diff --git a/SonarDesk/Views/RootView.swift b/SonarDesk/Views/RootView.swift index b89e211..f197a1d 100644 --- a/SonarDesk/Views/RootView.swift +++ b/SonarDesk/Views/RootView.swift @@ -1,61 +1,116 @@ import SwiftUI +import AppKit struct RootView: View { @EnvironmentObject var app: AppState - @State private var selection: Destination? = .dashboard + @State private var selection: Destination = .dashboard var body: some View { - NavigationSplitView { - List(selection: $selection) { - Section("Overview") { - Label("Dashboard", systemImage: "square.grid.2x2.fill").tag(Destination.dashboard) - Label("All Issues", systemImage: "exclamationmark.triangle.fill").tag(Destination.issues) - Label("Bugs", systemImage: "ladybug.fill").foregroundStyle(selection == .issuesFiltered(.bugs) ? .red : .primary).tag(Destination.issuesFiltered(.bugs)) - Label("Vulnerabilities", systemImage: "lock.trianglebadge.exclamationmark.fill").foregroundStyle(selection == .issuesFiltered(.vulnerabilities) ? .orange : .primary).tag(Destination.issuesFiltered(.vulnerabilities)) - Label("Code Smells", systemImage: "exclamationmark.bubble.fill").foregroundStyle(selection == .issuesFiltered(.codeSmells) ? .yellow : .primary).tag(Destination.issuesFiltered(.codeSmells)) - } - Section("Projects") { - ForEach(app.projects) { snapshot in - Label(snapshot.project.name, systemImage: "shippingbox.fill") - .tag(Destination.project(snapshot.project.key)) - .contextMenu { - Button("Move Up") { app.moveProject(snapshot.project.key, direction: -1) }.disabled(app.projects.first?.project.key == snapshot.project.key) - Button("Move Down") { app.moveProject(snapshot.project.key, direction: 1) }.disabled(app.projects.last?.project.key == snapshot.project.key) - } - } - .onMove { source, destination in app.moveProjects(from: source, to: destination) } - } - } - .navigationTitle("SonarDesk") - .safeAreaInset(edge: .bottom) { - if let server = app.selectedServer { - HStack { Circle().fill(.green).frame(width: 7, height: 7); Text(server.name).lineLimit(1); Spacer() } - .font(.caption).foregroundStyle(.secondary).padding(12) - } - } - } detail: { - Group { - switch selection { - case .dashboard: DashboardView { selection = $0 } - case .issues: IssuesView(projectKey: nil) - case .issuesFiltered(let filter): IssuesView(projectKey: nil, initialFilter: filter).id(filter.rawValue) - case .project(let key): ProjectDetailView(projectKey: key) - case .none: ContentUnavailableView("Select an item", systemImage: "sidebar.left") - } - } - } - .tint(Color(red: 0.08, green: 0.52, blue: 0.96)) - .toolbar { - ToolbarItemGroup { - if app.isLoading { ProgressView().controlSize(.small) } - Button { Task { await app.refresh() } } label: { Label("Refresh", systemImage: "arrow.clockwise") }.keyboardShortcut("r") - } + HSplitView { + brandedSidebar.frame(minWidth: 205, idealWidth: 225, maxWidth: 245) + VStack(spacing: 0) { + topBar + Divider().opacity(0.5) + detail.frame(maxWidth: .infinity, maxHeight: .infinity) + }.frame(minWidth: 820, maxWidth: .infinity, maxHeight: .infinity) } + .background(SonarTheme.canvas) + .tint(SonarTheme.accent) .task { if app.projects.isEmpty { await app.refresh() } } .alert("SonarQube", isPresented: Binding(get: { app.errorMessage != nil }, set: { if !$0 { app.errorMessage = nil } })) { Button("OK", role: .cancel) { app.errorMessage = nil } } message: { Text(app.errorMessage ?? "Unknown error") } } + + private var topBar: some View { + HStack(spacing: 10) { + Circle().fill(app.selectedServer == nil ? Color.gray : SonarTheme.success).frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 1) { + Text(app.selectedServer?.name ?? "No server configured").font(.caption.weight(.semibold)) + Text(app.selectedServer?.baseURL ?? "Open Settings to connect").font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + if let version = app.serverVersion { Text("SonarQube \(version)").font(.caption).foregroundStyle(.secondary) } + if app.isLoading { ProgressView().controlSize(.small) } + Button { Task { await app.refresh() } } label: { Label("Refresh", systemImage: "arrow.clockwise") }.buttonStyle(.bordered) + }.padding(.horizontal, 18).frame(height: 52).background(SonarTheme.panel.opacity(0.45)) + } + + private var brandedSidebar: some View { + VStack(spacing: 0) { + HStack(spacing: 11) { + Image("SonarDeskLogo").resizable().scaledToFit().frame(width: 42, height: 42) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + VStack(alignment: .leading, spacing: 1) { + Text("SonarDesk").font(.title3.bold()) + Text("SonarQube Monitor").font(.caption2).foregroundStyle(.secondary) + } + Spacer() + }.padding(.horizontal, 14).padding(.top, 16).padding(.bottom, 14) + + ScrollView { + VStack(alignment: .leading, spacing: 3) { + sidebarHeading("MONITOR") + sidebarItem("Dashboard", "square.grid.2x2.fill", .dashboard, SonarTheme.accent) + sidebarItem("Projects", "shippingbox.fill", .metric(.projects), SonarTheme.info) + sidebarItem("All Issues", "exclamationmark.triangle.fill", .issues, SonarTheme.info) + sidebarItem("Bugs", "ladybug.fill", .issuesFiltered(.bugs), SonarTheme.danger) + sidebarItem("Vulnerabilities", "lock.trianglebadge.exclamationmark.fill", .issuesFiltered(.vulnerabilities), SonarTheme.warning) + sidebarItem("Code Smells", "exclamationmark.bubble.fill", .issuesFiltered(.codeSmells), SonarTheme.smell) + sidebarItem("Security Hotspots", "shield.lefthalf.filled.badge.checkmark", .metric(.securityHotspots), SonarTheme.purple) + sidebarItem("Quality Gates", "checkmark.seal.fill", .metric(.qualityGates), SonarTheme.success) + sidebarItem("Measures", "chart.bar.xaxis", .metric(.measures), SonarTheme.teal) + + sidebarHeading("PROJECTS") + ForEach(app.projects) { snapshot in + sidebarItem(snapshot.project.name, "shippingbox", .project(snapshot.project.key), SonarTheme.info) + .contextMenu { + Button("Move Up") { app.moveProject(snapshot.project.key, direction: -1) }.disabled(app.projects.first?.project.key == snapshot.project.key) + Button("Move Down") { app.moveProject(snapshot.project.key, direction: 1) }.disabled(app.projects.last?.project.key == snapshot.project.key) + } + } + }.padding(.horizontal, 8).padding(.bottom, 12) + } + + Divider().opacity(0.5) + HStack(spacing: 8) { + Circle().fill(app.selectedServer == nil ? Color.gray : SonarTheme.success).frame(width: 7, height: 7) + Text(app.selectedServer?.name ?? "Not configured").font(.caption).foregroundStyle(.secondary).lineLimit(1) + Spacer() + }.padding(12) + }.background(SonarTheme.panel.opacity(0.30)) + } + + private func sidebarHeading(_ title: String) -> some View { + Text(title).font(.caption2.bold()).tracking(1.1).foregroundStyle(.tertiary).padding(.horizontal, 10).padding(.top, 13).padding(.bottom, 5) + } + + private func sidebarItem(_ title: String, _ icon: String, _ destination: Destination, _ tint: Color) -> some View { + let active = selection == destination + return Button { selection = destination } label: { + HStack(spacing: 10) { + Image(systemName: icon).frame(width: 18).foregroundStyle(active ? Color.white : tint) + Text(title).lineLimit(1).truncationMode(.tail) + Spacer(minLength: 0) + } + .font(.system(size: 13, weight: active ? .semibold : .regular)) + .foregroundStyle(active ? Color.white : Color.primary) + .padding(.horizontal, 10).padding(.vertical, 7) + .background(active ? SonarTheme.accent : Color.clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .contentShape(Rectangle()) + }.buttonStyle(.plain) + } + + @ViewBuilder private var detail: some View { + switch selection { + case .dashboard: DashboardView { selection = $0 } + case .issues: IssuesView(projectKey: nil) + case .issuesFiltered(let filter): IssuesView(projectKey: nil, initialFilter: filter).id("global-\(filter.rawValue)") + case .project(let key): ProjectDetailView(projectKey: key) + case .metric(let metric): GlobalMetricView(metric: metric) { selection = $0 } + } + } } -enum Destination: Hashable { case dashboard, issues, issuesFiltered(IssueFilter), project(String) } +enum MetricDestination: String, Hashable { case projects, failedGates, bugs, vulnerabilities, codeSmells, securityHotspots, coverage, duplication, lines, qualityGates, measures } +enum Destination: Hashable { case dashboard, issues, issuesFiltered(IssueFilter), project(String), metric(MetricDestination) } diff --git a/SonarDesk/Views/Settings/SettingsView.swift b/SonarDesk/Views/Settings/SettingsView.swift index 7f2ce2a..a82f1d8 100644 --- a/SonarDesk/Views/Settings/SettingsView.swift +++ b/SonarDesk/Views/Settings/SettingsView.swift @@ -25,7 +25,7 @@ struct SettingsView: View { } if let message { Text(message).foregroundStyle(.secondary) } }.padding(20).tabItem { Label("Servers", systemImage: "server.rack") } - VStack(alignment: .leading, spacing: 12) { Text("SonarDesk").font(.title.bold()); Text("Native SonarQube monitoring for macOS."); Text("Credentials are stored in the macOS Keychain. Server metadata is stored locally in UserDefaults.").foregroundStyle(.secondary); Spacer(); Text("Version 1.0.0").font(.caption).foregroundStyle(.tertiary) }.padding(24).tabItem { Label("About", systemImage: "info.circle") } + VStack(alignment: .leading, spacing: 12) { Text("SonarDesk").font(.title.bold()); Text("Native SonarQube monitoring for macOS."); Text("Credentials are stored in the macOS Keychain. Server metadata is stored locally in UserDefaults.").foregroundStyle(.secondary); Spacer(); Text("Version \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "2.0.4")").font(.caption).foregroundStyle(.tertiary) }.padding(24).tabItem { Label("About", systemImage: "info.circle") } } } diff --git a/sonar-desk-licensing/.editorconfig b/sonar-desk-licensing/.editorconfig new file mode 100755 index 0000000..6df8428 --- /dev/null +++ b/sonar-desk-licensing/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[{compose,docker-compose}.{yml,yaml}] +indent_size = 4 diff --git a/sonar-desk-licensing/.env.example b/sonar-desk-licensing/.env.example new file mode 100755 index 0000000..1204def --- /dev/null +++ b/sonar-desk-licensing/.env.example @@ -0,0 +1,87 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost:8000 + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" + +# SonarDesk commercial/licensing +SONARDESK_LICENSE_PRIVATE_KEY_PATH= +SONARDESK_LICENSE_PUBLIC_KEY= +SONARDESK_DEFAULT_EDITION=Professional +SONARDESK_DEFAULT_MAX_MAJOR_VERSION=1 +SONARDESK_PURCHASE_URL= +SONARDESK_PORTAL_URL= +SONARDESK_SUPPORT_URL= +PADDLE_API_KEY= +PADDLE_API_URL=https://sandbox-api.paddle.com +PADDLE_CLIENT_SIDE_TOKEN= +PADDLE_ENVIRONMENT=sandbox +PADDLE_WEBHOOK_SECRET= +PADDLE_WEBHOOK_TOLERANCE=300 +PADDLE_PERSONAL_PRODUCT_ID= +PADDLE_PERSONAL_MONTHLY_PRICE_ID= +PADDLE_PERSONAL_ANNUAL_PRICE_ID= +PADDLE_PROFESSIONAL_PRODUCT_ID= +PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID= +PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID= +LEMON_SQUEEZY_WEBHOOK_SECRET= diff --git a/sonar-desk-licensing/.gitattributes b/sonar-desk-licensing/.gitattributes new file mode 100755 index 0000000..fcb21d3 --- /dev/null +++ b/sonar-desk-licensing/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/sonar-desk-licensing/.gitignore b/sonar-desk-licensing/.gitignore new file mode 100755 index 0000000..7be55e2 --- /dev/null +++ b/sonar-desk-licensing/.gitignore @@ -0,0 +1,27 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.codex +/.cursor/ +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/fonts-manifest.dev.json +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +_ide_helper.php +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/sonar-desk-licensing/.npmrc b/sonar-desk-licensing/.npmrc new file mode 100755 index 0000000..495a6af --- /dev/null +++ b/sonar-desk-licensing/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/sonar-desk-licensing/AGENTS.md b/sonar-desk-licensing/AGENTS.md new file mode 100755 index 0000000..b80472f --- /dev/null +++ b/sonar-desk-licensing/AGENTS.md @@ -0,0 +1,47 @@ + +# Laravel Application + +This repository contains a Laravel application. Complete the following setup before working on the user's request. + +## Prerequisites + +Verify that PHP and Composer are available: + +```sh +php -v +composer -V +``` + +If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command: + +macOS: + +```sh +/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)" +``` + +Windows PowerShell: + +```powershell +Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5')) +``` + +Linux: + +```sh +/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)" +``` + +After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt. + +## Agent Setup + +Install Laravel Boost from the application root before making application changes: + +```sh +composer require laravel/boost --dev +php artisan boost:install +``` + +Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines. + diff --git a/sonar-desk-licensing/CLAUDE.md b/sonar-desk-licensing/CLAUDE.md new file mode 100755 index 0000000..b80472f --- /dev/null +++ b/sonar-desk-licensing/CLAUDE.md @@ -0,0 +1,47 @@ + +# Laravel Application + +This repository contains a Laravel application. Complete the following setup before working on the user's request. + +## Prerequisites + +Verify that PHP and Composer are available: + +```sh +php -v +composer -V +``` + +If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command: + +macOS: + +```sh +/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)" +``` + +Windows PowerShell: + +```powershell +Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5')) +``` + +Linux: + +```sh +/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)" +``` + +After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt. + +## Agent Setup + +Install Laravel Boost from the application root before making application changes: + +```sh +composer require laravel/boost --dev +php artisan boost:install +``` + +Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines. + diff --git a/sonar-desk-licensing/README.md b/sonar-desk-licensing/README.md new file mode 100755 index 0000000..cfcf390 --- /dev/null +++ b/sonar-desk-licensing/README.md @@ -0,0 +1,17 @@ +# SonarDesk Licensing & CMS Server + +Production Laravel backend for SonarDesk. + +## Included + +- SonarDesk licence products, signed licence issuance, activation and revocation +- Customer portal with licence and activation management +- Paddle checkout/webhook provisioning and Lemon Squeezy webhook support +- Support ticket system and admin inbox +- Admin dashboard +- Lightweight CMS for published website pages (`/admin/cms`) +- SonarDesk-branded public website, legal pages, icons and metadata + +## Setup + +Copy `.env.example` to `.env`, configure the database, mail, payment provider and SonarDesk signing keys, then run migrations. Keep the private signing key outside the public web root and out of source control. diff --git a/sonar-desk-licensing/app/Console/Commands/SonarDeskAdminCreate.php b/sonar-desk-licensing/app/Console/Commands/SonarDeskAdminCreate.php new file mode 100755 index 0000000..2ffb683 --- /dev/null +++ b/sonar-desk-licensing/app/Console/Commands/SonarDeskAdminCreate.php @@ -0,0 +1,36 @@ +argument('email'))); + $password = Str::password(24, true, true, false, false); + $u = User::firstOrNew(['email' => $email]); + $u->name = $this->option('name'); + $u->is_admin = true; + $u->email_verified_at = now(); + if (! $u->exists) { + $u->password = Hash::make($password); + } $u->save(); + $this->info("Administrator ready: {$email}"); + if (! $u->wasRecentlyCreated) { + $this->line('Existing password retained.'); + } else { + $this->warn("Temporary password (shown once): {$password}"); + } + +return self::SUCCESS; + } +} diff --git a/sonar-desk-licensing/app/Console/Commands/SonarDeskLicenseIssue.php b/sonar-desk-licensing/app/Console/Commands/SonarDeskLicenseIssue.php new file mode 100755 index 0000000..962b540 --- /dev/null +++ b/sonar-desk-licensing/app/Console/Commands/SonarDeskLicenseIssue.php @@ -0,0 +1,29 @@ +argument('product'))->firstOrFail(); + [$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null); + if (! $key) { + $this->warn('Licence already existed; plaintext key is intentionally not recoverable.'); + + return self::FAILURE; + } $this->line('Licence ID: '.$l->id); + $this->line('Licence key: '.$key); + $this->warn('This is the only time the plaintext key is shown.'); + + return self::SUCCESS; + } +} diff --git a/sonar-desk-licensing/app/Console/Commands/SonarDeskLicenseRevoke.php b/sonar-desk-licensing/app/Console/Commands/SonarDeskLicenseRevoke.php new file mode 100755 index 0000000..e6fdecd --- /dev/null +++ b/sonar-desk-licensing/app/Console/Commands/SonarDeskLicenseRevoke.php @@ -0,0 +1,22 @@ +argument('id')); + $l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]); + $this->info('Licence revoked.'); + + return self::SUCCESS; + } +} diff --git a/sonar-desk-licensing/app/Console/Commands/SonarDeskProductCreate.php b/sonar-desk-licensing/app/Console/Commands/SonarDeskProductCreate.php new file mode 100755 index 0000000..9edce35 --- /dev/null +++ b/sonar-desk-licensing/app/Console/Commands/SonarDeskProductCreate.php @@ -0,0 +1,21 @@ + $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]); + $this->info("Product {$p->code} ready."); + + return self::SUCCESS; + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Admin/AdminController.php b/sonar-desk-licensing/app/Http/Controllers/Admin/AdminController.php new file mode 100755 index 0000000..6c2bb25 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Admin/AdminController.php @@ -0,0 +1,49 @@ + License::count(), 'activeCount' => License::whereNull('revoked_at')->count(), 'activationCount' => LicenseActivation::whereNull('deactivated_at')->count(), 'userCount' => User::count()]); + } + + public function licenses(Request $r) + { + $q = License::with('product')->latest(); + if ($s = trim((string) $r->query('q'))) { + $q->where(fn ($x) => $x->where('customer_email', 'like', "%{$s}%")->orWhere('customer_name', 'like', "%{$s}%")->orWhere('license_key_last4', 'like', "%{$s}%")); + } + +return view('admin.licenses', ['licenses' => $q->paginate(50)->withQueryString()]); + } + + public function show(License $license) + { + $license->load(['product', 'activations']); + + return view('admin.license', compact('license')); + } + + public function revoke(Request $request, License $license) + { + $data = $request->validate(['reason' => ['nullable', 'string', 'max:255']]); + $license->update(['revoked_at' => now(), 'revoke_reason' => $data['reason'] ?: 'Revoked by administrator']); + + return back()->with('status', 'Licence revoked.'); + } + + public function restore(License $license) + { + $license->update(['revoked_at' => null, 'revoke_reason' => null]); + + return back()->with('status','Licence restored.'); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Admin/CmsPageController.php b/sonar-desk-licensing/app/Http/Controllers/Admin/CmsPageController.php new file mode 100644 index 0000000..b0d53ab --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Admin/CmsPageController.php @@ -0,0 +1,35 @@ + CmsPage::latest()->paginate(25)]); } + public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); } + public function store(Request $request) { $page = CmsPage::create($this->validated($request)); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); } + public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); } + public function update(Request $request, CmsPage $page) { $page->update($this->validated($request, $page)); return back()->with('status', 'Page updated.'); } + public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); } + + private function validated(Request $request, ?CmsPage $page = null): array + { + $request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]); + $data = $request->validate([ + 'title' => ['required','string','max:160'], + 'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)], + 'excerpt' => ['nullable','string','max:1000'], + 'body' => ['required','string'], + 'meta_title' => ['nullable','string','max:160'], + 'meta_description' => ['nullable','string','max:320'], + ]); + $data['is_published'] = $request->boolean('is_published'); + $data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null; + return $data; + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Admin/SupportTicketController.php b/sonar-desk-licensing/app/Http/Controllers/Admin/SupportTicketController.php new file mode 100755 index 0000000..99484af --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Admin/SupportTicketController.php @@ -0,0 +1,59 @@ +with('assignee') + ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) + ->latest() + ->paginate(50) + ->withQueryString(); + + return view('admin.support.index', compact('tickets')); + } + + public function show(SupportTicket $ticket) + { + $ticket->load(['user', 'assignee']); + if (auth()->user()) { + auth()->user()->unreadNotifications()->get() + ->filter(fn ($notification) => (int) ($notification->data['support_ticket_id'] ?? 0) === $ticket->id) + ->each->markAsRead(); + } + + return view('admin.support.show', [ + 'ticket' => $ticket, + 'admins' => User::query()->where('is_admin', true)->orderBy('name')->get(['id', 'name', 'email']), + ]); + } + + public function update(Request $request, SupportTicket $ticket): RedirectResponse + { + $data = $request->validate([ + 'status' => ['required', 'in:new,open,waiting,resolved,closed'], + 'priority' => ['required', 'in:low,normal,high,urgent'], + 'assigned_to' => ['nullable', 'integer', 'exists:users,id'], + ]); + + if ($data['assigned_to'] ?? null) { + abort_unless(User::whereKey($data['assigned_to'])->where('is_admin', true)->exists(), 422); + } + + $data['resolved_at'] = in_array($data['status'], ['resolved', 'closed'], true) + ? ($ticket->resolved_at ?? now()) + : null; + $ticket->update($data); + + return back()->with('status', 'Support ticket updated.'); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Api/LicenseActivationController.php b/sonar-desk-licensing/app/Http/Controllers/Api/LicenseActivationController.php new file mode 100755 index 0000000..d4e1aba --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Api/LicenseActivationController.php @@ -0,0 +1,164 @@ +validate([ + 'licenseKey' => ['required', 'string', 'max:128'], + 'installationID' => ['required', 'uuid'], + 'appVersion' => ['required', 'string', 'max:32'], + 'platform' => ['required', 'string', 'max:32'], + ]); + + return DB::transaction(function () use ($data, $signer) { + $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey'])) + ->lockForUpdate() + ->first(); + + if (! $license) { + throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']); + } + + if (! $license->is_usable) { + return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); + } + + $installationID = strtolower($data['installationID']); + $activation = LicenseActivation::where('license_id', $license->id) + ->where('installation_id', $installationID) + ->first(); + + if ($activation && $activation->deactivated_at === null) { + $activation->update([ + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + } else { + $activeCount = LicenseActivation::where('license_id', $license->id) + ->whereNull('deactivated_at') + ->count(); + + if ($activeCount >= $license->activation_limit) { + return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409); + } + + if ($activation) { + $activation->update([ + 'deactivated_at' => null, + 'activated_at' => now(), + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + } else { + LicenseActivation::create([ + 'license_id' => $license->id, + 'installation_id' => $installationID, + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + 'activated_at' => now(), + 'last_seen_at' => now(), + ]); + } + } + + return response()->json([ + 'licenseToken' => $signer->sign($license, $data['installationID']), + ]); + }); + } + + public function validateInstallation( + Request $request, + LicenseTokenVerifier $verifier, + LicenseSigner $signer, + ): JsonResponse { + $data = $request->validate([ + 'licenseToken' => ['required', 'string', 'max:8192'], + 'installationID' => ['required', 'uuid'], + 'appVersion' => ['required', 'string', 'max:32'], + 'platform' => ['required', 'string', 'max:32'], + ]); + + try { + $payload = $verifier->verify($data['licenseToken']); + } catch (\Throwable) { + return response()->json(['message' => 'The licence token is invalid.'], 400); + } + + if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { + return response()->json(['message' => 'The installation does not match this licence token.'], 400); + } + + return DB::transaction(function () use ($data, $payload, $signer) { + $license = License::whereKey((string) ($payload['licenseID'] ?? '')) + ->lockForUpdate() + ->first(); + + if (! $license || ! $license->is_usable) { + return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); + } + + $activation = LicenseActivation::where('license_id', $license->id) + ->where('installation_id', strtolower($data['installationID'])) + ->first(); + + if (! $activation || $activation->deactivated_at !== null) { + return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403); + } + + $activation->update([ + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + + return response()->json([ + 'active' => true, + // Refreshing the signed token also applies subscription edition/expiry changes + // without requiring the user to reactivate manually. + 'licenseToken' => $signer->sign($license, $data['installationID']), + ]); + }); + } + + public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse + { + $data = $request->validate([ + 'licenseToken' => ['required', 'string', 'max:8192'], + 'installationID' => ['required', 'uuid'], + ]); + + try { + $payload = $verifier->verify($data['licenseToken']); + } catch (\Throwable) { + return response()->json(['message' => 'The licence token is invalid.'], 400); + } + + if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { + return response()->json(['message' => 'The installation does not match this licence token.'], 400); + } + + LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? '')) + ->where('installation_id', strtolower($data['installationID'])) + ->whereNull('deactivated_at') + ->update(['deactivated_at' => now(), 'last_seen_at' => now()]); + + return response()->json((object) []); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Api/PaymentWebhookController.php b/sonar-desk-licensing/app/Http/Controllers/Api/PaymentWebhookController.php new file mode 100755 index 0000000..e4c8036 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Api/PaymentWebhookController.php @@ -0,0 +1,78 @@ +getContent(); + $verifier = match ($provider) { + 'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null + }; + if (! $verifier) { + return response()->json(['message' => 'Unknown webhook provider.'], 404); + } + try { + $verifier->verify($request, $raw); + } catch (\Throwable $e) { + report($e); + + return response()->json(['message' => 'Invalid webhook signature.'], 400); + } + try { + $payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + } catch (\Throwable) { + return response()->json(['message' => 'Invalid JSON.'], 400); + } + $externalID = (string) ($provider === 'paddle' ? ($payload['event_id'] ?? '') : ($payload['meta']['event_name'] ?? '').':'.($payload['data']['id'] ?? '')); + $eventType = (string) ($provider === 'paddle' ? ($payload['event_type'] ?? 'unknown') : ($payload['meta']['event_name'] ?? 'unknown')); + if ($externalID === '') { + return response()->json(['message' => 'Webhook event has no stable identifier.'], 400); + } + + try { + DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) { + $event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]); + if ($event->processed_at) { + return; + } + if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) { + throw new \RuntimeException('Webhook ID was reused with a different payload.'); + } + try { + $provisioner->provision($provider, $payload); + $event->update(['processed_at' => now(), 'processing_error' => null]); + } catch (\Throwable $e) { + $event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]); + throw $e; + } + }); + } catch (\Throwable $e) { + report($e); + + return response()->json(['message' => 'Webhook could not be processed.'], 500); + } + + // Delivery is intentionally outside the webhook transaction. A temporary mail + // outage must not roll back an otherwise valid Paddle event. Pending outbox + // rows remain available for portal resend or a later webhook delivery attempt. + try { + $delivery->deliverPending(10); + } catch (\Throwable $e) { + report($e); + } + + return response()->json(['ok' => true]); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/sonar-desk-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100755 index 0000000..d63878e --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,35 @@ +validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]); + if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) { + return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email'); + } + $request->session()->regenerate(); + + return redirect()->intended(route('portal.index')); + } + + public function destroy(Request $request) + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('login'); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Auth/EmailVerificationController.php b/sonar-desk-licensing/app/Http/Controllers/Auth/EmailVerificationController.php new file mode 100755 index 0000000..2f38de1 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Auth/EmailVerificationController.php @@ -0,0 +1,31 @@ +fulfill(); + + return redirect()->route('portal.index')->with('status', 'Email verified.'); + } + + public function resend(Request $request) + { + if (! $request->user()->hasVerifiedEmail()) { + $request->user()->sendEmailVerificationNotification(); + } + +return back()->with('status', 'Verification link sent.'); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Auth/PasswordController.php b/sonar-desk-licensing/app/Http/Controllers/Auth/PasswordController.php new file mode 100755 index 0000000..4eb6694 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Auth/PasswordController.php @@ -0,0 +1,43 @@ +validate(['email' => ['required', 'email']]); + $status = Password::sendResetLink(['email' => strtolower($request->email)]); + + return $status === Password::RESET_LINK_SENT ? back()->with('status', __($status)) : back()->withErrors(['email' => __($status)]); + } + + public function resetForm(Request $request, string $token) + { + return view('auth.reset-password', ['token' => $token, 'email' => $request->query('email')]); + } + + public function reset(Request $request) + { + $data = $request->validate(['token' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', PasswordRule::min(12)->letters()->mixedCase()->numbers()]]); + $status = Password::reset($data, function (User $user, string $password) { + $user->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save(); + event(new PasswordReset($user)); + }); + + return $status === Password::PASSWORD_RESET ? redirect()->route('login')->with('status', __($status)) : back()->withErrors(['email' => __($status)]); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Auth/RegisteredUserController.php b/sonar-desk-licensing/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100755 index 0000000..3b5c073 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,29 @@ +validate(['name' => ['required', 'string', 'max:120'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'confirmed', Password::min(12)->letters()->mixedCase()->numbers()]]); + $user = User::create(['name' => $data['name'], 'email' => strtolower($data['email']), 'password' => $data['password']]); + event(new Registered($user)); + Auth::login($user); + $request->session()->regenerate(); + + return redirect()->route('verification.notice'); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/CheckoutController.php b/sonar-desk-licensing/app/Http/Controllers/CheckoutController.php new file mode 100755 index 0000000..2aeb9d6 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/CheckoutController.php @@ -0,0 +1,38 @@ + ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£1.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1], + 'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£14.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1], + 'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£3.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3], + 'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£29.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3], + ]; + + foreach ($plans as $plan) { + if (trim((string) $plan['price_id']) === '') { + throw new RuntimeException('One or more Paddle price IDs are not configured.'); + } + } + + return view('checkout.buy', [ + 'plans' => $plans, + 'paddleToken' => $token, + 'paddleEnvironment' => (string) ($paddle['environment'] ?? 'sandbox'), + ]); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/CmsPageController.php b/sonar-desk-licensing/app/Http/Controllers/CmsPageController.php new file mode 100644 index 0000000..cb86e52 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/CmsPageController.php @@ -0,0 +1,14 @@ +where('is_published', true)->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->firstOrFail(); + return view('cms.show', compact('page')); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Controller.php b/sonar-desk-licensing/app/Http/Controllers/Controller.php new file mode 100755 index 0000000..ae7ff85 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]); + } + + public function index(Request $request) + { + $licenses = $this->owned($request)->latest()->get(); + $hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional'); + + return view('portal.index', compact('licenses', 'hasProfessional')); + } + + public function show(Request $request, License $license) + { + $this->authorise($request, $license); + $license->load(['product', 'activations']); + + return view('portal.show', compact('license')); + } + + public function upgrade(Request $request, License $license): RedirectResponse + { + $this->authorise($request, $license); + abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); + abort_unless($license->edition === 'Personal' && $license->is_usable, 422); + + $targetPriceID = $this->professionalPriceFor($license); + $targetProduct = LicenseProduct::query() + ->where('payment_provider', 'paddle') + ->where('external_price_id', $targetPriceID) + ->where('active', true) + ->firstOrFail(); + + try { + $subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID); + } catch (RuntimeException $exception) { + return back()->withErrors(['licence' => $exception->getMessage()]); + } + + // Apply the entitlement immediately. Paddle webhooks remain authoritative and + // will reconcile subscription status/current-period details afterwards. + $license->update([ + 'license_product_id' => $targetProduct->id, + 'edition' => $targetProduct->edition, + 'activation_limit' => $targetProduct->activation_limit, + 'max_major_version' => $targetProduct->max_major_version, + 'external_price_id' => $targetPriceID, + 'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status), + 'revoked_at' => null, + 'revoke_reason' => null, + ]); + + return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.'); + } + + public function cancel(Request $request, License $license): RedirectResponse + { + $this->authorise($request, $license); + abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); + abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422); + + try { + $this->paddle->cancelSubscriptionImmediately($license->external_subscription_id); + } catch (RuntimeException $exception) { + return back()->withErrors(['licence' => $exception->getMessage()]); + } + + $license->update([ + 'subscription_status' => 'canceled', + 'expires_at' => now(), + 'revoked_at' => now(), + 'revoke_reason' => 'Subscription canceled from licence portal', + 'cancel_scheduled_at' => null, + ]); + + return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.'); + } + + public function deactivate(Request $request, License $license, int $activation) + { + $this->authorise($request, $license); + $item = $license->activations()->whereKey($activation)->firstOrFail(); + if (! $item->deactivated_at) { + $item->update(['deactivated_at' => now()]); + } + + return back()->with('status', 'Mac deactivated. SonarDesk will detect this through the licensing API.'); + } + + public function resend(Request $request, License $license) + { + $this->authorise($request, $license); + $out = LicenseDeliveryOutbox::where('license_id', $license->id)->first(); + if (! $out) { + return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']); + } + + $plain = Crypt::decryptString($out->encrypted_license_key); + Mail::raw("Your SonarDesk licence key is:\n\n{$plain}\n\nKeep this key private.", fn ($m) => $m->to($request->user()->email)->subject('Your SonarDesk licence key')); + $out->update(['delivered_at' => now()]); + + return back()->with('status', 'Licence key sent to your verified email address.'); + } + + private function authorise(Request $request, License $license): void + { + abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404); + } + + private function professionalPriceFor(License $license): string + { + $paddle = config('sonardesk.paddle'); + $current = (string) $license->external_price_id; + + if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) { + return (string) ($paddle['professional_annual_price_id'] ?? ''); + } + + return (string) ($paddle['professional_monthly_price_id'] ?? ''); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Support/SupportController.php b/sonar-desk-licensing/app/Http/Controllers/Support/SupportController.php new file mode 100755 index 0000000..55d6a68 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Support/SupportController.php @@ -0,0 +1,92 @@ + [ + 'technical' => 'Technical support', + 'licensing' => 'Licence & activation', + 'billing' => 'Billing & subscription', + 'feature' => 'Feature request', + 'privacy' => 'Privacy / data request', + 'general' => 'General enquiry', + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + // Honeypot: bots tend to fill hidden fields. Return success without retaining spam. + if (filled($request->input('company_website'))) { + return back()->with('status', 'Your support request has been received.'); + } + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'email' => ['required', 'email:rfc', 'max:254'], + 'category' => ['required', 'in:technical,licensing,billing,feature,privacy,general'], + 'subject' => ['required', 'string', 'max:180'], + 'message' => ['required', 'string', 'min:10', 'max:10000'], + 'privacy_notice' => ['accepted'], + ]); + + $ticket = SupportTicket::create([ + 'user_id' => $request->user()?->id, + 'reference' => $this->reference(), + 'name' => $data['name'], + 'email' => strtolower($data['email']), + 'category' => $data['category'], + 'subject' => $data['subject'], + 'message' => $data['message'], + 'privacy_notice_accepted_at' => now(), + ]); + + $this->notifyTeam($ticket); + + Mail::raw( + "We received your SonarDesk support request.\n\nReference: {$ticket->reference}\nSubject: {$ticket->subject}\n\nWe will reply to this email address. Please keep the reference in any follow-up.", + fn ($mail) => $mail->to($ticket->email)->subject("SonarDesk support request {$ticket->reference}") + ); + + return redirect()->route('support')->with('status', "Support request {$ticket->reference} received. A confirmation has been emailed to you."); + } + + private function notifyTeam(SupportTicket $ticket): void + { + if (config('sonardesk.support.notify_admin_users', true)) { + User::query()->where('is_admin', true)->get()->each->notify(new NewSupportTicket($ticket)); + } + + $emails = collect(config('sonardesk.support.alert_emails', [])) + ->filter(fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique() + ->values(); + + if ($emails->isNotEmpty()) { + Notification::route('mail', $emails->all())->notify(new NewSupportTicket($ticket)); + } + } + + private function reference(): string + { + do { + $reference = 'RS-'.now()->format('ymd').'-'.Str::upper(Str::random(6)); + } while (SupportTicket::where('reference', $reference)->exists()); + + return $reference; + } +} diff --git a/sonar-desk-licensing/app/Http/Middleware/AdminOnly.php b/sonar-desk-licensing/app/Http/Middleware/AdminOnly.php new file mode 100755 index 0000000..e7cd3a6 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Middleware/AdminOnly.php @@ -0,0 +1,17 @@ +user()?->is_admin === true, 403); + + return $next($request); + } +} diff --git a/sonar-desk-licensing/app/Models/CmsPage.php b/sonar-desk-licensing/app/Models/CmsPage.php new file mode 100644 index 0000000..180a18e --- /dev/null +++ b/sonar-desk-licensing/app/Models/CmsPage.php @@ -0,0 +1,15 @@ + 'boolean', 'published_at' => 'datetime']; + } +} diff --git a/sonar-desk-licensing/app/Models/License.php b/sonar-desk-licensing/app/Models/License.php new file mode 100755 index 0000000..c3dcc56 --- /dev/null +++ b/sonar-desk-licensing/app/Models/License.php @@ -0,0 +1,36 @@ + 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime']; + + public function product(): BelongsTo + { + return $this->belongsTo(LicenseProduct::class, 'license_product_id'); + } + + public function activations(): HasMany + { + return $this->hasMany(LicenseActivation::class); + } + + public function getIsUsableAttribute(): bool + { + return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); + } +} diff --git a/sonar-desk-licensing/app/Models/LicenseActivation.php b/sonar-desk-licensing/app/Models/LicenseActivation.php new file mode 100755 index 0000000..8562d4b --- /dev/null +++ b/sonar-desk-licensing/app/Models/LicenseActivation.php @@ -0,0 +1,14 @@ + 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime']; +} diff --git a/sonar-desk-licensing/app/Models/LicenseDeliveryOutbox.php b/sonar-desk-licensing/app/Models/LicenseDeliveryOutbox.php new file mode 100755 index 0000000..e0af182 --- /dev/null +++ b/sonar-desk-licensing/app/Models/LicenseDeliveryOutbox.php @@ -0,0 +1,14 @@ + 'datetime']; +} diff --git a/sonar-desk-licensing/app/Models/LicenseProduct.php b/sonar-desk-licensing/app/Models/LicenseProduct.php new file mode 100755 index 0000000..85ac9f5 --- /dev/null +++ b/sonar-desk-licensing/app/Models/LicenseProduct.php @@ -0,0 +1,12 @@ + 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer']; +} diff --git a/sonar-desk-licensing/app/Models/LicenseWebhookEvent.php b/sonar-desk-licensing/app/Models/LicenseWebhookEvent.php new file mode 100755 index 0000000..38fea30 --- /dev/null +++ b/sonar-desk-licensing/app/Models/LicenseWebhookEvent.php @@ -0,0 +1,12 @@ + 'array', 'processed_at' => 'datetime']; +} diff --git a/sonar-desk-licensing/app/Models/SupportTicket.php b/sonar-desk-licensing/app/Models/SupportTicket.php new file mode 100755 index 0000000..ccbd655 --- /dev/null +++ b/sonar-desk-licensing/app/Models/SupportTicket.php @@ -0,0 +1,46 @@ + 'datetime', + 'resolved_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function prunable(): Builder + { + $days = max(30, (int) config('sonardesk.privacy.support_retention_days', 730)); + + return static::query() + ->whereNotNull('resolved_at') + ->where('resolved_at', '<=', now()->subDays($days)); + } +} diff --git a/sonar-desk-licensing/app/Models/User.php b/sonar-desk-licensing/app/Models/User.php new file mode 100755 index 0000000..ff6872c --- /dev/null +++ b/sonar-desk-licensing/app/Models/User.php @@ -0,0 +1,22 @@ + 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean']; + } +} diff --git a/sonar-desk-licensing/app/Notifications/NewSupportTicket.php b/sonar-desk-licensing/app/Notifications/NewSupportTicket.php new file mode 100755 index 0000000..704c05a --- /dev/null +++ b/sonar-desk-licensing/app/Notifications/NewSupportTicket.php @@ -0,0 +1,43 @@ +subject("New SonarDesk support request {$this->ticket->reference}") + ->greeting('New support request') + ->line("{$this->ticket->name} <{$this->ticket->email}>") + ->line("Category: {$this->ticket->category}") + ->line("Subject: {$this->ticket->subject}") + ->line($this->ticket->message) + ->action('Open support ticket', route('admin.support.show', $this->ticket)); + } + + public function toArray(object $notifiable): array + { + return [ + 'support_ticket_id' => $this->ticket->id, + 'reference' => $this->ticket->reference, + 'subject' => $this->ticket->subject, + 'email' => $this->ticket->email, + ]; + } +} diff --git a/sonar-desk-licensing/app/Providers/SonarDeskLicensingServiceProvider.php b/sonar-desk-licensing/app/Providers/SonarDeskLicensingServiceProvider.php new file mode 100755 index 0000000..3674573 --- /dev/null +++ b/sonar-desk-licensing/app/Providers/SonarDeskLicensingServiceProvider.php @@ -0,0 +1,26 @@ +by($request->ip()), + Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))), + ]; + }); + + RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5) + ->by(strtolower((string) $request->input('email')).'|'.$request->ip())); + + RateLimiter::for('registration', fn (Request $request) => Limit::perHour(5)->by($request->ip())); + } +} diff --git a/sonar-desk-licensing/app/Services/Licensing/LicenseDeliveryService.php b/sonar-desk-licensing/app/Services/Licensing/LicenseDeliveryService.php new file mode 100755 index 0000000..ecfd43a --- /dev/null +++ b/sonar-desk-licensing/app/Services/Licensing/LicenseDeliveryService.php @@ -0,0 +1,40 @@ +whereNull('delivered_at') + ->whereNotNull('recipient_email') + ->where('recipient_email', '!=', '') + ->oldest() + ->limit(max(1, min($limit, 100))) + ->get(); + + foreach ($pending as $outbox) { + $plain = Crypt::decryptString($outbox->encrypted_license_key); + $recipient = strtolower(trim((string) $outbox->recipient_email)); + if ($recipient === '') { + continue; + } + + Mail::raw( + "Thank you for choosing SonarDesk.\n\nYour licence key is:\n\n{$plain}\n\nOpen SonarDesk → Settings → Licence and enter this key to activate your Mac.\n\nKeep this key private. You can manage activations from the SonarDesk licence portal.", + fn ($message) => $message->to($recipient)->subject('Your SonarDesk licence key') + ); + + $outbox->update(['delivered_at' => now()]); + $delivered++; + } + + return $delivered; + } +} diff --git a/sonar-desk-licensing/app/Services/Licensing/LicenseIssuer.php b/sonar-desk-licensing/app/Services/Licensing/LicenseIssuer.php new file mode 100755 index 0000000..ec51713 --- /dev/null +++ b/sonar-desk-licensing/app/Services/Licensing/LicenseIssuer.php @@ -0,0 +1,46 @@ +where('external_order_id', $orderID)->first(); + if ($existing) { + return [$existing, null]; + } + } + $plain = LicenseKey::generate(); + $license = License::create([ + 'license_product_id' => $product->id, + 'license_key_hash' => LicenseKey::hash($plain), + 'license_key_last4' => substr(LicenseKey::normalize($plain), -4), + 'customer_name' => $name, + 'customer_email' => $email ? strtolower(trim($email)) : null, + 'edition' => $product->edition, + 'activation_limit' => $product->activation_limit, + 'max_major_version' => $product->max_major_version, + 'payment_provider' => $provider, + 'external_order_id' => $orderID, + 'external_customer_id' => $customerID, + ]); + if ($queueDelivery) { + LicenseDeliveryOutbox::updateOrCreate( + ['license_id' => $license->id], + ['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null] + ); + } + + return [$license, $plain]; + }); + } +} diff --git a/sonar-desk-licensing/app/Services/Licensing/LicenseKey.php b/sonar-desk-licensing/app/Services/Licensing/LicenseKey.php new file mode 100755 index 0000000..3e42361 --- /dev/null +++ b/sonar-desk-licensing/app/Services/Licensing/LicenseKey.php @@ -0,0 +1,27 @@ +privateSeed(); + $pair = sodium_crypto_sign_seed_keypair($seed); + $secret = sodium_crypto_sign_secretkey($pair); + + $payload = [ + 'customerEmail' => $license->customer_email, + 'customerName' => $license->customer_name, + 'edition' => $license->edition, + 'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'), + 'installationID' => strtolower($installationID), + 'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'), + 'licenseID' => strtolower((string) $license->id), + 'maxMajorVersion' => $license->max_major_version, + ]; + ksort($payload, SORT_STRING); + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + $sig = sodium_crypto_sign_detached($json, $secret); + + return 'RS3.'.self::b64url($json).'.'.self::b64url($sig); + } + + public function publicKeyBase64(): string + { + $pair = sodium_crypto_sign_seed_keypair($this->privateSeed()); + + return base64_encode(sodium_crypto_sign_publickey($pair)); + } + + private function privateSeed(): string + { + $path = config('sonardesk.private_key_path'); + if (! $path || ! is_file($path)) { + throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.'); + } + $decoded = base64_decode(trim((string) file_get_contents($path)), true); + if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) { + throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.'); + } + + return $decoded; + } + + private static function b64url(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/sonar-desk-licensing/app/Services/Licensing/LicenseTokenVerifier.php b/sonar-desk-licensing/app/Services/Licensing/LicenseTokenVerifier.php new file mode 100755 index 0000000..fa497fc --- /dev/null +++ b/sonar-desk-licensing/app/Services/Licensing/LicenseTokenVerifier.php @@ -0,0 +1,40 @@ +decode($parts[1]); + $signature = $this->decode($parts[2]); + $public = base64_decode((string) config('sonardesk.public_key'), true); + if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { + throw new RuntimeException('Server licence public key is invalid.'); + } + if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) { + throw new RuntimeException('Licence token signature is invalid.'); + } + $decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR); + + return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.'); + } + + private function decode(string $value): string + { + $value = strtr($value, '-_', '+/'); + $value .= str_repeat('=', (4 - strlen($value) % 4) % 4); + $out = base64_decode($value, true); + if ($out === false) { + throw new RuntimeException('Invalid Base64URL value.'); + } + + return $out; + } +} diff --git a/sonar-desk-licensing/app/Services/Payments/LemonSqueezyWebhookVerifier.php b/sonar-desk-licensing/app/Services/Payments/LemonSqueezyWebhookVerifier.php new file mode 100755 index 0000000..356a3e5 --- /dev/null +++ b/sonar-desk-licensing/app/Services/Payments/LemonSqueezyWebhookVerifier.php @@ -0,0 +1,22 @@ +header('X-Signature'); + if ($secret === '' || $signature === '') { + throw new RuntimeException('Lemon Squeezy webhook verification is not configured.'); + } + $expected = hash_hmac('sha256', $rawBody, $secret); + if (! hash_equals($expected, $signature)) { + throw new RuntimeException('Lemon Squeezy webhook signature is invalid.'); + } + } +} diff --git a/sonar-desk-licensing/app/Services/Payments/PaddleClient.php b/sonar-desk-licensing/app/Services/Payments/PaddleClient.php new file mode 100755 index 0000000..4dc7eb8 --- /dev/null +++ b/sonar-desk-licensing/app/Services/Payments/PaddleClient.php @@ -0,0 +1,82 @@ +request()->get('/customers/'.$customerID); + + if (! $response->successful()) { + return null; + } + $data = $response->json('data'); + + return is_array($data) ? $data : null; + } + + public function updateSubscriptionPrice(string $subscriptionID, string $priceID): array + { + $this->requireWritableConfiguration(); + + $response = $this->request()->patch('/subscriptions/'.$subscriptionID, [ + 'proration_billing_mode' => 'prorated_immediately', + 'items' => [[ + 'price_id' => $priceID, + 'quantity' => 1, + ]], + ]); + + return $this->subscriptionData($response, 'Paddle could not update the subscription.'); + } + + public function cancelSubscriptionImmediately(string $subscriptionID): array + { + $this->requireWritableConfiguration(); + + $response = $this->request()->post('/subscriptions/'.$subscriptionID.'/cancel', [ + 'effective_from' => 'immediately', + ]); + + return $this->subscriptionData($response, 'Paddle could not cancel the subscription.'); + } + + private function request() + { + return Http::baseUrl((string) config('sonardesk.paddle.api_url')) + ->withToken((string) config('sonardesk.paddle.api_key')) + ->acceptJson() + ->timeout(15); + } + + private function requireWritableConfiguration(): void + { + if (trim((string) config('sonardesk.paddle.api_key')) === '') { + throw new RuntimeException('Paddle subscription management is not configured.'); + } + } + + private function subscriptionData(Response $response, string $fallback): array + { + if (! $response->successful()) { + $detail = trim((string) ($response->json('error.detail') ?? '')); + throw new RuntimeException($detail !== '' ? $detail : $fallback); + } + + $data = $response->json('data'); + if (! is_array($data)) { + throw new RuntimeException($fallback); + } + + return $data; + } +} diff --git a/sonar-desk-licensing/app/Services/Payments/PaddleWebhookVerifier.php b/sonar-desk-licensing/app/Services/Payments/PaddleWebhookVerifier.php new file mode 100755 index 0000000..6b862a2 --- /dev/null +++ b/sonar-desk-licensing/app/Services/Payments/PaddleWebhookVerifier.php @@ -0,0 +1,38 @@ +header('Paddle-Signature'); + if ($secret === '' || $header === '') { + throw new RuntimeException('Paddle webhook verification is not configured.'); + } + $parts = []; + foreach (explode(';', $header) as $part) { + [$k,$v] = array_pad(explode('=', trim($part), 2), 2, null); + if ($k && $v) { + $parts[$k][] = $v; + } + } + $ts = isset($parts['ts'][0]) ? (int) $parts['ts'][0] : 0; + $signatures = $parts['h1'] ?? []; + $tolerance = max(1, (int) config('sonardesk.paddle.timestamp_tolerance', 300)); + if (! $ts || abs(time() - $ts) > $tolerance) { + throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.'); + } + $expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret); + foreach ($signatures as $signature) { + if (hash_equals($expected, $signature)) { + return; + } + } + throw new RuntimeException('Paddle webhook signature is invalid.'); + } +} diff --git a/sonar-desk-licensing/app/Services/Payments/PurchaseProvisioner.php b/sonar-desk-licensing/app/Services/Payments/PurchaseProvisioner.php new file mode 100755 index 0000000..2cc9680 --- /dev/null +++ b/sonar-desk-licensing/app/Services/Payments/PurchaseProvisioner.php @@ -0,0 +1,246 @@ +provisionPaddle($event); + + return; + } + + if ($provider === 'lemon-squeezy') { + $this->provisionLemonSqueezy($event); + + return; + } + + throw new RuntimeException('Unsupported payment provider.'); + } + + private function provisionPaddle(array $event): void + { + $type = (string) ($event['event_type'] ?? ''); + $data = $event['data'] ?? []; + + if (str_starts_with($type, 'subscription.')) { + $this->syncPaddleSubscription($type, $data); + + return; + } + + if ($type === 'transaction.completed') { + $this->syncPaddleTransaction($data); + + return; + } + + if ($type === 'transaction.refunded') { + $transactionID = (string) ($data['id'] ?? ''); + if ($transactionID !== '') { + License::where('payment_provider', 'paddle') + ->where('external_order_id', $transactionID) + ->whereNull('external_subscription_id') + ->whereNull('revoked_at') + ->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded']); + } + } + } + + private function syncPaddleSubscription(string $type, array $data): void + { + $subscriptionID = (string) ($data['id'] ?? ''); + if ($subscriptionID === '') { + throw new RuntimeException('Paddle subscription webhook has no subscription ID.'); + } + + $status = (string) ($data['status'] ?? ''); + $item = $this->firstRecurringItem($data['items'] ?? []); + $price = $item['price'] ?? []; + $priceID = (string) ($price['id'] ?? $item['price_id'] ?? ''); + $productID = (string) ($price['product_id'] ?? $item['product_id'] ?? ''); + + $license = License::where('payment_provider', 'paddle') + ->where('external_subscription_id', $subscriptionID) + ->first(); + + if (! $license) { + $product = $this->mappedProduct('paddle', $priceID, $productID); + $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; + $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)); + [$license] = $this->issuer->issue( + $product, + $this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null), + $this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null), + 'paddle', + $this->nullableString($data['transaction_id'] ?? null), + $this->nullableString($data['customer_id'] ?? null), + true, + ); + $license->external_subscription_id = $subscriptionID; + } elseif ($priceID !== '' && $priceID !== $license->external_price_id) { + // Upgrades/downgrades change the entitlement attached to the existing key. + $product = $this->mappedProduct('paddle', $priceID, $productID); + $license->license_product_id = $product->id; + $license->edition = $product->edition; + $license->activation_limit = $product->activation_limit; + $license->max_major_version = $product->max_major_version; + } + + $periodEnd = $data['current_billing_period']['ends_at'] ?? null; + $scheduled = $data['scheduled_change'] ?? null; + $cancelAt = is_array($scheduled) && ($scheduled['action'] ?? null) === 'cancel' + ? ($scheduled['effective_at'] ?? null) + : null; + + $license->external_customer_id = $this->nullableString($data['customer_id'] ?? null) ?? $license->external_customer_id; + $license->external_price_id = $priceID !== '' ? $priceID : $license->external_price_id; + $license->subscription_status = $status !== '' ? $status : $license->subscription_status; + $license->current_period_ends_at = $periodEnd ? Carbon::parse($periodEnd) : $license->current_period_ends_at; + $license->cancel_scheduled_at = $cancelAt ? Carbon::parse($cancelAt) : null; + + if (in_array($status, ['active', 'trialing'], true)) { + $license->revoked_at = null; + $license->revoke_reason = null; + // Trialing/active subscriptions remain valid through the current period. + $license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null; + } elseif ($status === 'past_due') { + // Do not instantly kill a licence on the first failed renewal. Preserve the + // period Paddle says is current; a later canceled event ends entitlement. + $license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : ($license->expires_at ?? now()->addDays(7)); + } elseif (in_array($status, ['canceled', 'paused'], true)) { + $license->expires_at = now(); + $license->revoked_at = now(); + $license->revoke_reason = $status === 'paused' ? 'Subscription paused' : 'Subscription canceled'; + } + + $license->save(); + } + + private function syncPaddleTransaction(array $data): void + { + $subscriptionID = $this->nullableString($data['subscription_id'] ?? null); + $transactionID = (string) ($data['id'] ?? ''); + + // Recurring purchases are provisioned from subscription events because those + // events contain the authoritative trial/status/current-period state. + if ($subscriptionID) { + License::where('payment_provider', 'paddle') + ->where('external_subscription_id', $subscriptionID) + ->whereNull('external_order_id') + ->update(['external_order_id' => $transactionID ?: null]); + + return; + } + + // Keep support for any future one-time Paddle licence product. + $item = $data['items'][0] ?? []; + $price = $item['price'] ?? []; + $priceID = (string) ($price['id'] ?? $item['price_id'] ?? ''); + $productID = (string) ($price['product_id'] ?? $item['product_id'] ?? ''); + $product = $this->mappedProduct('paddle', $priceID, $productID); + $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; + $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)); + + $this->issuer->issue( + $product, + $this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null), + $this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null), + 'paddle', + $transactionID, + $this->nullableString($data['customer_id'] ?? null), + true, + ); + } + + private function provisionLemonSqueezy(array $event): void + { + $type = (string) ($event['meta']['event_name'] ?? ''); + $attributes = $event['data']['attributes'] ?? []; + $order = (string) ($event['data']['id'] ?? $attributes['order_id'] ?? ''); + + if (in_array($type, ['order_refunded', 'subscription_expired'], true)) { + License::where('payment_provider', 'lemon-squeezy') + ->where('external_order_id', $order) + ->whereNull('revoked_at') + ->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']); + + return; + } + + if (! in_array($type, ['order_created', 'subscription_created'], true)) { + return; + } + + $product = $this->mappedProduct( + 'lemon-squeezy', + (string) ($attributes['variant_id'] ?? ''), + (string) ($attributes['product_id'] ?? ''), + ); + + $this->issuer->issue( + $product, + $this->nullableString($attributes['user_name'] ?? null), + $this->nullableString($attributes['user_email'] ?? null), + 'lemon-squeezy', + $order, + $this->nullableString($attributes['customer_id'] ?? null), + true, + ); + } + + private function mappedProduct(string $provider, string $priceID, string $productID): LicenseProduct + { + // Price IDs are the authoritative mapping because monthly and annual prices + // intentionally share the same Paddle product ID. + if ($priceID !== '') { + $product = LicenseProduct::where('payment_provider', $provider) + ->where('external_price_id', $priceID) + ->where('active', true) + ->first(); + } else { + $product = LicenseProduct::where('payment_provider', $provider) + ->where('external_product_id', $productID) + ->where('active', true) + ->first(); + } + + if (! $product) { + throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}."); + } + + return $product; + } + + private function firstRecurringItem(array $items): array + { + foreach ($items as $item) { + if (($item['recurring'] ?? true) === true) { + return $item; + } + } + + return $items[0] ?? []; + } + + private function nullableString(mixed $value): ?string + { + if (! is_scalar($value)) { + return null; + } + $value = trim((string) $value); + + return $value === '' ? null : $value; + } +} diff --git a/sonar-desk-licensing/app/Services/Payments/WebhookVerifier.php b/sonar-desk-licensing/app/Services/Payments/WebhookVerifier.php new file mode 100755 index 0000000..984c28e --- /dev/null +++ b/sonar-desk-licensing/app/Services/Payments/WebhookVerifier.php @@ -0,0 +1,10 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/sonar-desk-licensing/bootstrap/app.php b/sonar-desk-licensing/bootstrap/app.php new file mode 100755 index 0000000..5304386 --- /dev/null +++ b/sonar-desk-licensing/bootstrap/app.php @@ -0,0 +1,22 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + // + }) + ->withExceptions(function (Exceptions $exceptions): void { + $exceptions->shouldRenderJsonWhen( + fn (Request $request) => $request->is('api/*') || $request->expectsJson(), + ); + })->create(); diff --git a/sonar-desk-licensing/bootstrap/cache/.gitignore b/sonar-desk-licensing/bootstrap/cache/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/sonar-desk-licensing/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/sonar-desk-licensing/bootstrap/providers.php b/sonar-desk-licensing/bootstrap/providers.php new file mode 100755 index 0000000..cfe2c04 --- /dev/null +++ b/sonar-desk-licensing/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2026-09-06T14:11:38+00:00" + }, + { + "name": "deployer/deployer", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/deployphp/deployer.git", + "reference": "199273500b4f14ff5eafd76bea524e5d44bcaff3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/deployphp/deployer/zipball/199273500b4f14ff5eafd76bea524e5d44bcaff3", + "reference": "199273500b4f14ff5eafd76bea524e5d44bcaff3", + "shasum": "" + }, + "require": { + "maml/maml": "^3.2", + "php": "^8.3", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/process": "^7.4.0 || ^8.0.0", + "symfony/yaml": "^7.4.0 || ^8.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.68", + "phpstan/phpstan": "^2.1", + "phpunit/php-code-coverage": "^12.0", + "phpunit/phpunit": "^12.5" + }, + "bin": [ + "bin/dep" + ], + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Support/helpers.php" + ], + "psr-4": { + "Deployer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Anton Medvedev", + "email": "anton@medv.io" + } + ], + "description": "Deployment Tool", + "homepage": "https://deployer.org", + "support": { + "docs": "https://deployer.org/docs", + "issues": "https://github.com/deployphp/deployer/issues", + "source": "https://github.com/deployphp/deployer" + }, + "funding": [ + { + "url": "https://github.com/sponsors/antonmedv", + "type": "github" + } + ], + "time": "2026-05-18T16:59:34+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:06:52+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "8.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/93939470950a9b11e2e84204166ef5e048c55fe4", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^3.0.2", + "guzzlehttp/psr7": "^3.1", + "php": "^7.4 || ^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "4.0.1", + "guzzlehttp/test-server": "^1.0.1", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/8.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-09-06T13:55:09+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T10:00:26+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-factory-implementation": "1.1", + "psr/http-message-implementation": "2.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "php-http/psr7-integration-tests": "^1.5.1", + "phpunit/phpunit": "^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/3.1.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T11:02:13+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/7a466ad606491eb6528c717482f7cca77f1851f3", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34", + "uri-template/tests": "1.0.2" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-08-24T17:13:02+00:00" + }, + { + "name": "laravel/framework", + "version": "v13.31.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "7c75fbf93f91fa077d3df1c820cc14f4e59a9774" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/7c75fbf93f91fa077d3df1c820cc14f4e59a9774", + "reference": "7c75fbf93f91fa077d3df1c820cc14f4e59a9774", + "shasum": "" + }, + "require": { + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.9 || ^3.0", + "guzzlehttp/uri-template": "^1.0 || ^2.0", + "laravel/prompts": "^0.3.11", + "laravel/serializable-closure": "^2.0.10", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.10", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/http-message": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/image": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "intervention/image": "^4.0", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^11.0.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "2.6.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "intervention/image": "Required to use the image processing features (^4.0).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-09-08T14:22:55+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.24", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.24" + }, + "time": "2026-08-20T12:55:36+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.16", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-08-18T20:28:54+00:00" + }, + { + "name": "laravel/tinker", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "4faba77764bd33411735936acdf30446d058c78b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", + "shasum": "" + }, + "require": { + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5|^11.5" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v3.0.2" + }, + "time": "2026-03-17T14:54:13+00:00" + }, + { + "name": "league/commonmark", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e", + "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.11-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-09-07T13:44:26+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.36.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" + }, + "time": "2026-09-02T08:00:27+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" + }, + "time": "2026-08-12T13:29:21+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "maml/maml", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/maml-dev/maml-php.git", + "reference": "f11cdff4325bf399f23c33fc1995450e40ac0854" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maml-dev/maml-php/zipball/f11cdff4325bf399f23c33fc1995450e40ac0854", + "reference": "f11cdff4325bf399f23c33fc1995450e40ac0854", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.94", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Maml\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Anton Medvedev", + "email": "anton@medv.io" + } + ], + "description": "MAML Parser", + "homepage": "https://maml.dev", + "keywords": [ + "config", + "configuration", + "maml", + "parser", + "serializer" + ], + "support": { + "issues": "https://github.com/maml-dev/maml-php/issues", + "source": "https://github.com/maml-dev/maml-php/tree/v3.2.0" + }, + "time": "2026-04-12T16:50:36+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.11.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "147f303310f06334f03f409e49d7ad1e275ff05a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/147f303310f06334f03f409e49d7ad1e275ff05a", + "reference": "147f303310f06334f03f409e49d7ad1e275ff05a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.11.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-09-02T12:39:56+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.13.2", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-08-08T11:40:35+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.6", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "c54350438cd6914616f790a49cb424605f421562" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.6" + }, + "time": "2026-08-16T21:58:41+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2026-08-24T00:54:40+00:00" + }, + { + "name": "predis/predis", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/predis/predis.git", + "reference": "2ff20c08bb63697245ffee3f198d1673086c302d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/predis/predis/zipball/2ff20c08bb63697245ffee3f198d1673086c302d", + "reference": "2ff20c08bb63697245ffee3f198d1673086c302d", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0|^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpcov": "^6.0 || ^8.0", + "phpunit/phpunit": "^8.0 || ~9.4.4" + }, + "suggest": { + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Till Krüss", + "homepage": "https://till.im", + "role": "Maintainer" + } + ], + "description": "A flexible and feature-complete Redis/Valkey client for PHP.", + "homepage": "http://github.com/predis/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2026-08-14T23:07:56+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.3", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "shasum": "" + }, + "require": { + "brick/math": ">=0.8.16 <=0.18", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.3" + }, + "time": "2026-06-18T03:57:49+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:18:37+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T12:09:28+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:52+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T21:24:29+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584", + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "bf328d82105831db3e409195db0540ff57f27c80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80", + "reference": "bf328d82105831db3e409195db0540ff57f27c80", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-22T09:04:42+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-24T10:51:20+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php82", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-02T13:42:24+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "058d17fc284cce14efb2385783b55014a461b176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", + "reference": "058d17fc284cce14efb2385783b55014a461b176", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-17T13:12:36+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:33:02+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-11T07:38:58+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:52+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T00:47:26+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.2", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-08-24T18:07:49+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-dom": "*", + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" + }, + "time": "2026-03-17T11:56:53+00:00" + }, + { + "name": "laravel/agent-detector", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/agent-detector.git", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/agent-detector/zipball/90694b9256099591cf9e55d08c18ba7a00bf099f", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f", + "shasum": "" + }, + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "laravel/pint": "^1.24.0", + "pestphp/pest": "^3.8.5|^4.1.0", + "pestphp/pest-plugin-type-coverage": "^3.0|^4.0.2", + "phpstan/phpstan": "^2.1.26", + "rector/rector": "^2.1.7", + "symfony/var-dumper": "^7.3.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Laravel\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Detect if code is running in an AI agent or automated development environment", + "homepage": "https://github.com/laravel/agent-detector", + "keywords": [ + "Agent", + "ai", + "automation", + "claude", + "cursor", + "detection", + "devin", + "php" + ], + "support": { + "issues": "https://github.com/laravel/agent-detector/issues", + "source": "https://github.com/laravel/agent-detector" + }, + "time": "2026-04-29T18:32:34+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pao", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/pao.git", + "reference": "620424f1ce744d96937fb6be60a94793e79b67ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pao/zipball/620424f1ce744d96937fb6be60a94793e79b67ad", + "reference": "620424f1ce744d96937fb6be60a94793e79b67ad", + "shasum": "" + }, + "require": { + "laravel/agent-detector": "^2.0.2", + "php": "^8.3" + }, + "conflict": { + "laravel/framework": "<12.0.0", + "nunomaduro/collision": "<8.9.3", + "pestphp/pest": "<4.6.3 || >=6.0.0", + "phpunit/phpunit": "<12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.20.0", + "laravel/pint": "^1.30.5", + "orchestra/testbench": "^10.11.0 || ^11.2.0", + "pestphp/pest": "^4.7.8 || ^5.1.4", + "pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.2", + "phpstan/phpstan": "^2.2.13", + "rector/rector": "^2.6.6", + "symfony/process": "^7.4.13 || ^8.1.6", + "symfony/var-dumper": "^7.4.15 || ^8.1.6" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Laravel\\Pao\\Drivers\\Pest\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Laravel\\Pao\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Laravel\\Pao\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Agent-optimized output for PHP testing tools", + "keywords": [ + "Agent", + "PHPStan", + "ai", + "dev", + "paratest", + "pest", + "php", + "phpunit", + "rector", + "testing" + ], + "support": { + "issues": "https://github.com/laravel/pao/issues", + "source": "https://github.com/laravel/pao" + }, + "time": "2026-09-07T14:48:06+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.31.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "b136cebaf0b3f7d288bdafad5088858d082580aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/b136cebaf0b3f7d288bdafad5088858d082580aa", + "reference": "b136cebaf0b3f7d288bdafad5088858d082580aa", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.3.0" + }, + "require-dev": { + "composer/semver": "^3.4.4", + "friendsofphp/php-cs-fixer": "^3.95.25", + "illuminate/view": "^13.31.0", + "larastan/larastan": "^3.11.0", + "laravel-zero/framework": "^13.0.0", + "laravel/agent-detector": "^2.0.2", + "laravel/prompts": "^0.3.24", + "mockery/mockery": "^1.6.15", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^4.7.8" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-09-08T17:10:00+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.15", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0 || ^3.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2026-08-19T19:37:52+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.5", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.14 || ^8.1.1" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-07-15T19:09:14+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "186dab580576598076de6818596d12b61801880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.28" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-06-01T13:24:19+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:40:53+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.34", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f", + "reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.34" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-08-27T08:38:28+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:35:54+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.3" + }, + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/sonar-desk-licensing/config/sonardesk.php b/sonar-desk-licensing/config/sonardesk.php new file mode 100755 index 0000000..55987ab --- /dev/null +++ b/sonar-desk-licensing/config/sonardesk.php @@ -0,0 +1,46 @@ + env('SONARDESK_PRODUCT_CODE', 'sonardesk'), + 'private_key_path' => env('SONARDESK_LICENSE_PRIVATE_KEY_PATH'), + 'public_key' => env('SONARDESK_LICENSE_PUBLIC_KEY'), + 'default_edition' => env('SONARDESK_DEFAULT_EDITION', 'Professional'), + 'default_max_major_version' => (int) env('SONARDESK_DEFAULT_MAX_MAJOR_VERSION', 1), + 'activation_rate_limit' => env('SONARDESK_ACTIVATION_RATE_LIMIT', '12,1'), + 'purchase_url' => env('SONARDESK_PURCHASE_URL'), + 'portal_url' => env('SONARDESK_PORTAL_URL'), + 'support_url' => env('SONARDESK_SUPPORT_URL'), + + 'legal' => [ + 'controller_name' => env('SONARDESK_LEGAL_NAME', 'SonarDesk'), + 'privacy_email' => env('SONARDESK_PRIVACY_EMAIL', env('MAIL_FROM_ADDRESS')), + 'support_email' => env('SONARDESK_SUPPORT_EMAIL', env('MAIL_FROM_ADDRESS')), + 'country' => env('SONARDESK_LEGAL_COUNTRY', 'United Kingdom'), + ], + 'privacy' => [ + 'policy_version' => env('SONARDESK_PRIVACY_VERSION', '2026-09-09'), + 'cookie_version' => env('SONARDESK_COOKIE_VERSION', '1'), + 'support_retention_days' => (int) env('SONARDESK_SUPPORT_RETENTION_DAYS', 730), + ], + 'support' => [ + 'alert_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('SONARDESK_SUPPORT_ALERT_EMAILS', ''))))), + 'notify_admin_users' => filter_var(env('SONARDESK_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL), + ], + 'paddle' => [ + 'api_key' => env('PADDLE_API_KEY'), + 'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'), + 'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'), + 'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'), + 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), + 'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300), + 'personal_product_id' => env('PADDLE_PERSONAL_PRODUCT_ID'), + 'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'), + 'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'), + 'professional_product_id' => env('PADDLE_PROFESSIONAL_PRODUCT_ID'), + 'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'), + 'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'), + ], + 'lemon_squeezy' => [ + 'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'), + ], +]; diff --git a/sonar-desk-licensing/database/database.sqlite b/sonar-desk-licensing/database/database.sqlite new file mode 100755 index 0000000..379c23d Binary files /dev/null and b/sonar-desk-licensing/database/database.sqlite differ diff --git a/sonar-desk-licensing/database/migrations/2026_09_08_193343_create_sessions_table.php b/sonar-desk-licensing/database/migrations/2026_09_08_193343_create_sessions_table.php new file mode 100755 index 0000000..f60625b --- /dev/null +++ b/sonar-desk-licensing/database/migrations/2026_09_08_193343_create_sessions_table.php @@ -0,0 +1,31 @@ +string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/sonar-desk-licensing/database/migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php b/sonar-desk-licensing/database/migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php new file mode 100755 index 0000000..babc7a3 --- /dev/null +++ b/sonar-desk-licensing/database/migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php @@ -0,0 +1,92 @@ +id(); + $table->string('code')->unique(); + $table->string('name'); + $table->string('edition')->default('Professional'); + $table->unsignedInteger('activation_limit')->default(1); + $table->unsignedInteger('max_major_version')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_product_id')->nullable(); + $table->string('external_price_id')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + $table->unique(['payment_provider', 'external_price_id']); + }); + + Schema::create('licenses', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignId('license_product_id')->constrained('license_products'); + $table->string('license_key_hash', 64)->unique(); + $table->string('license_key_last4', 4); + $table->string('customer_name')->nullable(); + $table->string('customer_email')->nullable()->index(); + $table->string('edition'); + $table->unsignedInteger('activation_limit'); + $table->unsignedInteger('max_major_version')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->string('revoke_reason')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_order_id')->nullable(); + $table->string('external_customer_id')->nullable(); + $table->timestamps(); + $table->unique(['payment_provider', 'external_order_id']); + }); + + Schema::create('license_delivery_outbox', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id')->unique(); + $table->text('encrypted_license_key'); + $table->string('recipient_email')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamps(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + }); + + Schema::create('license_activations', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id'); + $table->uuid('installation_id'); + $table->string('platform', 32)->nullable(); + $table->string('app_version', 32)->nullable(); + $table->timestamp('activated_at'); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('deactivated_at')->nullable(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + $table->unique(['license_id', 'installation_id']); + $table->index(['license_id', 'deactivated_at']); + }); + + Schema::create('license_webhook_events', function (Blueprint $table) { + $table->id(); + $table->string('provider'); + $table->string('external_event_id'); + $table->string('event_type'); + $table->string('payload_sha256', 64); + $table->json('payload'); + $table->timestamp('processed_at')->nullable(); + $table->text('processing_error')->nullable(); + $table->timestamps(); + $table->unique(['provider', 'external_event_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('license_webhook_events'); + Schema::dropIfExists('license_activations'); + Schema::dropIfExists('license_delivery_outbox'); + Schema::dropIfExists('licenses'); + Schema::dropIfExists('license_products'); + } +}; diff --git a/sonar-desk-licensing/database/migrations/2026_09_08_200758_create_cache_table.php b/sonar-desk-licensing/database/migrations/2026_09_08_200758_create_cache_table.php new file mode 100755 index 0000000..06dc7a5 --- /dev/null +++ b/sonar-desk-licensing/database/migrations/2026_09_08_200758_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/sonar-desk-licensing/database/migrations/2026_09_08_205000_create_sonardesk_auth_tables.php b/sonar-desk-licensing/database/migrations/2026_09_08_205000_create_sonardesk_auth_tables.php new file mode 100755 index 0000000..b058933 --- /dev/null +++ b/sonar-desk-licensing/database/migrations/2026_09_08_205000_create_sonardesk_auth_tables.php @@ -0,0 +1,37 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('password_reset_tokens')) { + Schema::create('password_reset_tokens', function (Blueprint $table): void { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('users'); + } +}; diff --git a/sonar-desk-licensing/database/migrations/2026_09_08_210000_add_sonardesk_portal_fields.php b/sonar-desk-licensing/database/migrations/2026_09_08_210000_add_sonardesk_portal_fields.php new file mode 100755 index 0000000..b302e12 --- /dev/null +++ b/sonar-desk-licensing/database/migrations/2026_09_08_210000_add_sonardesk_portal_fields.php @@ -0,0 +1,30 @@ + $table->boolean('is_admin')->default(false)->index()); + } + + if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id')); + } + } + + public function down(): void + { + if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name')); + } + + if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) { + Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin')); + } + } +}; diff --git a/sonar-desk-licensing/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php b/sonar-desk-licensing/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php new file mode 100755 index 0000000..ef60b76 --- /dev/null +++ b/sonar-desk-licensing/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php @@ -0,0 +1,34 @@ +string('external_subscription_id')->nullable()->after('external_customer_id'); + $table->string('external_price_id')->nullable()->after('external_subscription_id'); + $table->string('subscription_status')->nullable()->after('external_price_id'); + $table->timestamp('current_period_ends_at')->nullable()->after('subscription_status'); + $table->timestamp('cancel_scheduled_at')->nullable()->after('current_period_ends_at'); + $table->unique(['payment_provider', 'external_subscription_id']); + }); + } + + public function down(): void + { + Schema::table('licenses', function (Blueprint $table) { + $table->dropUnique(['payment_provider', 'external_subscription_id']); + $table->dropColumn([ + 'external_subscription_id', + 'external_price_id', + 'subscription_status', + 'current_period_ends_at', + 'cancel_scheduled_at', + ]); + }); + } +}; diff --git a/sonar-desk-licensing/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php b/sonar-desk-licensing/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php new file mode 100755 index 0000000..a6223e3 --- /dev/null +++ b/sonar-desk-licensing/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->string('reference', 24)->unique(); + $table->string('name', 120); + $table->string('email', 254)->index(); + $table->string('category', 40)->default('general'); + $table->string('subject', 180); + $table->text('message'); + $table->string('status', 20)->default('new')->index(); + $table->string('priority', 20)->default('normal'); + $table->timestamp('privacy_notice_accepted_at'); + $table->timestamp('resolved_at')->nullable()->index(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('notifications')) { + Schema::create('notifications', function (Blueprint $table): void { + $table->uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('support_tickets'); + Schema::dropIfExists('notifications'); + } +}; diff --git a/sonar-desk-licensing/database/migrations/2026_09_29_100000_create_cms_pages_table.php b/sonar-desk-licensing/database/migrations/2026_09_29_100000_create_cms_pages_table.php new file mode 100644 index 0000000..dee3e79 --- /dev/null +++ b/sonar-desk-licensing/database/migrations/2026_09_29_100000_create_cms_pages_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('excerpt')->nullable(); + $table->longText('body'); + $table->string('meta_title')->nullable(); + $table->string('meta_description', 320)->nullable(); + $table->boolean('is_published')->default(false)->index(); + $table->timestamp('published_at')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void { Schema::dropIfExists('cms_pages'); } +}; diff --git a/sonar-desk-licensing/database/seeders/PaddleCatalogSeeder.php b/sonar-desk-licensing/database/seeders/PaddleCatalogSeeder.php new file mode 100755 index 0000000..29a2390 --- /dev/null +++ b/sonar-desk-licensing/database/seeders/PaddleCatalogSeeder.php @@ -0,0 +1,29 @@ + 'personal-monthly', 'name' => 'SonarDesk Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.personal_product_id'), 'external_price_id' => config('sonardesk.paddle.personal_monthly_price_id')], + ['code' => 'personal-annual', 'name' => 'SonarDesk Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.personal_product_id'), 'external_price_id' => config('sonardesk.paddle.personal_annual_price_id')], + ['code' => 'professional-monthly', 'name' => 'SonarDesk Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.professional_product_id'), 'external_price_id' => config('sonardesk.paddle.professional_monthly_price_id')], + ['code' => 'professional-annual', 'name' => 'SonarDesk Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.professional_product_id'), 'external_price_id' => config('sonardesk.paddle.professional_annual_price_id')], + ]; + + foreach ($products as $data) { + if (! $data['external_product_id'] || ! $data['external_price_id']) { + continue; + } + LicenseProduct::updateOrCreate( + ['code' => $data['code']], + $data + ['payment_provider' => 'paddle', 'max_major_version' => config('sonardesk.default_max_major_version'), 'active' => true] + ); + } + } +} diff --git a/sonar-desk-licensing/deploy.php b/sonar-desk-licensing/deploy.php new file mode 100755 index 0000000..f55b6e0 --- /dev/null +++ b/sonar-desk-licensing/deploy.php @@ -0,0 +1,61 @@ +set('hostname', 'sonardesk.absdev.net') + ->set('remote_user', 'mag') + ->set('branch', 'main') + ->set('deploy_path', '/var/www/sonardesk'); + +host('stage') + ->set('hostname', 'sonardesk.absdev.net') + ->set('remote_user', 'mag') + ->set('branch', 'stage') + ->set('deploy_path', '/var/www/sonardesk_dev'); + +task('build', function (): void { + run('export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use {{node_version}} && cd {{release_path}} && npm ci --no-audit --no-fund'); + run('export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use {{node_version}} && cd {{release_path}} && npm run build'); +}); +task('queue:restart', function (): void { + run('cd {{release_path}} && php artisan queue:restart'); +}); +task('deploy:write_revision_file', function (): void { + run('cd {{release_path}} && echo "{{branch}}" > BRANCH'); + run('cd {{release_path}} && date -u +"%Y-%m-%dT%H:%M:%SZ" > DEPLOYED_AT'); +}); +task('artisan:clear', function (): void { + run('cd {{release_path}} && php artisan view:clear'); + run('cd {{release_path}} && php artisan optimize:clear'); + run('cd {{release_path}} && php artisan config:clear'); + run('cd {{release_path}} && php artisan route:clear'); +}); +task('services:restart', function (): void { + run('sudo -n nginx -t'); + run('sudo -n systemctl reload nginx'); + run('sudo -n systemctl restart php8.3-fpm'); +}); + +after('deploy:update_code', 'deploy:write_revision_file'); +after('deploy:vendors', 'build'); +after('deploy:symlink', 'artisan:clear'); +after('deploy:symlink', 'queue:restart'); +after('deploy:failed', 'deploy:unlock'); +after('deploy:symlink', 'services:restart'); diff --git a/sonar-desk-licensing/docs/PADDLE_SUBSCRIPTIONS.md b/sonar-desk-licensing/docs/PADDLE_SUBSCRIPTIONS.md new file mode 100755 index 0000000..bb6826d --- /dev/null +++ b/sonar-desk-licensing/docs/PADDLE_SUBSCRIPTIONS.md @@ -0,0 +1,71 @@ +# Paddle subscription integration + +## Sandbox configuration + +Keep the Paddle API key and webhook secret server-side only. Do not put either value in the macOS application or commit them to Git. + +Required environment variables: + +```dotenv +PADDLE_API_KEY=pdl_sdbx_apikey_... +PADDLE_API_URL=https://sandbox-api.paddle.com +PADDLE_WEBHOOK_SECRET=... + +PADDLE_PERSONAL_PRODUCT_ID=pro_01m22q4c3vt7yvtv3h3zw1jbn0 +PADDLE_PERSONAL_MONTHLY_PRICE_ID=pri_01m22q4cjx177krcpvdph7e2vy +PADDLE_PERSONAL_ANNUAL_PRICE_ID=pri_01m22q4cs9sr5gshxp6j56nvmh +PADDLE_PROFESSIONAL_PRODUCT_ID=pro_01m22q4d0s6y1557wsq9s7xw8n +PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=pri_01m22q4d6h1yvr1frawtx51gy8 +PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=pri_01m22q4dcndcdbzc20txadaz52 +``` + +Then run: + +```bash +php artisan migrate +php artisan db:seed --class=Database\\Seeders\\PaddleCatalogSeeder +php artisan config:clear +``` + +The seeder maps the four Paddle price IDs to licensing entitlements. Personal permits one active Mac; Professional permits three. + +## Paddle notification destination + +Configure the sandbox notification destination to POST to: + +`https://YOUR-HOST/api/v1/webhooks/paddle` + +Copy the destination endpoint secret into `PADDLE_WEBHOOK_SECRET`. + +Subscribe to at least: + +- `subscription.created` +- `subscription.updated` +- `subscription.activated` +- `subscription.trialing` +- `subscription.canceled` +- `subscription.paused` +- `subscription.resumed` +- `subscription.past_due` +- `transaction.completed` +- `transaction.refunded` + +## Entitlement behavior + +- Trialing and active subscriptions are usable. +- A scheduled end-of-period cancellation stays usable until Paddle changes the subscription to canceled. +- Upgrades/downgrades update the entitlement on the existing licence key. +- Canceled or paused subscriptions revoke the licence. +- Past-due subscriptions retain access through Paddle's current billing period rather than being revoked on the first failed payment. +- Paddle customer details are fetched server-side when the webhook does not carry customer email/name in custom data. + +## Important + +The API key is required only on the licensing server. Use a sandbox key with the sandbox API URL. Live Paddle products/prices have different IDs and should be configured separately when moving to production. + + +## Hosted sandbox checkout + +The licensing site exposes `/buy`, which initializes Paddle.js with `PADDLE_CLIENT_SIDE_TOKEN` and opens an overlay checkout for the four configured price IDs. Set `PADDLE_ENVIRONMENT=sandbox` while testing and use a `test_...` client-side token. The token is intentionally browser-visible; never use `PADDLE_API_KEY` in frontend code. + +After a subscription webhook provisions a licence, the webhook controller makes a best-effort attempt to deliver pending licence keys by email. Delivery failures do not roll back the verified Paddle event; the encrypted outbox remains available for portal resend. diff --git a/sonar-desk-licensing/docs/PORTAL_LAUNCH_FRONTEND.md b/sonar-desk-licensing/docs/PORTAL_LAUNCH_FRONTEND.md new file mode 100755 index 0000000..bc2b00a --- /dev/null +++ b/sonar-desk-licensing/docs/PORTAL_LAUNCH_FRONTEND.md @@ -0,0 +1,24 @@ +# SonarDesk launch frontend + +The public `/` page is now a product-launch landing page using the same shared Laravel layout as checkout, support, authentication and the licence portal. + +## Favicon/device assets + +The website uses the existing SonarDesk app icon as the web brand source and includes: + +- multi-resolution `public/favicon.ico` +- 16x16, 32x32 and 48x48 browser favicons +- 180x180 Apple touch icon +- 192x192 and 512x512 Android/PWA icons +- Windows 150x150 tile icon and `browserconfig.xml` +- Safari pinned-tab mask icon +- `site.webmanifest` +- Open Graph/Twitter image metadata + +The shared `resources/views/layouts/app.blade.php` loads these assets on every website page. + +## Launch page + +`resources/views/portal/welcome.blade.php` contains the public product page: hero, native app preview, features, workflow, security positioning, Personal/Professional plan summary and trial calls-to-action. + +Non-production environments automatically emit `noindex,nofollow`; production emits normal index/follow metadata. diff --git a/sonar-desk-licensing/docs/rsync-studio-paddle-ids.env b/sonar-desk-licensing/docs/rsync-studio-paddle-ids.env new file mode 100755 index 0000000..87b3c5f --- /dev/null +++ b/sonar-desk-licensing/docs/rsync-studio-paddle-ids.env @@ -0,0 +1,7 @@ +PADDLE_PERSONAL_PRODUCT_ID=pro_01m22q4c3vt7yvtv3h3zw1jbn0 +PADDLE_PERSONAL_MONTHLY_PRICE_ID=pri_01m22q4cjx177krcpvdph7e2vy +PADDLE_PERSONAL_ANNUAL_PRICE_ID=pri_01m22q4cs9sr5gshxp6j56nvmh + +PADDLE_PROFESSIONAL_PRODUCT_ID=pro_01m22q4d0s6y1557wsq9s7xw8n +PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=pri_01m22q4d6h1yvr1frawtx51gy8 +PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=pri_01m22q4dcndcdbzc20txadaz52 diff --git a/sonar-desk-licensing/package-lock.json b/sonar-desk-licensing/package-lock.json new file mode 100755 index 0000000..0c8927f --- /dev/null +++ b/sonar-desk-licensing/package-lock.json @@ -0,0 +1,2354 @@ +{ + "name": "sonardesk-licensing", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "concurrently": "^10.0.3", + "laravel-vite-plugin": "^3.1", + "tailwindcss": "^4.0.0", + "vite": "^8.0.0" + }, + "optionalDependencies": { + "@laravel/multiplex": "^0.4.1" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", + "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@laravel/multiplex": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@laravel/multiplex/-/multiplex-0.4.3.tgz", + "integrity": "sha512-jJTZGgKl6sK0ETYmqiPqeAwmErvqjxgDRLfzqsmqiWiupc5dfjaoVVrrdXZwb6eGylSg99WwHLibCPJs8F0bGw==", + "license": "MIT", + "optional": true, + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "ansi-styles": "^6.2.3", + "commander": "^15.0.0", + "ink": "^7.1.0", + "react": "^19.2.7", + "string-width": "^8.2.2", + "wrap-ansi": "^10.0.0" + }, + "bin": { + "multiplex": "dist/cli.js" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "optional": true, + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "optional": true, + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "optional": true, + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/concurrently": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", + "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.0", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.3", + "auto-bind": "^5.0.1", + "chalk": "^5.6.2", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.45.1", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^9.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.2.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.5.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "react": ">=19.2.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "optional": true, + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz", + "integrity": "sha512-xSxY9Gzeb/eancd8WeK09piAFP+a6i5QIBqNCKNv9L0Eq6wziwzSem7F1GvMSrtjMh5F/QVKFxn8t9naGOA66A==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "tinyglobby": "^0.2.12", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "fontaine": "^0.8.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "fontaine": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "optional": true + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "optional": true, + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz", + "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz", + "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT", + "optional": true + } + } +} diff --git a/sonar-desk-licensing/package.json b/sonar-desk-licensing/package.json new file mode 100755 index 0000000..13ec885 --- /dev/null +++ b/sonar-desk-licensing/package.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "concurrently": "^10.0.3", + "laravel-vite-plugin": "^3.1", + "tailwindcss": "^4.0.0", + "vite": "^8.0.0" + }, + "optionalDependencies": { + "@laravel/multiplex": "^0.4.1" + } +} diff --git a/sonar-desk-licensing/phpunit.xml b/sonar-desk-licensing/phpunit.xml new file mode 100755 index 0000000..e7f0a48 --- /dev/null +++ b/sonar-desk-licensing/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/sonar-desk-licensing/public/.htaccess b/sonar-desk-licensing/public/.htaccess new file mode 100755 index 0000000..b574a59 --- /dev/null +++ b/sonar-desk-licensing/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/sonar-desk-licensing/public/browserconfig.xml b/sonar-desk-licensing/public/browserconfig.xml new file mode 100755 index 0000000..6492abb --- /dev/null +++ b/sonar-desk-licensing/public/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #2563eb + + + diff --git a/sonar-desk-licensing/public/favicon.ico b/sonar-desk-licensing/public/favicon.ico new file mode 100755 index 0000000..439dd30 Binary files /dev/null and b/sonar-desk-licensing/public/favicon.ico differ diff --git a/sonar-desk-licensing/public/icons/android-chrome-192x192.png b/sonar-desk-licensing/public/icons/android-chrome-192x192.png new file mode 100755 index 0000000..f9cf0a3 Binary files /dev/null and b/sonar-desk-licensing/public/icons/android-chrome-192x192.png differ diff --git a/sonar-desk-licensing/public/icons/android-chrome-512x512.png b/sonar-desk-licensing/public/icons/android-chrome-512x512.png new file mode 100755 index 0000000..2f2d498 Binary files /dev/null and b/sonar-desk-licensing/public/icons/android-chrome-512x512.png differ diff --git a/sonar-desk-licensing/public/icons/apple-touch-icon.png b/sonar-desk-licensing/public/icons/apple-touch-icon.png new file mode 100755 index 0000000..d24294c Binary files /dev/null and b/sonar-desk-licensing/public/icons/apple-touch-icon.png differ diff --git a/sonar-desk-licensing/public/icons/favicon-16x16.png b/sonar-desk-licensing/public/icons/favicon-16x16.png new file mode 100755 index 0000000..343e10b Binary files /dev/null and b/sonar-desk-licensing/public/icons/favicon-16x16.png differ diff --git a/sonar-desk-licensing/public/icons/favicon-32x32.png b/sonar-desk-licensing/public/icons/favicon-32x32.png new file mode 100755 index 0000000..aea1964 Binary files /dev/null and b/sonar-desk-licensing/public/icons/favicon-32x32.png differ diff --git a/sonar-desk-licensing/public/icons/favicon-48x48.png b/sonar-desk-licensing/public/icons/favicon-48x48.png new file mode 100755 index 0000000..e49d856 Binary files /dev/null and b/sonar-desk-licensing/public/icons/favicon-48x48.png differ diff --git a/sonar-desk-licensing/public/icons/mstile-150x150.png b/sonar-desk-licensing/public/icons/mstile-150x150.png new file mode 100755 index 0000000..900b36e Binary files /dev/null and b/sonar-desk-licensing/public/icons/mstile-150x150.png differ diff --git a/sonar-desk-licensing/public/icons/og-sonardesk.png b/sonar-desk-licensing/public/icons/og-sonardesk.png new file mode 100644 index 0000000..2f2d498 Binary files /dev/null and b/sonar-desk-licensing/public/icons/og-sonardesk.png differ diff --git a/sonar-desk-licensing/public/icons/safari-pinned-tab.svg b/sonar-desk-licensing/public/icons/safari-pinned-tab.svg new file mode 100755 index 0000000..d7cba39 --- /dev/null +++ b/sonar-desk-licensing/public/icons/safari-pinned-tab.svg @@ -0,0 +1,3 @@ + + + diff --git a/sonar-desk-licensing/public/icons/sonardesk-icon-128.png b/sonar-desk-licensing/public/icons/sonardesk-icon-128.png new file mode 100644 index 0000000..37e9332 Binary files /dev/null and b/sonar-desk-licensing/public/icons/sonardesk-icon-128.png differ diff --git a/sonar-desk-licensing/public/icons/sonardesk-icon-64.png b/sonar-desk-licensing/public/icons/sonardesk-icon-64.png new file mode 100644 index 0000000..5c6e5a7 Binary files /dev/null and b/sonar-desk-licensing/public/icons/sonardesk-icon-64.png differ diff --git a/sonar-desk-licensing/public/index.php b/sonar-desk-licensing/public/index.php new file mode 100755 index 0000000..ee8f07e --- /dev/null +++ b/sonar-desk-licensing/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/sonar-desk-licensing/public/robots.txt b/sonar-desk-licensing/public/robots.txt new file mode 100755 index 0000000..eb05362 --- /dev/null +++ b/sonar-desk-licensing/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/sonar-desk-licensing/public/site.webmanifest b/sonar-desk-licensing/public/site.webmanifest new file mode 100755 index 0000000..053f28f --- /dev/null +++ b/sonar-desk-licensing/public/site.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "SonarDesk", + "short_name": "SonarDesk", + "description": "Native SonarQube monitoring for macOS.", + "start_url": "/", + "display": "standalone", + "background_color": "#f7f9fc", + "theme_color": "#0b1220", + "icons": [ + {"src": "/icons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"}, + {"src": "/icons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"} + ] +} diff --git a/sonar-desk-licensing/resources/css/app.css b/sonar-desk-licensing/resources/css/app.css new file mode 100755 index 0000000..54b247e --- /dev/null +++ b/sonar-desk-licensing/resources/css/app.css @@ -0,0 +1,9 @@ +@import 'tailwindcss'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; + +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} diff --git a/sonar-desk-licensing/resources/js/app.js b/sonar-desk-licensing/resources/js/app.js new file mode 100755 index 0000000..8337712 --- /dev/null +++ b/sonar-desk-licensing/resources/js/app.js @@ -0,0 +1 @@ +// diff --git a/sonar-desk-licensing/resources/views/admin/cms/form.blade.php b/sonar-desk-licensing/resources/views/admin/cms/form.blade.php new file mode 100644 index 0000000..2ba7d36 --- /dev/null +++ b/sonar-desk-licensing/resources/views/admin/cms/form.blade.php @@ -0,0 +1,14 @@ +@extends('layouts.app') +@section('content') +
CMS

{{ $page->exists ? 'Edit page' : 'New page' }}

Back to pages
+
@csrf @if($page->exists) @method('PUT') @endif + + + + +
+ +
@if($page->exists && $page->is_published)View page@endif
+
+@if($page->exists)
@csrf @method('DELETE')
@endif +@endsection diff --git a/sonar-desk-licensing/resources/views/admin/cms/index.blade.php b/sonar-desk-licensing/resources/views/admin/cms/index.blade.php new file mode 100644 index 0000000..52037c8 --- /dev/null +++ b/sonar-desk-licensing/resources/views/admin/cms/index.blade.php @@ -0,0 +1,9 @@ +@extends('layouts.app') +@section('content') +
CMS

Content pages

Manage SonarDesk website content.

New page
+
{{ $pages->links() }}
+@endsection diff --git a/sonar-desk-licensing/resources/views/admin/index.blade.php b/sonar-desk-licensing/resources/views/admin/index.blade.php new file mode 100755 index 0000000..85171f0 --- /dev/null +++ b/sonar-desk-licensing/resources/views/admin/index.blade.php @@ -0,0 +1,6 @@ +@extends('layouts.app') +@section('content') +
Admin

Licensing administration

Manage licences, activations and customer support.

+
{{ $licenseCount }}

Licences

{{ $activeCount }}

Not revoked

{{ $activationCount }}

Active installations

{{ $userCount }}

Portal users

+
Manage licencesManage contentSupport inbox
+@endsection diff --git a/sonar-desk-licensing/resources/views/admin/license.blade.php b/sonar-desk-licensing/resources/views/admin/license.blade.php new file mode 100755 index 0000000..9a2ac70 --- /dev/null +++ b/sonar-desk-licensing/resources/views/admin/license.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Licence •••• {{ $license->license_key_last4 }}

{{ $license->customer_name }} · {{ $license->customer_email }}

{{ $license->edition }} · {{ $license->activation_limit }} seats

@if($license->revoked_at)
@csrf
@else
@csrf
@endif

Activations

@foreach($license->activations as $a)@endforeach
IDPlatformVersionStatus
{{ $a->installation_name ?: $a->installation_id }}{{ $a->platform }}{{ $a->app_version }}{{ $a->deactivated_at ? 'Released':'Active' }}
@endsection diff --git a/sonar-desk-licensing/resources/views/admin/licenses.blade.php b/sonar-desk-licensing/resources/views/admin/licenses.blade.php new file mode 100755 index 0000000..8ff6b69 --- /dev/null +++ b/sonar-desk-licensing/resources/views/admin/licenses.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Licences

@foreach($licenses as $l)@endforeach
CustomerEditionKeyStatus
{{ $l->customer_email ?: 'Manual' }}{{ $l->edition }}•••• {{ $l->license_key_last4 }}{{ $l->revoked_at ? 'Revoked':'Active' }}
{{ $licenses->links() }}
@endsection diff --git a/sonar-desk-licensing/resources/views/admin/support/index.blade.php b/sonar-desk-licensing/resources/views/admin/support/index.blade.php new file mode 100755 index 0000000..ae56c8a --- /dev/null +++ b/sonar-desk-licensing/resources/views/admin/support/index.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.app', ['title' => 'Support Inbox — SonarDesk']) +@section('content') +
Admin

Support inbox

Customer, licence, billing and privacy requests.

+
+ + +
+ +{{ $tickets->links() }} +@endsection diff --git a/sonar-desk-licensing/resources/views/admin/support/show.blade.php b/sonar-desk-licensing/resources/views/admin/support/show.blade.php new file mode 100755 index 0000000..4248c64 --- /dev/null +++ b/sonar-desk-licensing/resources/views/admin/support/show.blade.php @@ -0,0 +1,14 @@ +@extends('layouts.app', ['title' => $ticket->reference.' — Support']) +@section('content') +← Support inbox +
{{ $ticket->reference }}

{{ $ticket->subject }}

{{ $ticket->name }} · {{ $ticket->email }}

{{ ucfirst($ticket->status) }}
+
+

Request

{{ ucfirst($ticket->category) }}{{ $ticket->created_at->format('j M Y H:i') }}
{!! nl2br(e($ticket->message)) !!}
Reply by email
+ +
+@endsection diff --git a/sonar-desk-licensing/resources/views/auth/forgot-password.blade.php b/sonar-desk-licensing/resources/views/auth/forgot-password.blade.php new file mode 100755 index 0000000..21c04c9 --- /dev/null +++ b/sonar-desk-licensing/resources/views/auth/forgot-password.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Reset password

@csrf
@endsection diff --git a/sonar-desk-licensing/resources/views/auth/login.blade.php b/sonar-desk-licensing/resources/views/auth/login.blade.php new file mode 100755 index 0000000..0701f4d --- /dev/null +++ b/sonar-desk-licensing/resources/views/auth/login.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Sign in

@csrf
Forgot password?
@endsection diff --git a/sonar-desk-licensing/resources/views/auth/register.blade.php b/sonar-desk-licensing/resources/views/auth/register.blade.php new file mode 100755 index 0000000..64b5aa0 --- /dev/null +++ b/sonar-desk-licensing/resources/views/auth/register.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Create account

Use the same email address used to purchase SonarDesk.

@csrf
@endsection diff --git a/sonar-desk-licensing/resources/views/auth/reset-password.blade.php b/sonar-desk-licensing/resources/views/auth/reset-password.blade.php new file mode 100755 index 0000000..d11d1a7 --- /dev/null +++ b/sonar-desk-licensing/resources/views/auth/reset-password.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Choose a new password

@csrf
@endsection diff --git a/sonar-desk-licensing/resources/views/auth/verify-email.blade.php b/sonar-desk-licensing/resources/views/auth/verify-email.blade.php new file mode 100755 index 0000000..bde6474 --- /dev/null +++ b/sonar-desk-licensing/resources/views/auth/verify-email.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Verify your email

For security, licence ownership is only shown after the account email has been verified.

@csrf
@endsection diff --git a/sonar-desk-licensing/resources/views/checkout/buy.blade.php b/sonar-desk-licensing/resources/views/checkout/buy.blade.php new file mode 100755 index 0000000..294121f --- /dev/null +++ b/sonar-desk-licensing/resources/views/checkout/buy.blade.php @@ -0,0 +1,91 @@ +@extends('layouts.app') +@section('content') +
+ SonarDesk +

Choose your licence

+

Every subscription starts with a 7-day free trial. Cancel before the trial ends and you won't be charged.

+ @if($paddleEnvironment === 'sandbox')
Sandbox checkout — no real payment is taken
@endif +
+ +
+@foreach($plans as $code => $plan) +
+
+
+
+

{{ $plan['name'] }}

+
{{ $plan['billing'] }} billing
+
+ @if($plan['billing'] === 'Annual')Best value@endif +
+
{{ $plan['display_price'] }}
+
    +
  • 7-day free trial
  • +
  • {{ $plan['activation_limit'] }} active Mac{{ $plan['activation_limit'] === 1 ? '' : 's' }}
  • +
  • Secure online activation
  • +
  • Licence management portal
  • +
+
+ +
+@endforeach +
+ + + + + +@endsection diff --git a/sonar-desk-licensing/resources/views/cms/show.blade.php b/sonar-desk-licensing/resources/views/cms/show.blade.php new file mode 100644 index 0000000..909e3cf --- /dev/null +++ b/sonar-desk-licensing/resources/views/cms/show.blade.php @@ -0,0 +1,4 @@ +@extends('layouts.app', ['title' => $page->meta_title ?: $page->title.' — SonarDesk', 'description' => $page->meta_description ?: $page->excerpt]) +@section('content') + +@endsection diff --git a/sonar-desk-licensing/resources/views/layouts/app.blade.php b/sonar-desk-licensing/resources/views/layouts/app.blade.php new file mode 100755 index 0000000..cc59ca6 --- /dev/null +++ b/sonar-desk-licensing/resources/views/layouts/app.blade.php @@ -0,0 +1,315 @@ + + + + + + + + @php + $pageTitle = $title ?? 'SonarDesk'; + $pageDescription = $description ?? 'Native SonarQube monitoring for macOS. Track projects, quality gates, bugs, vulnerabilities and code smells from one professional desktop app.'; + $canonicalUrl = url()->current(); + $socialImage = url('/icons/og-sonardesk.png'); + @endphp + + {{ $pageTitle }} + + + + + + + + + @if(app()->environment('production')) + + @else + + @endif + + + + + + + + + + + + + + + + + + + + + + + + @stack('head') + + + + +@hasSection('fullbleed') + @yield('fullbleed') +@else +
+ @if(session('status'))
{{ session('status') }}
@endif + @if($errors->any())
{{ $errors->first() }}
@endif + @yield('content') +
+@endif + + + + + + + + + + + + diff --git a/sonar-desk-licensing/resources/views/legal/cookies.blade.php b/sonar-desk-licensing/resources/views/legal/cookies.blade.php new file mode 100755 index 0000000..06981ab --- /dev/null +++ b/sonar-desk-licensing/resources/views/legal/cookies.blade.php @@ -0,0 +1,16 @@ +@extends('layouts.app', ['title' => 'Cookie Policy — SonarDesk']) + +@section('content') + +@endsection diff --git a/sonar-desk-licensing/resources/views/legal/privacy.blade.php b/sonar-desk-licensing/resources/views/legal/privacy.blade.php new file mode 100755 index 0000000..a19a562 --- /dev/null +++ b/sonar-desk-licensing/resources/views/legal/privacy.blade.php @@ -0,0 +1,39 @@ +@extends('layouts.app', ['title' => 'Privacy Policy — SonarDesk']) + +@section('content') +@php + $legal = config('sonardesk.legal'); + $retention = config('sonardesk.privacy.support_retention_days', 730); +@endphp + +@endsection diff --git a/sonar-desk-licensing/resources/views/legal/terms.blade.php b/sonar-desk-licensing/resources/views/legal/terms.blade.php new file mode 100755 index 0000000..db09d91 --- /dev/null +++ b/sonar-desk-licensing/resources/views/legal/terms.blade.php @@ -0,0 +1,19 @@ +@extends('layouts.app', ['title' => 'Terms of Service — SonarDesk']) + +@section('content') +@php($legal = config('sonardesk.legal')) + +@endsection diff --git a/sonar-desk-licensing/resources/views/portal/index.blade.php b/sonar-desk-licensing/resources/views/portal/index.blade.php new file mode 100755 index 0000000..0a874fb --- /dev/null +++ b/sonar-desk-licensing/resources/views/portal/index.blade.php @@ -0,0 +1,113 @@ +@extends('layouts.app') + +@section('content') +
+
+ Account +

My licences

+

Manage subscriptions, licence keys and activated Macs.

+
+ Buy another licence +
+ +@php + $activeLicences = $licenses->filter(fn ($license) => $license->is_usable); + $historyLicences = $licenses->reject(fn ($license) => $license->is_usable); +@endphp + +@if($activeLicences->isEmpty()) +
+

No active licences

+

No active SonarDesk licence is associated with {{ auth()->user()->email }}.

+ Choose a plan +
+@else +
+ @foreach($activeLicences as $license) + @php + $activeCount = $license->activations->whereNull('deactivated_at')->count(); + $isDuplicatePersonal = $hasProfessional && $license->edition === 'Personal'; + $billing = str_contains(strtolower((string) optional($license->product)->code), 'annual') ? 'Annual' : 'Monthly'; + @endphp +
+
+
+
+
+ Active + {{ $license->edition }} + {{ $billing }} +
+

SonarDesk {{ $license->edition }}

+

Licence ending {{ $license->license_key_last4 }}

+
+ Manage licence +
+ +
+
+ Macs activated + {{ $activeCount }} of {{ $license->activation_limit }} +
+
+ Subscription + {{ ucfirst($license->subscription_status ?: 'active') }} +
+
+ {{ $license->subscription_status === 'trialing' ? 'Trial ends' : 'Renews' }} + {{ optional($license->current_period_ends_at)->format('j M Y') ?: '—' }} +
+
+ +
+ +
+ + @if($isDuplicatePersonal) +
+
+ You already have an active Professional subscription. +

This Personal plan is a separate Paddle subscription and could be billed separately. Cancel it if it was only created while upgrading.

+
+
+ @csrf + @method('DELETE') + +
+
+ @elseif($license->edition === 'Personal') +
+
+ Need more Macs? + Upgrade this subscription to Professional for up to 3 Macs. +
+
+ @csrf + +
+
+ @endif +
+
+ @endforeach +
+@endif + +@if($historyLicences->isNotEmpty()) +
+ Licence history {{ $historyLicences->count() }} +
+ @foreach($historyLicences as $license) +
+
+ SonarDesk {{ $license->edition }} + Licence ending {{ $license->license_key_last4 }} +
+
{{ ucfirst($license->subscription_status ?: ($license->revoked_at ? 'revoked' : 'unavailable')) }}
+ Details +
+ @endforeach +
+
+@endif +@endsection diff --git a/sonar-desk-licensing/resources/views/portal/show.blade.php b/sonar-desk-licensing/resources/views/portal/show.blade.php new file mode 100755 index 0000000..2b09e8b --- /dev/null +++ b/sonar-desk-licensing/resources/views/portal/show.blade.php @@ -0,0 +1,80 @@ +@extends('layouts.app') + +@section('content') +@php + $activeActivations = $license->activations->whereNull('deactivated_at'); + $billing = str_contains(strtolower((string) optional($license->product)->code), 'annual') ? 'Annual' : 'Monthly'; +@endphp + +← My licences + +
+
+
+ {{ $license->is_usable ? 'Active' : 'Unavailable' }} + {{ $license->edition }} + {{ $billing }} +
+

SonarDesk {{ $license->edition }}

+

Licence ending {{ $license->license_key_last4 }}

+
+
+ @csrf + +
+
+ +
+
+
+
+

Activated Macs

+

{{ $activeActivations->count() }} of {{ $license->activation_limit }} activation slots in use.

+
+ {{ $activeActivations->count() }}/{{ $license->activation_limit }} +
+ + @forelse($activeActivations as $activation) +
+
⌘
+
+ {{ $activation->installation_name ?: 'Mac' }} + {{ $activation->platform ?: 'macOS' }}@if($activation->app_version) · SonarDesk {{ $activation->app_version }}@endif + Last seen {{ optional($activation->last_seen_at)->diffForHumans() ?: 'not yet recorded' }} +
+
+ @csrf + @method('DELETE') + +
+
+ @empty +
No Macs are currently activated with this licence.
+ @endforelse +
+ + +
+@endsection diff --git a/sonar-desk-licensing/resources/views/portal/welcome.blade.php b/sonar-desk-licensing/resources/views/portal/welcome.blade.php new file mode 100755 index 0000000..233a2f6 --- /dev/null +++ b/sonar-desk-licensing/resources/views/portal/welcome.blade.php @@ -0,0 +1,19 @@ +@extends('layouts.app', [ + 'title' => 'SonarDesk — SonarQube monitoring for macOS', + 'description' => 'A professional native macOS dashboard for SonarQube projects, quality gates, bugs, vulnerabilities and code smells.' +]) +@section('content') +
+ Native SonarQube monitor for macOS +

Your SonarQube projects.
One focused Mac workspace.

+

SonarDesk gives development teams a fast native view of project health, quality gates, bugs, vulnerabilities, code smells and measures without living in a browser.

+ +
+
+
Projects

Everything at a glance

Review quality gates and core measures across every configured SonarQube project.

+
Issues

Find what needs work

Browse bugs, vulnerabilities and code smells with filtering and server-side pagination.

+
Security

Credentials stay protected

SonarQube tokens are stored in the macOS Keychain and API access respects your server permissions.

+
+
Built for engineering teams

A professional desktop companion for SonarQube.

Multiple servers, project dashboards, quality gates, issue views, secure token storage and a native macOS experience.

+
Licensing

Simple licensing for SonarDesk.

Start with a free trial, then manage your licence, activations and subscription from the customer portal.

View pricing
+@endsection diff --git a/sonar-desk-licensing/resources/views/support.blade.php b/sonar-desk-licensing/resources/views/support.blade.php new file mode 100755 index 0000000..6bdee43 --- /dev/null +++ b/sonar-desk-licensing/resources/views/support.blade.php @@ -0,0 +1,100 @@ +@extends('layouts.app', [ + 'title' => 'Support — SonarDesk', + 'description' => 'Contact SonarDesk support for technical, licence, billing and privacy enquiries.' +]) + +@section('content') +
+ Support +

How can we help?

+

Send us the details below. Your request is saved securely, assigned a reference and emailed to the support team.

+
+ +
+
+
+
+

Contact support

+

We will reply to the email address you provide.

+
+ Support ticket +
+ +
+ @csrf + + +
+
+ + + @error('name')
{{ $message }}
@enderror +
+
+ + + @error('email')
{{ $message }}
@enderror +
+
+ +
+
+ + + @error('category')
{{ $message }}
@enderror +
+
+ + + @error('subject')
{{ $message }}
@enderror +
+
+ +
+ + +
Do not include passwords, private SSH keys or your full licence token.
+ @error('message')
{{ $message }}
@enderror +
+ + + @error('privacy_notice')
{{ $message }}
@enderror + +
+ + @auth + @if(auth()->user()->hasVerifiedEmail())Open licence portal@endif + @endauth +
+
+
+ + +
+@endsection diff --git a/sonar-desk-licensing/resources/views/welcome.blade.php b/sonar-desk-licensing/resources/views/welcome.blade.php new file mode 100755 index 0000000..26e294a --- /dev/null +++ b/sonar-desk-licensing/resources/views/welcome.blade.php @@ -0,0 +1,223 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + @fonts + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

With so many options available to you,
we suggest you start with the following:

+ + + +

+ v{{ app()->version() }} + + View changelog + + + + +

+
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- 13 --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/sonar-desk-licensing/routes/api.php b/sonar-desk-licensing/routes/api.php new file mode 100755 index 0000000..9548279 --- /dev/null +++ b/sonar-desk-licensing/routes/api.php @@ -0,0 +1,12 @@ +group(function () { + Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation'); + Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation'); + Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation'); + Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']); +}); diff --git a/sonar-desk-licensing/routes/console.php b/sonar-desk-licensing/routes/console.php new file mode 100755 index 0000000..b98b725 --- /dev/null +++ b/sonar-desk-licensing/routes/console.php @@ -0,0 +1,6 @@ +dailyAt('03:20')->withoutOverlapping(); diff --git a/sonar-desk-licensing/routes/web.php b/sonar-desk-licensing/routes/web.php new file mode 100755 index 0000000..57b2844 --- /dev/null +++ b/sonar-desk-licensing/routes/web.php @@ -0,0 +1,65 @@ + view('portal.welcome'))->name('home'); +Route::get('/buy', CheckoutController::class)->name('buy'); +Route::get('/support', [SupportController::class, 'create'])->name('support'); +Route::post('/support', [SupportController::class, 'store'])->middleware('throttle:5,10')->name('support.store'); +Route::view('/privacy', 'legal.privacy')->name('privacy'); +Route::view('/terms', 'legal.terms')->name('terms'); +Route::view('/cookies', 'legal.cookies')->name('cookies'); +Route::middleware('guest')->group(function () { + Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login'); + Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('throttle:login'); + Route::get('/register', [RegisteredUserController::class, 'create'])->name('register'); + Route::post('/register', [RegisteredUserController::class, 'store'])->middleware('throttle:registration'); + Route::get('/forgot-password', [PasswordController::class, 'requestForm'])->name('password.request'); + Route::post('/forgot-password', [PasswordController::class, 'email'])->name('password.email')->middleware('throttle:5,1'); + Route::get('/reset-password/{token}', [PasswordController::class, 'resetForm'])->name('password.reset'); + Route::post('/reset-password', [PasswordController::class, 'reset'])->name('password.update'); +}); +Route::middleware('auth')->group(function () { + Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout'); + Route::get('/verify-email', [EmailVerificationController::class, 'notice'])->name('verification.notice'); + Route::get('/verify-email/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify'); + Route::post('/email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send'); +}); +Route::middleware(['auth', 'verified'])->prefix('portal')->name('portal.')->group(function () { + Route::get('/', [PortalController::class, 'index'])->name('index'); + Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show'); + Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade'); + Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel'); + Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend'); + Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate'); +}); +Route::middleware(['auth', 'verified', AdminOnly::class])->prefix('admin')->name('admin.')->group(function () { + Route::get('/', [AdminController::class, 'index'])->name('index'); + Route::get('/licences', [AdminController::class, 'licenses'])->name('licenses'); + Route::get('/licences/{license}', [AdminController::class, 'show'])->name('license'); + Route::post('/licences/{license}/revoke', [AdminController::class, 'revoke'])->name('revoke'); + Route::post('/licences/{license}/restore', [AdminController::class, 'restore'])->name('restore'); + Route::get('/cms', [AdminCmsPageController::class, 'index'])->name('cms.index'); + Route::get('/cms/create', [AdminCmsPageController::class, 'create'])->name('cms.create'); + Route::post('/cms', [AdminCmsPageController::class, 'store'])->name('cms.store'); + Route::get('/cms/{page}/edit', [AdminCmsPageController::class, 'edit'])->name('cms.edit'); + Route::put('/cms/{page}', [AdminCmsPageController::class, 'update'])->name('cms.update'); + Route::delete('/cms/{page}', [AdminCmsPageController::class, 'destroy'])->name('cms.destroy'); + Route::get('/support', [SupportTicketController::class, 'index'])->name('support.index'); + Route::get('/support/{ticket}', [SupportTicketController::class, 'show'])->name('support.show'); + Route::patch('/support/{ticket}',[SupportTicketController::class, 'update'])->name('support.update'); +}); + +Route::get('/pages/{slug}', [CmsPageController::class, 'show'])->name('cms.show'); diff --git a/sonar-desk-licensing/scripts/create-sonardesk-catalog.sh b/sonar-desk-licensing/scripts/create-sonardesk-catalog.sh new file mode 100755 index 0000000..c931c5e --- /dev/null +++ b/sonar-desk-licensing/scripts/create-sonardesk-catalog.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PADDLE_API_KEY="${PADDLE_API_KEY:-}" + +if [[ -z "$PADDLE_API_KEY" ]]; then + echo "ERROR: PADDLE_API_KEY is not set." + echo 'Run: export PADDLE_API_KEY="pdl_sdbx_apikey_..."' + exit 1 +fi + +API="https://sandbox-api.paddle.com" + +api_post() { + local endpoint="$1" + local payload="$2" + + curl --fail-with-body --silent --show-error \ + --request POST \ + --url "${API}${endpoint}" \ + --header "Authorization: Bearer ${PADDLE_API_KEY}" \ + --header "Content-Type: application/json" \ + --data "$payload" +} + +create_product() { + local name="$1" + local description="$2" + + api_post "/products" "$(jq -n \ + --arg name "$name" \ + --arg description "$description" \ + '{ + name: $name, + description: $description, + type: "standard", + tax_category: "standard" + }' + )" +} + +create_price() { + local product_id="$1" + local name="$2" + local description="$3" + local cycle="$4" + local usd="$5" + local gbp="$6" + local eur="$7" + local aud="$8" + + api_post "/prices" "$(jq -n \ + --arg product_id "$product_id" \ + --arg name "$name" \ + --arg description "$description" \ + --arg cycle "$cycle" \ + --arg usd "$usd" \ + --arg gbp "$gbp" \ + --arg eur "$eur" \ + --arg aud "$aud" \ + '{ + product_id: $product_id, + name: $name, + description: $description, + + billing_cycle: { + interval: $cycle, + frequency: 1 + }, + + trial_period: { + interval: "day", + frequency: 7 + }, + + unit_price: { + amount: $usd, + currency_code: "USD" + }, + + unit_price_overrides: [ + { + country_codes: ["GB"], + unit_price: { + amount: $gbp, + currency_code: "GBP" + } + }, + { + country_codes: ["IE"], + unit_price: { + amount: $eur, + currency_code: "EUR" + } + }, + { + country_codes: ["AU"], + unit_price: { + amount: $aud, + currency_code: "AUD" + } + } + ], + + tax_mode: "account_setting", + + quantity: { + minimum: 1, + maximum: 1 + } + }' + )" +} + +echo +echo "Creating SonarDesk Paddle sandbox catalog..." +echo + +# +# Personal +# + +PERSONAL_PRODUCT=$(create_product \ + "SonarDesk Personal" \ + "SonarDesk for personal use.") + +PERSONAL_PRODUCT_ID=$(echo "$PERSONAL_PRODUCT" | jq -r '.data.id') + +echo "Created Personal product: $PERSONAL_PRODUCT_ID" + +PERSONAL_MONTHLY=$(create_price \ + "$PERSONAL_PRODUCT_ID" \ + "Personal Monthly" \ + "SonarDesk Personal — monthly subscription with 7-day free trial." \ + "month" \ + "199" \ + "199" \ + "199" \ + "299") + +PERSONAL_MONTHLY_ID=$(echo "$PERSONAL_MONTHLY" | jq -r '.data.id') + +echo "Created Personal Monthly: $PERSONAL_MONTHLY_ID" + +PERSONAL_ANNUAL=$(create_price \ + "$PERSONAL_PRODUCT_ID" \ + "Personal Annual" \ + "SonarDesk Personal — annual subscription with 7-day free trial." \ + "year" \ + "1499" \ + "1499" \ + "1699" \ + "2499") + +PERSONAL_ANNUAL_ID=$(echo "$PERSONAL_ANNUAL" | jq -r '.data.id') + +echo "Created Personal Annual: $PERSONAL_ANNUAL_ID" + +# +# Professional +# + +PRO_PRODUCT=$(create_product \ + "SonarDesk Professional" \ + "SonarDesk for professional and commercial use.") + +PRO_PRODUCT_ID=$(echo "$PRO_PRODUCT" | jq -r '.data.id') + +echo "Created Professional product: $PRO_PRODUCT_ID" + +PRO_MONTHLY=$(create_price \ + "$PRO_PRODUCT_ID" \ + "Professional Monthly" \ + "SonarDesk Professional — monthly subscription with 7-day free trial." \ + "month" \ + "399" \ + "399" \ + "449" \ + "699") + +PRO_MONTHLY_ID=$(echo "$PRO_MONTHLY" | jq -r '.data.id') + +echo "Created Professional Monthly: $PRO_MONTHLY_ID" + +PRO_ANNUAL=$(create_price \ + "$PRO_PRODUCT_ID" \ + "Professional Annual" \ + "SonarDesk Professional — annual subscription with 7-day free trial." \ + "year" \ + "2999" \ + "2999" \ + "3499" \ + "4999") + +PRO_ANNUAL_ID=$(echo "$PRO_ANNUAL" | jq -r '.data.id') + +echo "Created Professional Annual: $PRO_ANNUAL_ID" + +echo +echo "============================================================" +echo " RSYNC STUDIO — PADDLE SANDBOX CATALOG" +echo "============================================================" +echo +echo "PERSONAL" +echo " Product ID: $PERSONAL_PRODUCT_ID" +echo " Monthly Price ID: $PERSONAL_MONTHLY_ID" +echo " Annual Price ID: $PERSONAL_ANNUAL_ID" +echo +echo "PROFESSIONAL" +echo " Product ID: $PRO_PRODUCT_ID" +echo " Monthly Price ID: $PRO_MONTHLY_ID" +echo " Annual Price ID: $PRO_ANNUAL_ID" +echo +echo "------------------------------------------------------------" +echo "Pricing" +echo "------------------------------------------------------------" +echo +printf "%-24s %-12s %-12s %-12s %-12s\n" \ + "Plan" "USD" "UK" "Ireland" "Australia" +printf "%-24s %-12s %-12s %-12s %-12s\n" \ + "Personal Monthly" '$1.99' '£1.99' '€1.99' 'A$2.99' +printf "%-24s %-12s %-12s %-12s %-12s\n" \ + "Personal Annual" '$14.99' '£14.99' '€16.99' 'A$24.99' +printf "%-24s %-12s %-12s %-12s %-12s\n" \ + "Professional Monthly" '$3.99' '£3.99' '€4.49' 'A$6.99' +printf "%-24s %-12s %-12s %-12s %-12s\n" \ + "Professional Annual" '$29.99' '£29.99' '€34.99' 'A$49.99' +echo +echo "All four prices include a 7-day FREE trial." +echo +echo "============================================================" + +cat > sonardesk-paddle-ids.env <&2; exit 2; fi +SRC="$(cd "$(dirname "$0")" && pwd)"; DST="$(cd "$1" && pwd)" +for dir in app config database routes tests resources; do [[ -d "$SRC/$dir" ]] && cp -R "$SRC/$dir/." "$DST/$dir/"; done +[[ -f "$DST/routes/web.php" ]] || printf '%s\n' ' "$DST/routes/web.php" +grep -q "web.stage5.php" "$DST/routes/web.php" || printf "\nrequire __DIR__.'/web.stage5.php';\n" >> "$DST/routes/web.php" +[[ -f "$DST/routes/api.php" ]] || printf '%s\n' ' "$DST/routes/api.php" +grep -q "api.stage4.php" "$DST/routes/api.php" || printf "\nrequire __DIR__.'/api.stage4.php';\n" >> "$DST/routes/api.php" +if ! grep -q 'SonarDeskLicensingServiceProvider' "$DST/bootstrap/providers.php"; then python3 - "$DST/bootstrap/providers.php" <<'PY' +import sys +p=sys.argv[1]; s=open(p).read(); marker='return ['; s=s.replace(marker, marker+"\n App\\Providers\\SonarDeskLicensingServiceProvider::class,",1); open(p,'w').write(s) +PY +fi +printf '\nStage 5 files copied into %s\n' "$DST" +printf 'Run: php artisan migrate && php artisan optimize:clear\n' +printf 'Then create an administrator with sonardesk:admin.\n' diff --git a/sonar-desk-licensing/storage/app/.gitignore b/sonar-desk-licensing/storage/app/.gitignore new file mode 100755 index 0000000..fedb287 --- /dev/null +++ b/sonar-desk-licensing/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/sonar-desk-licensing/storage/app/private/.gitignore b/sonar-desk-licensing/storage/app/private/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/sonar-desk-licensing/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/sonar-desk-licensing/storage/app/public/.gitignore b/sonar-desk-licensing/storage/app/public/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/sonar-desk-licensing/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/sonar-desk-licensing/storage/framework/.gitignore b/sonar-desk-licensing/storage/framework/.gitignore new file mode 100755 index 0000000..7a0b408 --- /dev/null +++ b/sonar-desk-licensing/storage/framework/.gitignore @@ -0,0 +1,10 @@ +compiled.php +config.php +down +events.scanned.php +lsp-*.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/sonar-desk-licensing/storage/framework/cache/.gitignore b/sonar-desk-licensing/storage/framework/cache/.gitignore new file mode 100755 index 0000000..01e4a6c --- /dev/null +++ b/sonar-desk-licensing/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/sonar-desk-licensing/storage/framework/cache/data/.gitignore b/sonar-desk-licensing/storage/framework/cache/data/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/sonar-desk-licensing/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/sonar-desk-licensing/storage/framework/sessions/.gitignore b/sonar-desk-licensing/storage/framework/sessions/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/sonar-desk-licensing/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/sonar-desk-licensing/storage/framework/testing/.gitignore b/sonar-desk-licensing/storage/framework/testing/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/sonar-desk-licensing/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/sonar-desk-licensing/storage/framework/views/.gitignore b/sonar-desk-licensing/storage/framework/views/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/sonar-desk-licensing/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/sonar-desk-licensing/storage/logs/.gitignore b/sonar-desk-licensing/storage/logs/.gitignore new file mode 100755 index 0000000..d6b7ef3 --- /dev/null +++ b/sonar-desk-licensing/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/sonar-desk-licensing/tests/Feature/CheckoutPageTest.php b/sonar-desk-licensing/tests/Feature/CheckoutPageTest.php new file mode 100755 index 0000000..1fd31b8 --- /dev/null +++ b/sonar-desk-licensing/tests/Feature/CheckoutPageTest.php @@ -0,0 +1,27 @@ +set('sonardesk.paddle.client_side_token', 'test_example'); + config()->set('sonardesk.paddle.environment', 'sandbox'); + config()->set('sonardesk.paddle.personal_monthly_price_id', 'pri_personal_monthly'); + config()->set('sonardesk.paddle.personal_annual_price_id', 'pri_personal_annual'); + config()->set('sonardesk.paddle.professional_monthly_price_id', 'pri_professional_monthly'); + config()->set('sonardesk.paddle.professional_annual_price_id', 'pri_professional_annual'); + + $this->get('/buy') + ->assertOk() + ->assertSee('7-day free trial') + ->assertSee('pri_personal_monthly') + ->assertSee('pri_personal_annual') + ->assertSee('pri_professional_monthly') + ->assertSee('pri_professional_annual') + ->assertSee('test_example'); + } +} diff --git a/sonar-desk-licensing/tests/Feature/LicenseActivationTest.php b/sonar-desk-licensing/tests/Feature/LicenseActivationTest.php new file mode 100755 index 0000000..7d81a87 --- /dev/null +++ b/sonar-desk-licensing/tests/Feature/LicenseActivationTest.php @@ -0,0 +1,85 @@ + 'test', 'name' => 'Test', 'edition' => 'Professional', 'activation_limit' => 1, 'max_major_version' => 1, 'active' => true]); + [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); + $one = ['licenseKey' => $key, 'installationID' => '11111111-1111-4111-8111-111111111111', 'appVersion' => '1.0.0', 'platform' => 'macOS']; + $this->postJson('/api/v1/activate', $one)->assertOk()->assertJsonStructure(['licenseToken']); + $this->postJson('/api/v1/activate', $one)->assertOk(); + $this->postJson('/api/v1/activate', array_merge($one, ['installationID' => '22222222-2222-4222-8222-222222222222']))->assertStatus(409); + } + + public function test_validation_refreshes_an_active_installation_without_calling_paddle(): void + { + $product = LicenseProduct::create([ + 'code' => 'validate-active', + 'name' => 'Validate Active', + 'edition' => 'Professional', + 'activation_limit' => 1, + 'max_major_version' => 1, + 'active' => true, + ]); + [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); + $request = [ + 'licenseKey' => $key, + 'installationID' => '11111111-1111-4111-8111-111111111111', + 'appVersion' => '1.0.0', + 'platform' => 'macOS', + ]; + + $token = $this->postJson('/api/v1/activate', $request)->assertOk()->json('licenseToken'); + + $this->postJson('/api/v1/validate', [ + 'licenseToken' => $token, + 'installationID' => $request['installationID'], + 'appVersion' => '1.0.1', + 'platform' => 'macOS', + ])->assertOk()->assertJson([ + 'active' => true, + ])->assertJsonStructure(['licenseToken']); + } + + public function test_validation_rejects_an_installation_deactivated_from_the_portal(): void + { + $product = LicenseProduct::create([ + 'code' => 'validate-deactivated', + 'name' => 'Validate Deactivated', + 'edition' => 'Personal', + 'activation_limit' => 1, + 'max_major_version' => 1, + 'active' => true, + ]); + [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); + $request = [ + 'licenseKey' => $key, + 'installationID' => '22222222-2222-4222-8222-222222222222', + 'appVersion' => '1.0.0', + 'platform' => 'macOS', + ]; + + $token = $this->postJson('/api/v1/activate', $request)->assertOk()->json('licenseToken'); + LicenseActivation::query()->where('installation_id', strtolower($request['installationID']))->update(['deactivated_at' => now()]); + + $this->postJson('/api/v1/validate', [ + 'licenseToken' => $token, + 'installationID' => $request['installationID'], + 'appVersion' => '1.0.0', + 'platform' => 'macOS', + ])->assertStatus(403)->assertJson([ + 'message' => 'This Mac has been deactivated from the licence portal.', + ]); + } +} diff --git a/sonar-desk-licensing/tests/Feature/PaddleSubscriptionProvisioningTest.php b/sonar-desk-licensing/tests/Feature/PaddleSubscriptionProvisioningTest.php new file mode 100755 index 0000000..527216e --- /dev/null +++ b/sonar-desk-licensing/tests/Feature/PaddleSubscriptionProvisioningTest.php @@ -0,0 +1,105 @@ + strtolower($edition).'-'.$price, + 'name' => $edition, + 'edition' => $edition, + 'activation_limit' => $activations, + 'max_major_version' => 1, + 'payment_provider' => 'paddle', + 'external_product_id' => $edition === 'Personal' ? 'pro_personal' : 'pro_professional', + 'external_price_id' => $price, + 'active' => true, + ]); + } + + private function subscription(string $type, string $status, string $price, string $product, array $extra = []): array + { + return [ + 'event_type' => $type, + 'data' => array_replace_recursive([ + 'id' => 'sub_test', + 'status' => $status, + 'customer_id' => 'ctm_test', + 'transaction_id' => 'txn_test', + 'custom_data' => ['customer_name' => 'Mark', 'customer_email' => 'mark@example.com'], + 'current_billing_period' => ['starts_at' => '2026-09-09T00:00:00Z', 'ends_at' => '2026-10-09T00:00:00Z'], + 'scheduled_change' => null, + 'items' => [[ + 'recurring' => true, + 'price' => ['id' => $price, 'product_id' => $product], + ]], + ], $extra), + ]; + } + + public function test_trialing_subscription_issues_a_usable_license(): void + { + $this->product('pri_personal_monthly', 'Personal', 1); + + app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( + 'subscription.created', 'trialing', 'pri_personal_monthly', 'pro_personal' + )); + + $license = License::firstOrFail(); + $this->assertSame('Personal', $license->edition); + $this->assertSame('sub_test', $license->external_subscription_id); + $this->assertSame('trialing', $license->subscription_status); + $this->assertTrue($license->is_usable); + } + + public function test_upgrade_changes_entitlement_without_changing_license(): void + { + $this->product('pri_personal_monthly', 'Personal', 1); + $this->product('pri_professional_monthly', 'Professional', 3); + $service = app(PurchaseProvisioner::class); + + $service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal')); + $id = License::firstOrFail()->id; + $service->provision('paddle', $this->subscription('subscription.updated', 'active', 'pri_professional_monthly', 'pro_professional')); + + $license = License::firstOrFail(); + $this->assertSame($id, $license->id); + $this->assertSame('Professional', $license->edition); + $this->assertSame(3, $license->activation_limit); + } + + public function test_scheduled_cancellation_does_not_revoke_before_effective_date(): void + { + $this->product('pri_personal_monthly', 'Personal', 1); + app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( + 'subscription.updated', 'active', 'pri_personal_monthly', 'pro_personal', + ['data' => ['scheduled_change' => ['action' => 'cancel', 'effective_at' => '2026-10-09T00:00:00Z']]] + )); + + $license = License::firstOrFail(); + $this->assertNull($license->revoked_at); + $this->assertNotNull($license->cancel_scheduled_at); + } + + public function test_canceled_subscription_revokes_license(): void + { + $this->product('pri_personal_monthly', 'Personal', 1); + $service = app(PurchaseProvisioner::class); + $service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal')); + $service->provision('paddle', $this->subscription('subscription.canceled', 'canceled', 'pri_personal_monthly', 'pro_personal', ['data' => ['current_billing_period' => null]])); + + $license = License::firstOrFail(); + $this->assertNotNull($license->revoked_at); + $this->assertFalse($license->is_usable); + } +} diff --git a/sonar-desk-licensing/tests/Feature/PortalSubscriptionManagementTest.php b/sonar-desk-licensing/tests/Feature/PortalSubscriptionManagementTest.php new file mode 100755 index 0000000..5fd08de --- /dev/null +++ b/sonar-desk-licensing/tests/Feature/PortalSubscriptionManagementTest.php @@ -0,0 +1,66 @@ + $code, + 'name' => 'SonarDesk '.$edition.' '.(str_contains($code, 'annual') ? 'Annual' : 'Monthly'), + 'edition' => $edition, + 'activation_limit' => $limit, + 'max_major_version' => 1, + 'payment_provider' => 'paddle', + 'external_product_id' => 'pro_'.strtolower($edition), + 'external_price_id' => $price, + 'active' => true, + ]); + } + + public function test_personal_subscription_is_upgraded_in_place(): void + { + config()->set('sonardesk.paddle.personal_monthly_price_id', 'pri_personal_monthly'); + config()->set('sonardesk.paddle.professional_monthly_price_id', 'pri_professional_monthly'); + + $personal = $this->product('personal-monthly', 'Personal', 'pri_personal_monthly', 1); + $professional = $this->product('professional-monthly', 'Professional', 'pri_professional_monthly', 3); + $user = User::factory()->create(['email' => 'mark@example.com', 'email_verified_at' => now()]); + $license = License::create([ + 'license_product_id' => $personal->id, + 'license_key_hash' => str_repeat('a', 64), + 'license_key_last4' => 'TEST', + 'customer_email' => $user->email, + 'edition' => 'Personal', + 'activation_limit' => 1, + 'max_major_version' => 1, + 'payment_provider' => 'paddle', + 'external_subscription_id' => 'sub_test', + 'external_price_id' => 'pri_personal_monthly', + 'subscription_status' => 'active', + ]); + + $this->mock(PaddleClient::class, function (MockInterface $mock) { + $mock->shouldReceive('updateSubscriptionPrice')->once()->with('sub_test', 'pri_professional_monthly')->andReturn(['status' => 'active']); + }); + + $this->actingAs($user)->post(route('portal.upgrade', $license))->assertRedirect(route('portal.show', $license)); + + $license->refresh(); + $this->assertSame($professional->id, $license->license_product_id); + $this->assertSame('Professional', $license->edition); + $this->assertSame(3, $license->activation_limit); + $this->assertSame('sub_test', $license->external_subscription_id); + } +} diff --git a/sonar-desk-licensing/vite.config.js b/sonar-desk-licensing/vite.config.js new file mode 100755 index 0000000..1fd66d5 --- /dev/null +++ b/sonar-desk-licensing/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import { bunny } from 'laravel-vite-plugin/fonts'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + fonts: [ + bunny('Instrument Sans', { + weights: [400, 500, 600], + }), + ], + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +});