Add command palette entries to install/uninstall cmux CLI in PATH (#626)
Adds CmuxCLIPathInstaller with symlink management at /usr/local/bin/cmux, exposed via Cmd+Shift+P command palette (VS Code style). Install entry shown when CLI is not in PATH, uninstall entry shown when it is. Falls back to osascript admin privilege escalation when /usr/local/bin is not user-writable. Uses attributesOfItem instead of fileExists to correctly handle dangling symlinks from relocated app bundles. Closes https://github.com/manaflow-ai/cmux/issues/618
This commit is contained in:
parent
978341b228
commit
49e93e4b4c
3 changed files with 503 additions and 0 deletions
|
|
@ -253,6 +253,321 @@ enum WorkspaceShortcutMapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CmuxCLIPathInstaller {
|
||||||
|
struct InstallOutcome {
|
||||||
|
let usedAdministratorPrivileges: Bool
|
||||||
|
let destinationURL: URL
|
||||||
|
let sourceURL: URL
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UninstallOutcome {
|
||||||
|
let usedAdministratorPrivileges: Bool
|
||||||
|
let destinationURL: URL
|
||||||
|
let removedExistingEntry: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InstallerError: LocalizedError {
|
||||||
|
case bundledCLIMissing(expectedPath: String)
|
||||||
|
case destinationParentNotDirectory(path: String)
|
||||||
|
case destinationIsDirectory(path: String)
|
||||||
|
case installVerificationFailed(path: String)
|
||||||
|
case uninstallVerificationFailed(path: String)
|
||||||
|
case privilegedCommandFailed(message: String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .bundledCLIMissing(let expectedPath):
|
||||||
|
return "Bundled cmux CLI was not found at \(expectedPath)."
|
||||||
|
case .destinationParentNotDirectory(let path):
|
||||||
|
return "Expected \(path) to be a directory."
|
||||||
|
case .destinationIsDirectory(let path):
|
||||||
|
return "\(path) is a directory. Remove or rename it and try again."
|
||||||
|
case .installVerificationFailed(let path):
|
||||||
|
return "Installed symlink at \(path) did not point to the bundled cmux CLI."
|
||||||
|
case .uninstallVerificationFailed(let path):
|
||||||
|
return "Failed to remove \(path)."
|
||||||
|
case .privilegedCommandFailed(let message):
|
||||||
|
return "Administrator action failed: \(message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias PrivilegedInstallHandler = (_ sourceURL: URL, _ destinationURL: URL) throws -> Void
|
||||||
|
typealias PrivilegedUninstallHandler = (_ destinationURL: URL) throws -> Void
|
||||||
|
|
||||||
|
let fileManager: FileManager
|
||||||
|
let destinationURL: URL
|
||||||
|
private let bundledCLIURLProvider: () -> URL?
|
||||||
|
private let expectedBundledCLIPath: String
|
||||||
|
private let privilegedInstaller: PrivilegedInstallHandler
|
||||||
|
private let privilegedUninstaller: PrivilegedUninstallHandler
|
||||||
|
|
||||||
|
init(
|
||||||
|
fileManager: FileManager = .default,
|
||||||
|
destinationURL: URL = URL(fileURLWithPath: "/usr/local/bin/cmux"),
|
||||||
|
bundledCLIURLProvider: @escaping () -> URL? = {
|
||||||
|
CmuxCLIPathInstaller.defaultBundledCLIURL()
|
||||||
|
},
|
||||||
|
expectedBundledCLIPath: String = CmuxCLIPathInstaller.defaultBundledCLIExpectedPath(),
|
||||||
|
privilegedInstaller: PrivilegedInstallHandler? = nil,
|
||||||
|
privilegedUninstaller: PrivilegedUninstallHandler? = nil
|
||||||
|
) {
|
||||||
|
self.fileManager = fileManager
|
||||||
|
self.destinationURL = destinationURL
|
||||||
|
self.bundledCLIURLProvider = bundledCLIURLProvider
|
||||||
|
self.expectedBundledCLIPath = expectedBundledCLIPath
|
||||||
|
self.privilegedInstaller = privilegedInstaller ?? Self.installWithAdministratorPrivileges(sourceURL:destinationURL:)
|
||||||
|
self.privilegedUninstaller = privilegedUninstaller ?? Self.uninstallWithAdministratorPrivileges(destinationURL:)
|
||||||
|
}
|
||||||
|
|
||||||
|
var destinationPath: String {
|
||||||
|
destinationURL.path
|
||||||
|
}
|
||||||
|
|
||||||
|
func install() throws -> InstallOutcome {
|
||||||
|
let sourceURL = try resolveBundledCLIURL()
|
||||||
|
do {
|
||||||
|
try installWithoutAdministratorPrivileges(sourceURL: sourceURL)
|
||||||
|
return InstallOutcome(
|
||||||
|
usedAdministratorPrivileges: false,
|
||||||
|
destinationURL: destinationURL,
|
||||||
|
sourceURL: sourceURL
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
guard Self.isPermissionDenied(error) else { throw error }
|
||||||
|
try ensureDestinationIsNotDirectory()
|
||||||
|
try privilegedInstaller(sourceURL, destinationURL)
|
||||||
|
try verifyInstalledSymlinkTarget(sourceURL: sourceURL)
|
||||||
|
return InstallOutcome(
|
||||||
|
usedAdministratorPrivileges: true,
|
||||||
|
destinationURL: destinationURL,
|
||||||
|
sourceURL: sourceURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uninstall() throws -> UninstallOutcome {
|
||||||
|
do {
|
||||||
|
let removedExistingEntry = try uninstallWithoutAdministratorPrivileges()
|
||||||
|
return UninstallOutcome(
|
||||||
|
usedAdministratorPrivileges: false,
|
||||||
|
destinationURL: destinationURL,
|
||||||
|
removedExistingEntry: removedExistingEntry
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
guard Self.isPermissionDenied(error) else { throw error }
|
||||||
|
try ensureDestinationIsNotDirectory()
|
||||||
|
let removedExistingEntry = destinationEntryExists()
|
||||||
|
try privilegedUninstaller(destinationURL)
|
||||||
|
if destinationEntryExists() {
|
||||||
|
throw InstallerError.uninstallVerificationFailed(path: destinationURL.path)
|
||||||
|
}
|
||||||
|
return UninstallOutcome(
|
||||||
|
usedAdministratorPrivileges: true,
|
||||||
|
destinationURL: destinationURL,
|
||||||
|
removedExistingEntry: removedExistingEntry
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInstalled() -> Bool {
|
||||||
|
guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { return false }
|
||||||
|
guard let installedTargetURL = symlinkDestinationURL() else { return false }
|
||||||
|
return installedTargetURL == sourceURL
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveBundledCLIURL() throws -> URL {
|
||||||
|
guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else {
|
||||||
|
throw InstallerError.bundledCLIMissing(expectedPath: expectedBundledCLIPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
guard fileManager.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), !isDirectory.boolValue else {
|
||||||
|
throw InstallerError.bundledCLIMissing(expectedPath: sourceURL.path)
|
||||||
|
}
|
||||||
|
return sourceURL
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installWithoutAdministratorPrivileges(sourceURL: URL) throws {
|
||||||
|
try ensureDestinationParentDirectoryExists()
|
||||||
|
try ensureDestinationIsNotDirectory()
|
||||||
|
if destinationEntryExists() {
|
||||||
|
try fileManager.removeItem(at: destinationURL)
|
||||||
|
}
|
||||||
|
try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL)
|
||||||
|
try verifyInstalledSymlinkTarget(sourceURL: sourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func uninstallWithoutAdministratorPrivileges() throws -> Bool {
|
||||||
|
try ensureDestinationIsNotDirectory()
|
||||||
|
let existed = destinationEntryExists()
|
||||||
|
if existed {
|
||||||
|
try fileManager.removeItem(at: destinationURL)
|
||||||
|
}
|
||||||
|
if destinationEntryExists() {
|
||||||
|
throw InstallerError.uninstallVerificationFailed(path: destinationURL.path)
|
||||||
|
}
|
||||||
|
return existed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the destination path has any filesystem entry (including dangling symlinks).
|
||||||
|
/// `FileManager.fileExists` follows symlinks, so a dangling symlink returns false.
|
||||||
|
private func destinationEntryExists() -> Bool {
|
||||||
|
(try? fileManager.attributesOfItem(atPath: destinationURL.path)) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verifyInstalledSymlinkTarget(sourceURL: URL) throws {
|
||||||
|
guard let installedTargetURL = symlinkDestinationURL(),
|
||||||
|
installedTargetURL == sourceURL.standardizedFileURL else {
|
||||||
|
throw InstallerError.installVerificationFailed(path: destinationURL.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func symlinkDestinationURL() -> URL? {
|
||||||
|
guard fileManager.fileExists(atPath: destinationURL.path) else { return nil }
|
||||||
|
guard let destinationPath = try? fileManager.destinationOfSymbolicLink(atPath: destinationURL.path) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return URL(
|
||||||
|
fileURLWithPath: destinationPath,
|
||||||
|
relativeTo: destinationURL.deletingLastPathComponent()
|
||||||
|
).standardizedFileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureDestinationParentDirectoryExists() throws {
|
||||||
|
let parentURL = destinationURL.deletingLastPathComponent()
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
if fileManager.fileExists(atPath: parentURL.path, isDirectory: &isDirectory) {
|
||||||
|
guard isDirectory.boolValue else {
|
||||||
|
throw InstallerError.destinationParentNotDirectory(path: parentURL.path)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureDestinationIsNotDirectory() throws {
|
||||||
|
guard let values = try resourceValuesIfFileExists(
|
||||||
|
at: destinationURL,
|
||||||
|
keys: [.isDirectoryKey, .isSymbolicLinkKey]
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if values.isDirectory == true, values.isSymbolicLink != true {
|
||||||
|
throw InstallerError.destinationIsDirectory(path: destinationURL.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resourceValuesIfFileExists(
|
||||||
|
at url: URL,
|
||||||
|
keys: Set<URLResourceKey>
|
||||||
|
) throws -> URLResourceValues? {
|
||||||
|
do {
|
||||||
|
return try url.resourceValues(forKeys: keys)
|
||||||
|
} catch {
|
||||||
|
let nsError = error as NSError
|
||||||
|
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if nsError.domain == NSPOSIXErrorDomain,
|
||||||
|
POSIXErrorCode(rawValue: Int32(nsError.code)) == .ENOENT {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultBundledCLIURL(bundle: Bundle = .main) -> URL? {
|
||||||
|
bundle.resourceURL?.appendingPathComponent("bin/cmux", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultBundledCLIExpectedPath(bundle: Bundle = .main) -> String {
|
||||||
|
bundle.bundleURL
|
||||||
|
.appendingPathComponent("Contents/Resources/bin/cmux", isDirectory: false)
|
||||||
|
.path
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func installWithAdministratorPrivileges(sourceURL: URL, destinationURL: URL) throws {
|
||||||
|
let destinationPath = destinationURL.path
|
||||||
|
let parentPath = destinationURL.deletingLastPathComponent().path
|
||||||
|
let command = "/bin/mkdir -p \(shellQuoted(parentPath)) && " +
|
||||||
|
"/bin/rm -f \(shellQuoted(destinationPath)) && " +
|
||||||
|
"/bin/ln -s \(shellQuoted(sourceURL.path)) \(shellQuoted(destinationPath))"
|
||||||
|
try runPrivilegedShellCommand(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func uninstallWithAdministratorPrivileges(destinationURL: URL) throws {
|
||||||
|
let command = "/bin/rm -f \(shellQuoted(destinationURL.path))"
|
||||||
|
try runPrivilegedShellCommand(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runPrivilegedShellCommand(_ command: String) throws {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||||
|
process.arguments = [
|
||||||
|
"-e", "on run argv",
|
||||||
|
"-e", "do shell script (item 1 of argv) with administrator privileges",
|
||||||
|
"-e", "end run",
|
||||||
|
command
|
||||||
|
]
|
||||||
|
let stdout = Pipe()
|
||||||
|
let stderr = Pipe()
|
||||||
|
process.standardOutput = stdout
|
||||||
|
process.standardError = stderr
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
guard process.terminationStatus == 0 else {
|
||||||
|
let stderrText = String(
|
||||||
|
data: stderr.fileHandleForReading.readDataToEndOfFile(),
|
||||||
|
encoding: .utf8
|
||||||
|
)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let stdoutText = String(
|
||||||
|
data: stdout.fileHandleForReading.readDataToEndOfFile(),
|
||||||
|
encoding: .utf8
|
||||||
|
)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let details = stderrText.isEmpty ? stdoutText : stderrText
|
||||||
|
let message = details.isEmpty
|
||||||
|
? "osascript exited with status \(process.terminationStatus)."
|
||||||
|
: details
|
||||||
|
throw InstallerError.privilegedCommandFailed(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shellQuoted(_ value: String) -> String {
|
||||||
|
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isPermissionDenied(_ error: Error) -> Bool {
|
||||||
|
isPermissionDenied(error as NSError)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isPermissionDenied(_ error: NSError) -> Bool {
|
||||||
|
if error.domain == NSPOSIXErrorDomain,
|
||||||
|
let code = POSIXErrorCode(rawValue: Int32(error.code)),
|
||||||
|
code == .EACCES || code == .EPERM || code == .EROFS {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if error.domain == NSCocoaErrorDomain {
|
||||||
|
switch error.code {
|
||||||
|
case NSFileWriteNoPermissionError, NSFileReadNoPermissionError, NSFileWriteVolumeReadOnlyError:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError {
|
||||||
|
return isPermissionDenied(underlying)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private extension NSScreen {
|
private extension NSScreen {
|
||||||
var cmuxDisplayID: UInt32? {
|
var cmuxDisplayID: UInt32? {
|
||||||
let key = NSDeviceDescriptionKey("NSScreenNumber")
|
let key = NSDeviceDescriptionKey("NSScreenNumber")
|
||||||
|
|
@ -3467,6 +3782,79 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
updateController.attemptUpdate()
|
updateController.attemptUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isCmuxCLIInstalledInPATH() -> Bool {
|
||||||
|
CmuxCLIPathInstaller().isInstalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func installCmuxCLIInPath(_ sender: Any?) {
|
||||||
|
let installer = CmuxCLIPathInstaller()
|
||||||
|
do {
|
||||||
|
let outcome = try installer.install()
|
||||||
|
var informativeText = """
|
||||||
|
Created symlink:
|
||||||
|
|
||||||
|
\(outcome.destinationURL.path) -> \(outcome.sourceURL.path)
|
||||||
|
"""
|
||||||
|
if outcome.usedAdministratorPrivileges {
|
||||||
|
informativeText += "\n\nAdministrator privileges were required to write to /usr/local/bin."
|
||||||
|
}
|
||||||
|
presentCLIPathAlert(
|
||||||
|
title: "cmux CLI Installed",
|
||||||
|
informativeText: informativeText,
|
||||||
|
style: .informational
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
presentCLIPathAlert(
|
||||||
|
title: "Couldn't Install cmux CLI",
|
||||||
|
informativeText: error.localizedDescription,
|
||||||
|
style: .warning
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func uninstallCmuxCLIInPath(_ sender: Any?) {
|
||||||
|
let installer = CmuxCLIPathInstaller()
|
||||||
|
do {
|
||||||
|
let outcome = try installer.uninstall()
|
||||||
|
let prefix = outcome.removedExistingEntry
|
||||||
|
? "Removed \(outcome.destinationURL.path)."
|
||||||
|
: "No cmux CLI symlink was found at \(outcome.destinationURL.path)."
|
||||||
|
var informativeText = prefix
|
||||||
|
if outcome.usedAdministratorPrivileges {
|
||||||
|
informativeText += "\n\nAdministrator privileges were required to modify /usr/local/bin."
|
||||||
|
}
|
||||||
|
presentCLIPathAlert(
|
||||||
|
title: "cmux CLI Uninstalled",
|
||||||
|
informativeText: informativeText,
|
||||||
|
style: .informational
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
presentCLIPathAlert(
|
||||||
|
title: "Couldn't Uninstall cmux CLI",
|
||||||
|
informativeText: error.localizedDescription,
|
||||||
|
style: .warning
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentCLIPathAlert(
|
||||||
|
title: String,
|
||||||
|
informativeText: String,
|
||||||
|
style: NSAlert.Style
|
||||||
|
) {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = style
|
||||||
|
alert.messageText = title
|
||||||
|
alert.informativeText = informativeText
|
||||||
|
alert.addButton(withTitle: "OK")
|
||||||
|
|
||||||
|
if let window = NSApp.keyWindow ?? NSApp.mainWindow {
|
||||||
|
alert.beginSheetModal(for: window, completionHandler: nil)
|
||||||
|
} else {
|
||||||
|
_ = alert.runModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc func restartSocketListener(_ sender: Any?) {
|
@objc func restartSocketListener(_ sender: Any?) {
|
||||||
guard tabManager != nil else {
|
guard tabManager != nil else {
|
||||||
NSSound.beep()
|
NSSound.beep()
|
||||||
|
|
|
||||||
|
|
@ -3504,6 +3504,24 @@ struct ContentView: View {
|
||||||
keywords: ["create", "new", "window"]
|
keywords: ["create", "new", "window"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
contributions.append(
|
||||||
|
CommandPaletteCommandContribution(
|
||||||
|
commandId: "palette.installCLI",
|
||||||
|
title: constant("Shell Command: Install 'cmux' in PATH"),
|
||||||
|
subtitle: constant("CLI"),
|
||||||
|
keywords: ["install", "cli", "path", "shell", "command", "symlink"],
|
||||||
|
when: { _ in !(AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
contributions.append(
|
||||||
|
CommandPaletteCommandContribution(
|
||||||
|
commandId: "palette.uninstallCLI",
|
||||||
|
title: constant("Shell Command: Uninstall 'cmux' from PATH"),
|
||||||
|
subtitle: constant("CLI"),
|
||||||
|
keywords: ["uninstall", "remove", "cli", "path", "shell", "command", "symlink"],
|
||||||
|
when: { _ in AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false }
|
||||||
|
)
|
||||||
|
)
|
||||||
contributions.append(
|
contributions.append(
|
||||||
CommandPaletteCommandContribution(
|
CommandPaletteCommandContribution(
|
||||||
commandId: "palette.newTerminalTab",
|
commandId: "palette.newTerminalTab",
|
||||||
|
|
@ -4021,6 +4039,12 @@ struct ContentView: View {
|
||||||
registry.register(commandId: "palette.newWindow") {
|
registry.register(commandId: "palette.newWindow") {
|
||||||
AppDelegate.shared?.openNewMainWindow(nil)
|
AppDelegate.shared?.openNewMainWindow(nil)
|
||||||
}
|
}
|
||||||
|
registry.register(commandId: "palette.installCLI") {
|
||||||
|
AppDelegate.shared?.installCmuxCLIInPath(nil)
|
||||||
|
}
|
||||||
|
registry.register(commandId: "palette.uninstallCLI") {
|
||||||
|
AppDelegate.shared?.uninstallCmuxCLIInPath(nil)
|
||||||
|
}
|
||||||
registry.register(commandId: "palette.newTerminalTab") {
|
registry.register(commandId: "palette.newTerminalTab") {
|
||||||
tabManager.newSurface()
|
tabManager.newSurface()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,3 +240,94 @@ final class SocketControlPasswordStoreTests: XCTestCase {
|
||||||
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret")
|
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class CmuxCLIPathInstallerTests: XCTestCase {
|
||||||
|
func testInstallAndUninstallRoundTripWithoutAdministratorPrivileges() throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let root = fileManager.temporaryDirectory
|
||||||
|
.appendingPathComponent("cmux-cli-installer-tests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
defer { try? fileManager.removeItem(at: root) }
|
||||||
|
|
||||||
|
let bundledCLIURL = root
|
||||||
|
.appendingPathComponent("cmux.app/Contents/Resources/bin/cmux", isDirectory: false)
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: bundledCLIURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "#!/bin/sh\necho cmux\n".write(to: bundledCLIURL, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let destinationURL = root.appendingPathComponent("usr/local/bin/cmux", isDirectory: false)
|
||||||
|
|
||||||
|
var privilegedInstallCallCount = 0
|
||||||
|
var privilegedUninstallCallCount = 0
|
||||||
|
let installer = CmuxCLIPathInstaller(
|
||||||
|
fileManager: fileManager,
|
||||||
|
destinationURL: destinationURL,
|
||||||
|
bundledCLIURLProvider: { bundledCLIURL },
|
||||||
|
expectedBundledCLIPath: bundledCLIURL.path,
|
||||||
|
privilegedInstaller: { _, _ in privilegedInstallCallCount += 1 },
|
||||||
|
privilegedUninstaller: { _ in privilegedUninstallCallCount += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
let installOutcome = try installer.install()
|
||||||
|
XCTAssertFalse(installOutcome.usedAdministratorPrivileges)
|
||||||
|
XCTAssertEqual(privilegedInstallCallCount, 0)
|
||||||
|
XCTAssertTrue(installer.isInstalled())
|
||||||
|
XCTAssertEqual(
|
||||||
|
try fileManager.destinationOfSymbolicLink(atPath: destinationURL.path),
|
||||||
|
bundledCLIURL.path
|
||||||
|
)
|
||||||
|
|
||||||
|
let uninstallOutcome = try installer.uninstall()
|
||||||
|
XCTAssertFalse(uninstallOutcome.usedAdministratorPrivileges)
|
||||||
|
XCTAssertTrue(uninstallOutcome.removedExistingEntry)
|
||||||
|
XCTAssertEqual(privilegedUninstallCallCount, 0)
|
||||||
|
XCTAssertFalse(fileManager.fileExists(atPath: destinationURL.path))
|
||||||
|
XCTAssertFalse(installer.isInstalled())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInstallFallsBackToAdministratorFlowWhenDestinationIsNotWritable() throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let root = fileManager.temporaryDirectory
|
||||||
|
.appendingPathComponent("cmux-cli-installer-tests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
defer { try? fileManager.removeItem(at: root) }
|
||||||
|
|
||||||
|
let bundledCLIURL = root
|
||||||
|
.appendingPathComponent("cmux.app/Contents/Resources/bin/cmux", isDirectory: false)
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: bundledCLIURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "#!/bin/sh\necho cmux\n".write(to: bundledCLIURL, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let destinationURL = root.appendingPathComponent("usr/local/bin/cmux", isDirectory: false)
|
||||||
|
let destinationDir = destinationURL.deletingLastPathComponent()
|
||||||
|
try fileManager.createDirectory(at: destinationDir, withIntermediateDirectories: true)
|
||||||
|
try fileManager.setAttributes([.posixPermissions: 0o555], ofItemAtPath: destinationDir.path)
|
||||||
|
defer {
|
||||||
|
try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationDir.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var privilegedInstallCallCount = 0
|
||||||
|
let installer = CmuxCLIPathInstaller(
|
||||||
|
fileManager: fileManager,
|
||||||
|
destinationURL: destinationURL,
|
||||||
|
bundledCLIURLProvider: { bundledCLIURL },
|
||||||
|
expectedBundledCLIPath: bundledCLIURL.path,
|
||||||
|
privilegedInstaller: { sourceURL, privilegedDestinationURL in
|
||||||
|
privilegedInstallCallCount += 1
|
||||||
|
XCTAssertEqual(sourceURL.standardizedFileURL, bundledCLIURL.standardizedFileURL)
|
||||||
|
XCTAssertEqual(privilegedDestinationURL.standardizedFileURL, destinationURL.standardizedFileURL)
|
||||||
|
try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationDir.path)
|
||||||
|
try fileManager.createSymbolicLink(at: privilegedDestinationURL, withDestinationURL: sourceURL)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
let installOutcome = try installer.install()
|
||||||
|
XCTAssertTrue(installOutcome.usedAdministratorPrivileges)
|
||||||
|
XCTAssertEqual(privilegedInstallCallCount, 1)
|
||||||
|
XCTAssertTrue(installer.isInstalled())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue