diff --git a/clients/macos/vellum-assistantTests/VellumPathsTests.swift b/clients/macos/vellum-assistantTests/VellumPathsTests.swift new file mode 100644 index 00000000000..41398b9a90e --- /dev/null +++ b/clients/macos/vellum-assistantTests/VellumPathsTests.swift @@ -0,0 +1,141 @@ +import XCTest +@testable import VellumAssistantShared + +final class VellumPathsTests: XCTestCase { + + // Explicit test roots so we don't depend on process environment + private let testHome = URL(fileURLWithPath: "/tmp/test-home") + private let testXdgConfig = URL(fileURLWithPath: "/tmp/test-home/.config") + private let testXdgData = URL(fileURLWithPath: "/tmp/test-home/.local/share") + + private func makePaths(_ env: VellumEnvironment) -> VellumPaths { + VellumPaths( + environment: env, + homeDirectory: testHome, + xdgConfigHome: testXdgConfig, + xdgDataHome: testXdgData + ) + } + + // MARK: - Production: legacy paths preserved byte-for-byte + + func testProductionLockfileCandidates() { + let paths = makePaths(.production) + XCTAssertEqual( + paths.lockfileCandidates.map(\.path), + [ + "/tmp/test-home/.vellum.lock.json", + "/tmp/test-home/.vellum.lockfile.json", + ] + ) + } + + func testProductionDeviceIdFile() { + XCTAssertEqual( + makePaths(.production).deviceIdFile.path, + "/tmp/test-home/.vellum/device.json" + ) + } + + func testProductionSigningKeyFile() { + XCTAssertEqual( + makePaths(.production).signingKeyFile.path, + "/tmp/test-home/.vellum/protected/app-signing-key" + ) + } + + func testProductionCredentialsDir() { + XCTAssertEqual( + makePaths(.production).credentialsDir.path, + "/tmp/test-home/.vellum/protected/credentials" + ) + } + + func testProductionConfigDir() { + XCTAssertEqual( + makePaths(.production).configDir.path, + "/tmp/test-home/.config/vellum" + ) + } + + func testProductionPlatformTokenFile() { + XCTAssertEqual( + makePaths(.production).platformTokenFile.path, + "/tmp/test-home/.config/vellum/platform-token" + ) + } + + // MARK: - Non-production: env-scoped paths + + func testDevLockfileCandidates() { + XCTAssertEqual( + makePaths(.dev).lockfileCandidates.map(\.path), + ["/tmp/test-home/.config/vellum-dev/lockfile.json"] + ) + } + + func testDevDeviceIdFile() { + XCTAssertEqual( + makePaths(.dev).deviceIdFile.path, + "/tmp/test-home/.config/vellum-dev/device.json" + ) + } + + func testDevSigningKeyFile() { + XCTAssertEqual( + makePaths(.dev).signingKeyFile.path, + "/tmp/test-home/.config/vellum-dev/app-signing-key" + ) + } + + func testDevCredentialsDir() { + XCTAssertEqual( + makePaths(.dev).credentialsDir.path, + "/tmp/test-home/.config/vellum-dev/credentials" + ) + } + + func testDevConfigDir() { + XCTAssertEqual( + makePaths(.dev).configDir.path, + "/tmp/test-home/.config/vellum-dev" + ) + } + + func testStagingConfigDir() { + XCTAssertEqual( + makePaths(.staging).configDir.path, + "/tmp/test-home/.config/vellum-staging" + ) + } + + func testTestConfigDir() { + XCTAssertEqual( + makePaths(.test).configDir.path, + "/tmp/test-home/.config/vellum-test" + ) + } + + func testLocalConfigDir() { + XCTAssertEqual( + makePaths(.local).configDir.path, + "/tmp/test-home/.config/vellum-local" + ) + } + + // MARK: - Parity: Swift matches TS production paths byte-for-byte + + func testProductionMatchesLegacyInlineConventions() { + // These paths MUST match what LockfilePaths.swift, DeviceIdStore.swift, + // SigningIdentityManager.swift, and FileCredentialStorage.swift + // currently construct inline. PR 5 routes those callers through + // VellumPaths.current and this parity must hold for production users + // to see zero path changes. + let paths = makePaths(.production) + XCTAssertEqual(paths.lockfileCandidates[0].lastPathComponent, ".vellum.lock.json") + XCTAssertEqual(paths.lockfileCandidates[1].lastPathComponent, ".vellum.lockfile.json") + XCTAssertEqual(paths.deviceIdFile.path.hasSuffix("/.vellum/device.json"), true) + XCTAssertEqual(paths.signingKeyFile.path.hasSuffix("/.vellum/protected/app-signing-key"), true) + XCTAssertEqual(paths.credentialsDir.path.hasSuffix("/.vellum/protected/credentials"), true) + } +} diff --git a/clients/shared/Utilities/VellumPaths.swift b/clients/shared/Utilities/VellumPaths.swift new file mode 100644 index 00000000000..a828074b7a3 --- /dev/null +++ b/clients/shared/Utilities/VellumPaths.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Env-aware filesystem path helpers for client-owned state. Mirrors +/// `cli/src/lib/environments/paths.ts` so the Swift client and the TS +/// daemon/CLI produce byte-identical paths for production users while +/// sharing the same convention for non-production environments. +/// +/// **Production is grandfathered**: every getter returns the legacy +/// `~/.vellum/...` path (or the existing `~/.config/vellum/...` path for +/// things that were already XDG-compliant). No migration for existing +/// installs. +/// +/// **Non-production environments** use env-scoped XDG paths +/// (`$XDG_CONFIG_HOME/vellum-/...`). These are dormant today — no +/// build currently bakes a non-production `VELLUM_ENVIRONMENT` into +/// `Info.plist` for end users. +/// +/// Production code reads `VellumPaths.current` (cached singleton). Tests +/// construct their own `VellumPaths` with explicit roots so they don't +/// depend on the surrounding process state. +public struct VellumPaths { + public let environment: VellumEnvironment + public let homeDirectory: URL + public let xdgConfigHome: URL + public let xdgDataHome: URL + + /// Resolved path bundle for the current process environment. + /// + /// `NSHomeDirectory()` is used intentionally to match the existing + /// convention in `LockfilePaths.swift` (for unsandboxed macOS apps, + /// this is equivalent to `FileManager.default.homeDirectoryForCurrentUser`). + public static let current: VellumPaths = { + VellumPaths( + environment: .current, + homeDirectory: URL(fileURLWithPath: NSHomeDirectory()), + xdgConfigHome: Self.resolveXdgConfigHome(), + xdgDataHome: Self.resolveXdgDataHome() + ) + }() + + public init( + environment: VellumEnvironment, + homeDirectory: URL, + xdgConfigHome: URL, + xdgDataHome: URL + ) { + self.environment = environment + self.homeDirectory = homeDirectory + self.xdgConfigHome = xdgConfigHome + self.xdgDataHome = xdgDataHome + } + + // MARK: - Path getters + + /// `~/.config/vellum/` for production, `~/.config/vellum-/` otherwise. + public var configDir: URL { + let dirName: String + if environment == .production { + dirName = "vellum" + } else { + dirName = "vellum-\(environment.rawValue)" + } + return xdgConfigHome.appendingPathComponent(dirName) + } + + /// Shared with the TypeScript daemon. + public var deviceIdFile: URL { + if environment == .production { + return homeDirectory.appendingPathComponent(".vellum/device.json") + } + return configDir.appendingPathComponent("device.json") + } + + /// macOS-client-owned; not read by the daemon. + public var signingKeyFile: URL { + if environment == .production { + return homeDirectory.appendingPathComponent( + ".vellum/protected/app-signing-key" + ) + } + return configDir.appendingPathComponent("app-signing-key") + } + + /// macOS-client-owned; not read by the daemon. + public var credentialsDir: URL { + if environment == .production { + return homeDirectory.appendingPathComponent( + ".vellum/protected/credentials" + ) + } + return configDir.appendingPathComponent("credentials") + } + + /// Shared with the daemon. Always XDG-rooted (no legacy branch). + public var platformTokenFile: URL { + configDir.appendingPathComponent("platform-token") + } + + /// Priority order: current name first, legacy fallback second. + /// Production returns both; non-prod returns only the current. + public var lockfileCandidates: [URL] { + if environment == .production { + return [ + homeDirectory.appendingPathComponent(".vellum.lock.json"), + homeDirectory.appendingPathComponent(".vellum.lockfile.json"), + ] + } + return [configDir.appendingPathComponent("lockfile.json")] + } + + // MARK: - Internals + + private static func resolveXdgConfigHome() -> URL { + if let raw = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + { + return URL(fileURLWithPath: raw) + } + return URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent(".config") + } + + private static func resolveXdgDataHome() -> URL { + if let raw = ProcessInfo.processInfo.environment["XDG_DATA_HOME"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + { + return URL(fileURLWithPath: raw) + } + return URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent(".local") + .appendingPathComponent("share") + } +}