From 2535e3da420ee82af3e68349b718ad939fdbff6e Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Tue, 1 Oct 2024 18:55:04 -0400 Subject: [PATCH] Use Swift Argument Parser instead of Commandant. Command structs are nested types of Mas. Renamed structs. Limit code visibility as much as possible. Standardize variable names. Standardize spacing. Fix a few tests. Disable a useless test. Remove unnecessary test stdout output. Get swift-format from Brewfile instead of from Package.swift since swift-format depends on an old version of swift-argument-parser. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Package.resolved | 34 ++-- Package.swift | 8 +- Sources/mas/Commands/Account.swift | 41 +++-- Sources/mas/Commands/Home.swift | 103 +++++------- Sources/mas/Commands/Info.swift | 75 ++++----- Sources/mas/Commands/Install.swift | 80 ++++------ Sources/mas/Commands/List.swift | 53 +++---- Sources/mas/Commands/Lucky.swift | 143 +++++++---------- Sources/mas/Commands/Open.swift | 141 +++++++---------- Sources/mas/Commands/Outdated.swift | 121 ++++++-------- Sources/mas/Commands/Purchase.swift | 72 ++++----- Sources/mas/Commands/Reset.swift | 143 +++++++++-------- Sources/mas/Commands/Search.swift | 82 ++++------ Sources/mas/Commands/SignIn.swift | 64 ++++---- Sources/mas/Commands/SignOut.swift | 38 +++-- Sources/mas/Commands/Uninstall.swift | 97 +++++------- Sources/mas/Commands/Upgrade.swift | 148 ++++++++---------- Sources/mas/Commands/Vendor.swift | 111 ++++++------- Sources/mas/Commands/Version.swift | 29 ++-- Sources/mas/Errors/MASError.swift | 4 +- Sources/mas/Formatters/Utilities.swift | 4 +- Sources/mas/Mas.swift | 33 +++- .../Network/URLSession+NetworkSession.swift | 2 +- Sources/mas/main.swift | 37 ----- ...untCommandSpec.swift => AccountSpec.swift} | 11 +- .../{HomeCommandSpec.swift => HomeSpec.swift} | 39 +++-- .../{InfoCommandSpec.swift => InfoSpec.swift} | 40 ++--- .../Commands/InstallCommandSpec.swift | 13 +- .../{ListCommandSpec.swift => ListSpec.swift} | 11 +- ...LuckyCommandSpec.swift => LuckySpec.swift} | 11 +- .../{OpenCommandSpec.swift => OpenSpec.swift} | 46 +++--- ...edCommandSpec.swift => OutdatedSpec.swift} | 13 +- .../Commands/PurchaseCommandSpec.swift | 13 +- ...ResetCommandSpec.swift => ResetSpec.swift} | 11 +- ...archCommandSpec.swift => SearchSpec.swift} | 29 ++-- ...gnInCommandSpec.swift => SignInSpec.swift} | 11 +- ...OutCommandSpec.swift => SignOutSpec.swift} | 11 +- ...lCommandSpec.swift => UninstallSpec.swift} | 63 ++++---- ...adeCommandSpec.swift => UpgradeSpec.swift} | 11 +- ...ndorCommandSpec.swift => VendorSpec.swift} | 41 ++--- ...ionCommandSpec.swift => VersionSpec.swift} | 11 +- Tests/masTests/OutputListenerSpec.swift | 12 +- 42 files changed, 952 insertions(+), 1108 deletions(-) delete mode 100644 Sources/mas/main.swift rename Tests/masTests/Commands/{AccountCommandSpec.swift => AccountSpec.swift} (69%) rename Tests/masTests/Commands/{HomeCommandSpec.swift => HomeSpec.swift} (53%) rename Tests/masTests/Commands/{InfoCommandSpec.swift => InfoSpec.swift} (63%) rename Tests/masTests/Commands/{ListCommandSpec.swift => ListSpec.swift} (62%) rename Tests/masTests/Commands/{LuckyCommandSpec.swift => LuckySpec.swift} (67%) rename Tests/masTests/Commands/{OpenCommandSpec.swift => OpenSpec.swift} (56%) rename Tests/masTests/Commands/{OutdatedCommandSpec.swift => OutdatedSpec.swift} (57%) rename Tests/masTests/Commands/{ResetCommandSpec.swift => ResetSpec.swift} (62%) rename Tests/masTests/Commands/{SearchCommandSpec.swift => SearchSpec.swift} (52%) rename Tests/masTests/Commands/{SignInCommandSpec.swift => SignInSpec.swift} (65%) rename Tests/masTests/Commands/{SignOutCommandSpec.swift => SignOutSpec.swift} (61%) rename Tests/masTests/Commands/{UninstallCommandSpec.swift => UninstallSpec.swift} (53%) rename Tests/masTests/Commands/{UpgradeCommandSpec.swift => UpgradeSpec.swift} (59%) rename Tests/masTests/Commands/{VendorCommandSpec.swift => VendorSpec.swift} (53%) rename Tests/masTests/Commands/{VersionCommandSpec.swift => VersionSpec.swift} (62%) diff --git a/Package.resolved b/Package.resolved index a4d32c7..80d3efe 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,21 +1,12 @@ { "pins" : [ - { - "identity" : "commandant", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Carthage/Commandant.git", - "state" : { - "revision" : "a1671cf728db837cf5ec1980a80d276bbba748f6", - "version" : "0.18.0" - } - }, { "identity" : "cwlcatchexception", "kind" : "remoteSourceControl", "location" : "https://github.com/mattgallagher/CwlCatchException.git", "state" : { - "revision" : "35f9e770f54ce62dd8526470f14c6e137cef3eea", - "version" : "2.1.1" + "revision" : "07b2ba21d361c223e25e3c1e924288742923f08c", + "version" : "2.2.1" } }, { @@ -23,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", "state" : { - "revision" : "c21f7bab5ca8eee0a9998bbd17ca1d0eb45d4688", - "version" : "2.1.0" + "revision" : "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071", + "version" : "2.2.2" } }, { @@ -41,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/mxcl/PromiseKit.git", "state" : { - "revision" : "43772616c46a44a9977e41924ae01d0e55f2f9ca", - "version" : "6.18.1" + "revision" : "8a98e31a47854d3180882c8068cc4d9381bf382d", + "version" : "6.22.1" } }, { @@ -63,13 +54,22 @@ "version" : "2.1.1" } }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" + } + }, { "identity" : "version", "kind" : "remoteSourceControl", "location" : "https://github.com/mxcl/Version.git", "state" : { - "revision" : "1fe824b80d89201652e7eca7c9252269a1d85e25", - "version" : "2.0.1" + "revision" : "303a0f916772545e1e8667d3104f83be708a723c", + "version" : "2.1.0" } } ], diff --git a/Package.swift b/Package.swift index 402c576..f56b7ec 100644 --- a/Package.swift +++ b/Package.swift @@ -17,11 +17,11 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/Carthage/Commandant.git", from: "0.18.0"), .package(url: "https://github.com/Quick/Nimble.git", from: "10.0.0"), .package(url: "https://github.com/Quick/Quick.git", from: "5.0.0"), - .package(url: "https://github.com/mxcl/PromiseKit.git", from: "6.16.2"), - .package(url: "https://github.com/mxcl/Version.git", from: "2.0.1"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + .package(url: "https://github.com/mxcl/PromiseKit.git", from: "6.22.1"), + .package(url: "https://github.com/mxcl/Version.git", from: "2.1.0"), .package(url: "https://github.com/sharplet/Regex.git", from: "2.1.1"), ], targets: [ @@ -30,7 +30,7 @@ let package = Package( .executableTarget( name: "mas", dependencies: [ - "Commandant", + .product(name: "ArgumentParser", package: "swift-argument-parser"), "PromiseKit", "Regex", "Version", diff --git a/Sources/mas/Commands/Account.swift b/Sources/mas/Commands/Account.swift index 55d9c5a..8306bb2 100644 --- a/Sources/mas/Commands/Account.swift +++ b/Sources/mas/Commands/Account.swift @@ -6,27 +6,36 @@ // Copyright (c) 2015 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser import StoreFoundation -public struct AccountCommand: CommandProtocol { - public typealias Options = NoOptions - public let verb = "account" - public let function = "Prints the primary account Apple ID" +extension Mas { + struct Account: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Prints the primary account Apple ID" + ) - /// Runs the command. - public func run(_: Options) -> Result { - if #available(macOS 12, *) { - // Account information is no longer available as of Monterey. - // https://github.com/mas-cli/mas/issues/417 - return .failure(.notSupported) + /// Runs the command. + func run() throws { + let result = runInternal() + if case .failure = result { + try result.get() + } } - do { - print(try ISStoreAccount.primaryAccount.wait().identifier) - return .success(()) - } catch { - return .failure(error as? MASError ?? .failed(error: error as NSError)) + func runInternal() -> Result { + if #available(macOS 12, *) { + // Account information is no longer available as of Monterey. + // https://github.com/mas-cli/mas/issues/417 + return .failure(.notSupported) + } + + do { + print(try ISStoreAccount.primaryAccount.wait().identifier) + return .success(()) + } catch { + return .failure(error as? MASError ?? .failed(error: error as NSError)) + } } } } diff --git a/Sources/mas/Commands/Home.swift b/Sources/mas/Commands/Home.swift index 896ae30..417ca8f 100644 --- a/Sources/mas/Commands/Home.swift +++ b/Sources/mas/Commands/Home.swift @@ -6,74 +6,53 @@ // Copyright © 2016 mas-cli. All rights reserved. // -import Commandant +import ArgumentParser -/// Opens app page on MAS Preview. Uses the iTunes Lookup API: -/// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup -public struct HomeCommand: CommandProtocol { - public typealias Options = HomeOptions - - public let verb = "home" - public let function = "Opens MAS Preview app page in a browser" - - private let storeSearch: StoreSearch - private var openCommand: ExternalCommand - - public init() { - self.init( - storeSearch: MasStoreSearch(), - openCommand: OpenSystemCommand() +extension Mas { + /// Opens app page on MAS Preview. Uses the iTunes Lookup API: + /// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup + struct Home: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Opens MAS Preview app page in a browser" ) - } - /// Designated initializer. - init( - storeSearch: StoreSearch = MasStoreSearch(), - openCommand: ExternalCommand = OpenSystemCommand() - ) { - self.storeSearch = storeSearch - self.openCommand = openCommand - } + @Argument(help: "ID of app to show on MAS Preview") + var appId: Int - /// Runs the command. - public func run(_ options: HomeOptions) -> Result { - do { - guard let result = try storeSearch.lookup(app: options.appId).wait() else { - return .failure(.noSearchResultsFound) + /// Runs the command. + func run() throws { + let result = run(storeSearch: MasStoreSearch(), openCommand: OpenSystemCommand()) + if case .failure = result { + try result.get() } - - do { - try openCommand.run(arguments: result.trackViewUrl) - } catch { - printError("Unable to launch open command") - return .failure(.searchFailed) - } - if openCommand.failed { - let reason = openCommand.process.terminationReason - printError("Open failed: (\(reason)) \(openCommand.stderr)") - return .failure(.searchFailed) - } - } catch { - // Bubble up MASErrors - if let error = error as? MASError { - return .failure(error) - } - return .failure(.searchFailed) } - return .success(()) - } -} - -public struct HomeOptions: OptionsProtocol { - let appId: Int - - static func create(_ appId: Int) -> HomeOptions { - HomeOptions(appId: appId) - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Argument(usage: "ID of app to show on MAS Preview") + func run(storeSearch: StoreSearch, openCommand: ExternalCommand) -> Result { + do { + guard let result = try storeSearch.lookup(app: appId).wait() else { + return .failure(.noSearchResultsFound) + } + + do { + try openCommand.run(arguments: result.trackViewUrl) + } catch { + printError("Unable to launch open command") + return .failure(.searchFailed) + } + if openCommand.failed { + let reason = openCommand.process.terminationReason + printError("Open failed: (\(reason)) \(openCommand.stderr)") + return .failure(.searchFailed) + } + } catch { + // Bubble up MASErrors + if let error = error as? MASError { + return .failure(error) + } + return .failure(.searchFailed) + } + + return .success(()) + } } } diff --git a/Sources/mas/Commands/Info.swift b/Sources/mas/Commands/Info.swift index 5530c4d..0083220 100644 --- a/Sources/mas/Commands/Info.swift +++ b/Sources/mas/Commands/Info.swift @@ -6,55 +6,44 @@ // Copyright © 2016 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser import Foundation -/// Displays app details. Uses the iTunes Lookup API: -/// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup -public struct InfoCommand: CommandProtocol { - public let verb = "info" - public let function = "Display app information from the Mac App Store" +extension Mas { + /// Displays app details. Uses the iTunes Lookup API: + /// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup + struct Info: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Display app information from the Mac App Store" + ) - private let storeSearch: StoreSearch + @Argument(help: "ID of app to show info") + var appId: Int - public init() { - self.init(storeSearch: MasStoreSearch()) - } - - /// Designated initializer. - init(storeSearch: StoreSearch = MasStoreSearch()) { - self.storeSearch = storeSearch - } - - /// Runs the command. - public func run(_ options: InfoOptions) -> Result { - do { - guard let result = try storeSearch.lookup(app: options.appId).wait() else { - return .failure(.noSearchResultsFound) + /// Runs the command. + func run() throws { + let result = run(storeSearch: MasStoreSearch()) + if case .failure = result { + try result.get() } - - print(AppInfoFormatter.format(app: result)) - } catch { - // Bubble up MASErrors - if let error = error as? MASError { - return .failure(error) - } - return .failure(.searchFailed) } - return .success(()) - } -} - -public struct InfoOptions: OptionsProtocol { - let appId: Int - - static func create(_ appId: Int) -> InfoOptions { - InfoOptions(appId: appId) - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Argument(usage: "ID of app to show info") + func run(storeSearch: StoreSearch) -> Result { + do { + guard let result = try storeSearch.lookup(app: appId).wait() else { + return .failure(.noSearchResultsFound) + } + + print(AppInfoFormatter.format(app: result)) + } catch { + // Bubble up MASErrors + if let error = error as? MASError { + return .failure(error) + } + return .failure(.searchFailed) + } + + return .success(()) + } } } diff --git a/Sources/mas/Commands/Install.swift b/Sources/mas/Commands/Install.swift index 12b5378..b3d79af 100644 --- a/Sources/mas/Commands/Install.swift +++ b/Sources/mas/Commands/Install.swift @@ -6,63 +6,47 @@ // Copyright (c) 2015 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser import CommerceKit -/// Installs previously purchased apps from the Mac App Store. -public struct InstallCommand: CommandProtocol { - public typealias Options = InstallOptions - public let verb = "install" - public let function = "Install from the Mac App Store" +extension Mas { + /// Installs previously purchased apps from the Mac App Store. + struct Install: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Install from the Mac App Store" + ) - private let appLibrary: AppLibrary + @Flag(help: "force reinstall") + var force = false + @Argument(help: "app ID(s) to install") + var appIds: [UInt64] - /// Public initializer. - public init() { - self.init(appLibrary: MasAppLibrary()) - } + /// Runs the command. + func run() throws { + let result = run(appLibrary: MasAppLibrary()) + if case .failure = result { + try result.get() + } + } - /// Internal initializer. - /// - Parameter appLibrary: AppLibrary manager. - init(appLibrary: AppLibrary = MasAppLibrary()) { - self.appLibrary = appLibrary - } + func run(appLibrary: AppLibrary) -> Result { + // Try to download applications with given identifiers and collect results + let appIds = appIds.filter { appId in + if let product = appLibrary.installedApp(forId: appId), !force { + printWarning("\(product.appName) is already installed") + return false + } - /// Runs the command. - public func run(_ options: Options) -> Result { - // Try to download applications with given identifiers and collect results - let appIds = options.appIds.filter { appId in - if let product = appLibrary.installedApp(forId: appId), !options.forceInstall { - printWarning("\(product.appName) is already installed") - return false + return true } - return true - } + do { + try downloadAll(appIds).wait() + } catch { + return .failure(error as? MASError ?? .downloadFailed(error: error as NSError)) + } - do { - try downloadAll(appIds).wait() - } catch { - return .failure(error as? MASError ?? .downloadFailed(error: error as NSError)) + return .success(()) } - - return .success(()) - } -} - -public struct InstallOptions: OptionsProtocol { - let appIds: [UInt64] - let forceInstall: Bool - - public static func create(_ appIds: [Int]) -> (_ forceInstall: Bool) -> InstallOptions { - { forceInstall in - InstallOptions(appIds: appIds.map { UInt64($0) }, forceInstall: forceInstall) - } - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Argument(usage: "app ID(s) to install") - <*> mode <| Switch(flag: nil, key: "force", usage: "force reinstall") } } diff --git a/Sources/mas/Commands/List.swift b/Sources/mas/Commands/List.swift index e280a4b..564ddfe 100644 --- a/Sources/mas/Commands/List.swift +++ b/Sources/mas/Commands/List.swift @@ -6,39 +6,34 @@ // Copyright (c) 2015 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser -/// Command which lists all installed apps. -public struct ListCommand: CommandProtocol { - public typealias Options = NoOptions - public let verb = "list" - public let function = "Lists apps from the Mac App Store which are currently installed" +extension Mas { + /// Command which lists all installed apps. + struct List: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Lists apps from the Mac App Store which are currently installed" + ) - private let appLibrary: AppLibrary - - /// Public initializer. - /// - Parameter appLibrary: AppLibrary manager. - public init() { - self.init(appLibrary: MasAppLibrary()) - } - - /// Internal initializer. - /// - Parameter appLibrary: AppLibrary manager. - init(appLibrary: AppLibrary = MasAppLibrary()) { - self.appLibrary = appLibrary - } - - /// Runs the command. - public func run(_: Options) -> Result { - let products = appLibrary.installedApps - if products.isEmpty { - printError("No installed apps found") - return .success(()) + /// Runs the command. + func run() throws { + let result = run(appLibrary: MasAppLibrary()) + if case .failure = result { + try result.get() + } } - let output = AppListFormatter.format(products: products) - print(output) + func run(appLibrary: AppLibrary) -> Result { + let products = appLibrary.installedApps + if products.isEmpty { + printError("No installed apps found") + return .success(()) + } - return .success(()) + let output = AppListFormatter.format(products: products) + print(output) + + return .success(()) + } } } diff --git a/Sources/mas/Commands/Lucky.swift b/Sources/mas/Commands/Lucky.swift index f1c7805..d9d774a 100644 --- a/Sources/mas/Commands/Lucky.swift +++ b/Sources/mas/Commands/Lucky.swift @@ -6,101 +6,74 @@ // Copyright © 2016 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser import CommerceKit -/// Command which installs the first search result. This is handy as many MAS titles -/// can be long with embedded keywords. -public struct LuckyCommand: CommandProtocol { - public typealias Options = LuckyOptions - public let verb = "lucky" - public let function = "Install the first result from the Mac App Store" +extension Mas { + /// Command which installs the first search result. This is handy as many MAS titles + /// can be long with embedded keywords. + struct Lucky: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Install the first result from the Mac App Store" + ) - private let appLibrary: AppLibrary - private let storeSearch: StoreSearch + @Flag(help: "force reinstall") + var force = false + @Argument(help: "the app name to install") + var appName: String - public init() { - self.init(storeSearch: MasStoreSearch()) - } - - /// Designated initializer. - /// - Parameter storeSearch: Search manager. - init(storeSearch: StoreSearch = MasStoreSearch()) { - self.init(appLibrary: MasAppLibrary(), storeSearch: storeSearch) - } - - /// Internal initializer. - /// - Parameter appLibrary: AppLibrary manager. - /// - Parameter storeSearch: Search manager. - init( - appLibrary: AppLibrary = MasAppLibrary(), - storeSearch: StoreSearch = MasStoreSearch() - ) { - self.appLibrary = appLibrary - self.storeSearch = storeSearch - } - - /// Runs the command. - public func run(_ options: Options) -> Result { - var appId: Int? - - do { - let results = try storeSearch.search(for: options.appName).wait() - guard let result = results.first else { - printError("No results found") - return .failure(.noSearchResultsFound) + /// Runs the command. + func run() throws { + let result = run(appLibrary: MasAppLibrary(), storeSearch: MasStoreSearch()) + if case .failure = result { + try result.get() } - - appId = result.trackId - } catch { - // Bubble up MASErrors - if let error = error as? MASError { - return .failure(error) - } - return .failure(.searchFailed) } - guard let identifier = appId else { fatalError() } + func run(appLibrary: AppLibrary, storeSearch: StoreSearch) -> Result { + var appId: Int? - return install(UInt64(identifier), options: options) - } + do { + let results = try storeSearch.search(for: appName).wait() + guard let result = results.first else { + printError("No results found") + return .failure(.noSearchResultsFound) + } + + appId = result.trackId + } catch { + // Bubble up MASErrors + if let error = error as? MASError { + return .failure(error) + } + return .failure(.searchFailed) + } + + guard let identifier = appId else { fatalError() } + + return install(UInt64(identifier), appLibrary: appLibrary) + } + + /// Installs an app. + /// + /// - Parameters: + /// - appId: App identifier + /// - appLibrary: Library of installed apps + /// - Returns: Result of the operation. + fileprivate func install(_ appId: UInt64, appLibrary: AppLibrary) -> Result { + // Try to download applications with given identifiers and collect results + if let product = appLibrary.installedApp(forId: appId), !force { + printWarning("\(product.appName) is already installed") + return .success(()) + } + + do { + try downloadAll([appId]).wait() + } catch { + return .failure(error as? MASError ?? .downloadFailed(error: error as NSError)) + } - /// Installs an app. - /// - /// - Parameters: - /// - appId: App identifier - /// - options: command options. - /// - Returns: Result of the operation. - fileprivate func install(_ appId: UInt64, options: Options) -> Result { - // Try to download applications with given identifiers and collect results - if let product = appLibrary.installedApp(forId: appId), !options.forceInstall { - printWarning("\(product.appName) is already installed") return .success(()) } - - do { - try downloadAll([appId]).wait() - } catch { - return .failure(error as? MASError ?? .downloadFailed(error: error as NSError)) - } - - return .success(()) - } -} - -public struct LuckyOptions: OptionsProtocol { - let appName: String - let forceInstall: Bool - - public static func create(_ appName: String) -> (_ forceInstall: Bool) -> LuckyOptions { - { forceInstall in - LuckyOptions(appName: appName, forceInstall: forceInstall) - } - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Argument(usage: "the app name to install") - <*> mode <| Switch(flag: nil, key: "force", usage: "force reinstall") } } diff --git a/Sources/mas/Commands/Open.swift b/Sources/mas/Commands/Open.swift index 1a0817e..5d4dc64 100644 --- a/Sources/mas/Commands/Open.swift +++ b/Sources/mas/Commands/Open.swift @@ -6,97 +6,76 @@ // Copyright © 2016 mas-cli. All rights reserved. // -import Commandant +import ArgumentParser import Foundation private let markerValue = "appstore" private let masScheme = "macappstore" -/// Opens app page in MAS app. Uses the iTunes Lookup API: -/// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup -public struct OpenCommand: CommandProtocol { - public typealias Options = OpenOptions - - public let verb = "open" - public let function = "Opens app page in AppStore.app" - - private let storeSearch: StoreSearch - private var systemOpen: ExternalCommand - - public init() { - self.init( - storeSearch: MasStoreSearch(), - openCommand: OpenSystemCommand() +extension Mas { + /// Opens app page in MAS app. Uses the iTunes Lookup API: + /// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup + struct Open: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Opens app page in AppStore.app" ) - } - /// Designated initializer. - init( - storeSearch: StoreSearch = MasStoreSearch(), - openCommand: ExternalCommand = OpenSystemCommand() - ) { - self.storeSearch = storeSearch - systemOpen = openCommand - } + @Argument(help: "the app ID") + var appId: String = markerValue - /// Runs the command. - public func run(_ options: OpenOptions) -> Result { - do { - if options.appId == markerValue { - // If no app ID is given, just open the MAS GUI app - try systemOpen.run(arguments: masScheme + "://") - return .success(()) + /// Runs the command. + func run() throws { + let result = run(storeSearch: MasStoreSearch(), openCommand: OpenSystemCommand()) + if case .failure = result { + try result.get() } - - guard let appId = Int(options.appId) - else { - printError("Invalid app ID") - return .failure(.noSearchResultsFound) - } - - guard let result = try storeSearch.lookup(app: appId).wait() - else { - return .failure(.noSearchResultsFound) - } - - guard var url = URLComponents(string: result.trackViewUrl) - else { - return .failure(.searchFailed) - } - url.scheme = masScheme - - do { - try systemOpen.run(arguments: url.string!) - } catch { - printError("Unable to launch open command") - return .failure(.searchFailed) - } - if systemOpen.failed { - let reason = systemOpen.process.terminationReason - printError("Open failed: (\(reason)) \(systemOpen.stderr)") - return .failure(.searchFailed) - } - } catch { - // Bubble up MASErrors - if let error = error as? MASError { - return .failure(error) - } - return .failure(.searchFailed) } - return .success(()) - } -} - -public struct OpenOptions: OptionsProtocol { - var appId: String - - static func create(_ appId: String) -> OpenOptions { - OpenOptions(appId: appId) - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Argument(defaultValue: markerValue, usage: "the app ID") + func run(storeSearch: StoreSearch, openCommand: ExternalCommand) -> Result { + do { + if appId == markerValue { + // If no app ID is given, just open the MAS GUI app + try openCommand.run(arguments: masScheme + "://") + return .success(()) + } + + guard let appId = Int(appId) + else { + printError("Invalid app ID") + return .failure(.noSearchResultsFound) + } + + guard let result = try storeSearch.lookup(app: appId).wait() + else { + return .failure(.noSearchResultsFound) + } + + guard var url = URLComponents(string: result.trackViewUrl) + else { + return .failure(.searchFailed) + } + url.scheme = masScheme + + do { + try openCommand.run(arguments: url.string!) + } catch { + printError("Unable to launch open command") + return .failure(.searchFailed) + } + if openCommand.failed { + let reason = openCommand.process.terminationReason + printError("Open failed: (\(reason)) \(openCommand.stderr)") + return .failure(.searchFailed) + } + } catch { + // Bubble up MASErrors + if let error = error as? MASError { + return .failure(error) + } + return .failure(.searchFailed) + } + + return .success(()) + } } } diff --git a/Sources/mas/Commands/Outdated.swift b/Sources/mas/Commands/Outdated.swift index 893b4b7..42466d6 100644 --- a/Sources/mas/Commands/Outdated.swift +++ b/Sources/mas/Commands/Outdated.swift @@ -6,84 +6,67 @@ // Copyright (c) 2015 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser import Foundation import PromiseKit import enum Swift.Result -/// Command which displays a list of installed apps which have available updates -/// ready to be installed from the Mac App Store. -public struct OutdatedCommand: CommandProtocol { - public typealias Options = OutdatedOptions - public let verb = "outdated" - public let function = "Lists pending updates from the Mac App Store" +extension Mas { + /// Command which displays a list of installed apps which have available updates + /// ready to be installed from the Mac App Store. + struct Outdated: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Lists pending updates from the Mac App Store" + ) - private let appLibrary: AppLibrary - private let storeSearch: StoreSearch + @Flag(help: "Show warnings about apps") + var verbose = false - /// Public initializer. - public init() { - self.init(appLibrary: MasAppLibrary()) - } - - /// Internal initializer. - /// - Parameter appLibrary: AppLibrary manager. - /// - Parameter storeSearch: StoreSearch manager. - init(appLibrary: AppLibrary = MasAppLibrary(), storeSearch: StoreSearch = MasStoreSearch()) { - self.appLibrary = appLibrary - self.storeSearch = storeSearch - } - - /// Runs the command. - public func run(_ options: Options) -> Result { - let promises = appLibrary.installedApps.map { installedApp in - firstly { - storeSearch.lookup(app: installedApp.itemIdentifier.intValue) - }.done { storeApp in - guard let storeApp else { - if options.verbose { - printWarning( - """ - Identifier \(installedApp.itemIdentifier) not found in store. \ - Was expected to identify \(installedApp.appName). - """) - } - return - } - - if installedApp.isOutdatedWhenComparedTo(storeApp) { - print( - """ - \(installedApp.itemIdentifier) \(installedApp.appName) \ - (\(installedApp.bundleVersion) -> \(storeApp.version)) - """) - } + /// Runs the command. + func run() throws { + let result = run(appLibrary: MasAppLibrary(), storeSearch: MasStoreSearch()) + if case .failure = result { + try result.get() } } - return firstly { - when(fulfilled: promises) - }.map { - Result.success(()) - }.recover { error in - // Bubble up MASErrors - .value(Result.failure(error as? MASError ?? .searchFailed)) - }.wait() - } -} - -public struct OutdatedOptions: OptionsProtocol { - public typealias ClientError = MASError - - let verbose: Bool - - static func create(verbose: Bool) -> OutdatedOptions { - OutdatedOptions(verbose: verbose) - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Switch(flag: nil, key: "verbose", usage: "Show warnings about apps") + func run(appLibrary: AppLibrary, storeSearch: StoreSearch) -> Result { + let promises = appLibrary.installedApps.map { installedApp in + firstly { + storeSearch.lookup(app: installedApp.itemIdentifier.intValue) + }.done { storeApp in + guard let storeApp else { + if verbose { + printWarning( + """ + Identifier \(installedApp.itemIdentifier) not found in store. \ + Was expected to identify \(installedApp.appName). + """ + ) + } + return + } + + if installedApp.isOutdatedWhenComparedTo(storeApp) { + print( + """ + \(installedApp.itemIdentifier) \(installedApp.appName) \ + (\(installedApp.bundleVersion) -> \(storeApp.version)) + """ + ) + } + } + } + + return firstly { + when(fulfilled: promises) + }.map { + Result.success(()) + }.recover { error in + // Bubble up MASErrors + .value(Result.failure(error as? MASError ?? .searchFailed)) + }.wait() + } } } diff --git a/Sources/mas/Commands/Purchase.swift b/Sources/mas/Commands/Purchase.swift index 77df061..95dce65 100644 --- a/Sources/mas/Commands/Purchase.swift +++ b/Sources/mas/Commands/Purchase.swift @@ -6,58 +6,44 @@ // Copyright (c) 2017 Jakob Rieck. All rights reserved. // -import Commandant +import ArgumentParser import CommerceKit -public struct PurchaseCommand: CommandProtocol { - public typealias Options = PurchaseOptions - public let verb = "purchase" - public let function = "Purchase and download free apps from the Mac App Store" +extension Mas { + struct Purchase: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Purchase and download free apps from the Mac App Store" + ) - private let appLibrary: AppLibrary + @Argument(help: "app ID(s) to install") + var appIds: [UInt64] - /// Public initializer. - public init() { - self.init(appLibrary: MasAppLibrary()) - } + /// Runs the command. + func run() throws { + let result = run(appLibrary: MasAppLibrary()) + if case .failure = result { + try result.get() + } + } - /// Internal initializer. - /// - Parameter appLibrary: AppLibrary manager. - init(appLibrary: AppLibrary = MasAppLibrary()) { - self.appLibrary = appLibrary - } + func run(appLibrary: AppLibrary) -> Result { + // Try to download applications with given identifiers and collect results + let appIds = appIds.filter { appId in + if let product = appLibrary.installedApp(forId: appId) { + printWarning("\(product.appName) has already been purchased.") + return false + } - /// Runs the command. - public func run(_ options: Options) -> Result { - // Try to download applications with given identifiers and collect results - let appIds = options.appIds.filter { appId in - if let product = appLibrary.installedApp(forId: appId) { - printWarning("\(product.appName) has already been purchased.") - return false + return true } - return true - } + do { + try downloadAll(appIds, purchase: true).wait() + } catch { + return .failure(error as? MASError ?? .downloadFailed(error: error as NSError)) + } - do { - try downloadAll(appIds, purchase: true).wait() - } catch { - return .failure(error as? MASError ?? .downloadFailed(error: error as NSError)) + return .success(()) } - - return .success(()) - } -} - -public struct PurchaseOptions: OptionsProtocol { - let appIds: [UInt64] - - public static func create(_ appIds: [Int]) -> PurchaseOptions { - PurchaseOptions(appIds: appIds.map { UInt64($0) }) - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Argument(usage: "app ID(s) to install") } } diff --git a/Sources/mas/Commands/Reset.swift b/Sources/mas/Commands/Reset.swift index 212549d..a4f51bd 100644 --- a/Sources/mas/Commands/Reset.swift +++ b/Sources/mas/Commands/Reset.swift @@ -6,84 +6,83 @@ // Copyright © 2016 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser import CommerceKit -/// Kills several macOS processes as a means to reset the app store. -public struct ResetCommand: CommandProtocol { - public typealias Options = ResetOptions - public let verb = "reset" - public let function = "Resets the Mac App Store" +extension Mas { + /// Kills several macOS processes as a means to reset the app store. + struct Reset: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Resets the Mac App Store" + ) - /// Runs the command. - public func run(_ options: Options) -> Result { - // The "Reset Application" command in the Mac App Store debug menu performs - // the following steps - // - // - killall Dock - // - killall storeagent (storeagent no longer exists) - // - rm com.apple.appstore download directory - // - clear cookies (appears to be a no-op) - // - // As storeagent no longer exists we will implement a slight variant and kill all - // App Store-associated processes - // - storeaccountd - // - storeassetd - // - storedownloadd - // - storeinstalld - // - storelegacy + @Flag(help: "Enable debug mode") + var debug = false - // Kill processes - let killProcs = [ - "Dock", - "storeaccountd", - "storeassetd", - "storedownloadd", - "storeinstalld", - "storelegacy", - ] - - let kill = Process() - let stdout = Pipe() - let stderr = Pipe() - - kill.launchPath = "/usr/bin/killall" - kill.arguments = killProcs - kill.standardOutput = stdout - kill.standardError = stderr - - kill.launch() - kill.waitUntilExit() - - if kill.terminationStatus != 0, options.debug { - let output = stderr.fileHandleForReading.readDataToEndOfFile() - printInfo("killall failed:\r\n\(String(data: output, encoding: String.Encoding.utf8)!)") - } - - // Wipe Download Directory - if let directory = CKDownloadDirectory(nil) { - do { - try FileManager.default.removeItem(atPath: directory) - } catch { - if options.debug { - printError("removeItemAtPath:\"\(directory)\" failed, \(error)") - } + /// Runs the command. + func run() throws { + let result = runInternal() + if case .failure = result { + try result.get() } } - return .success(()) - } -} - -public struct ResetOptions: OptionsProtocol { - let debug: Bool - - public static func create(debug: Bool) -> ResetOptions { - ResetOptions(debug: debug) - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Switch(flag: nil, key: "debug", usage: "Enable debug mode") + func runInternal() -> Result { + // The "Reset Application" command in the Mac App Store debug menu performs + // the following steps + // + // - killall Dock + // - killall storeagent (storeagent no longer exists) + // - rm com.apple.appstore download directory + // - clear cookies (appears to be a no-op) + // + // As storeagent no longer exists we will implement a slight variant and kill all + // App Store-associated processes + // - storeaccountd + // - storeassetd + // - storedownloadd + // - storeinstalld + // - storelegacy + + // Kill processes + let killProcs = [ + "Dock", + "storeaccountd", + "storeassetd", + "storedownloadd", + "storeinstalld", + "storelegacy", + ] + + let kill = Process() + let stdout = Pipe() + let stderr = Pipe() + + kill.launchPath = "/usr/bin/killall" + kill.arguments = killProcs + kill.standardOutput = stdout + kill.standardError = stderr + + kill.launch() + kill.waitUntilExit() + + if kill.terminationStatus != 0, debug { + let output = stderr.fileHandleForReading.readDataToEndOfFile() + printInfo("killall failed:\r\n\(String(data: output, encoding: String.Encoding.utf8)!)") + } + + // Wipe Download Directory + if let directory = CKDownloadDirectory(nil) { + do { + try FileManager.default.removeItem(atPath: directory) + } catch { + if debug { + printError("removeItemAtPath:\"\(directory)\" failed, \(error)") + } + } + } + + return .success(()) + } } } diff --git a/Sources/mas/Commands/Search.swift b/Sources/mas/Commands/Search.swift index 3d2af78..a7535f7 100644 --- a/Sources/mas/Commands/Search.swift +++ b/Sources/mas/Commands/Search.swift @@ -6,62 +6,46 @@ // Copyright © 2016 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser -/// Search the Mac App Store using the iTunes Search API: -/// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/ -public struct SearchCommand: CommandProtocol { - public typealias Options = SearchOptions - public let verb = "search" - public let function = "Search for apps from the Mac App Store" +extension Mas { + /// Search the Mac App Store using the iTunes Search API: + /// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/ + struct Search: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Search for apps from the Mac App Store" + ) - private let storeSearch: StoreSearch + @Flag(help: "Show price of found apps") + var price = false + @Argument(help: "the app name to search") + var appName: String - public init() { - self.init(storeSearch: MasStoreSearch()) - } - - /// Designated initializer. - /// - /// - Parameter storeSearch: Search manager. - init(storeSearch: StoreSearch = MasStoreSearch()) { - self.storeSearch = storeSearch - } - - public func run(_ options: Options) -> Result { - do { - let results = try storeSearch.search(for: options.appName).wait() - if results.isEmpty { - return .failure(.noSearchResultsFound) + func run() throws { + let result = run(storeSearch: MasStoreSearch()) + if case .failure = result { + try result.get() } + } - let output = SearchResultFormatter.format(results: results, includePrice: options.price) - print(output) + func run(storeSearch: StoreSearch) -> Result { + do { + let results = try storeSearch.search(for: appName).wait() + if results.isEmpty { + return .failure(.noSearchResultsFound) + } - return .success(()) - } catch { - // Bubble up MASErrors - if let error = error as? MASError { - return .failure(error) + let output = SearchResultFormatter.format(results: results, includePrice: price) + print(output) + + return .success(()) + } catch { + // Bubble up MASErrors + if let error = error as? MASError { + return .failure(error) + } + return .failure(.searchFailed) } - return .failure(.searchFailed) } } } - -public struct SearchOptions: OptionsProtocol { - let appName: String - let price: Bool - - public static func create(_ appName: String) -> (_ price: Bool) -> SearchOptions { - { price in - SearchOptions(appName: appName, price: price) - } - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Argument(usage: "the app name to search") - <*> mode <| Option(key: "price", defaultValue: false, usage: "Show price of found apps") - } -} diff --git a/Sources/mas/Commands/SignIn.swift b/Sources/mas/Commands/SignIn.swift index 3720806..1f06d76 100644 --- a/Sources/mas/Commands/SignIn.swift +++ b/Sources/mas/Commands/SignIn.swift @@ -6,46 +6,38 @@ // Copyright © 2016 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser import StoreFoundation -public struct SignInCommand: CommandProtocol { - public typealias Options = SignInOptions +extension Mas { + struct SignIn: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "signin", + abstract: "Sign in to the Mac App Store" + ) - public let verb = "signin" - public let function = "Sign in to the Mac App Store" + @Flag(help: "Complete login with graphical dialog") + var dialog = false + @Argument(help: "Apple ID") + var username: String + @Argument(help: "Password") + var password: String = "" - /// Runs the command. - public func run(_ options: Options) -> Result { - do { - _ = try ISStoreAccount.signIn( - username: options.username, - password: options.password, - systemDialog: options.dialog - ) - .wait() - return .success(()) - } catch { - return .failure(error as? MASError ?? .signInFailed(error: error as NSError)) + /// Runs the command. + func run() throws { + let result = runInternal() + if case .failure = result { + try result.get() + } + } + + func runInternal() -> Result { + do { + _ = try ISStoreAccount.signIn(username: username, password: password, systemDialog: dialog).wait() + return .success(()) + } catch { + return .failure(error as? MASError ?? .signInFailed(error: error as NSError)) + } } } } - -public struct SignInOptions: OptionsProtocol { - public typealias ClientError = MASError - - let username: String - let password: String - let dialog: Bool - - static func create(username: String) -> (_ password: String) -> (_ dialog: Bool) -> SignInOptions { - { password in { dialog in SignInOptions(username: username, password: password, dialog: dialog) } } - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Argument(usage: "Apple ID") - <*> mode <| Argument(defaultValue: "", usage: "Password") - <*> mode <| Option(key: "dialog", defaultValue: false, usage: "Complete login with graphical dialog") - } -} diff --git a/Sources/mas/Commands/SignOut.swift b/Sources/mas/Commands/SignOut.swift index 711f06f..cf31423 100644 --- a/Sources/mas/Commands/SignOut.swift +++ b/Sources/mas/Commands/SignOut.swift @@ -6,24 +6,34 @@ // Copyright © 2016 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser import CommerceKit -public struct SignOutCommand: CommandProtocol { - public typealias Options = NoOptions - public let verb = "signout" - public let function = "Sign out of the Mac App Store" +extension Mas { + struct SignOut: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "signout", + abstract: "Sign out of the Mac App Store" + ) - /// Runs the command. - public func run(_: Options) -> Result { - if #available(macOS 10.13, *) { - ISServiceProxy.genericShared().accountService.signOut() - } else { - // Using CKAccountStore to sign out does nothing on High Sierra - // https://github.com/mas-cli/mas/issues/129 - CKAccountStore.shared().signOut() + /// Runs the command. + func run() throws { + let result = runInternal() + if case .failure = result { + try result.get() + } } - return .success(()) + func runInternal() -> Result { + if #available(macOS 10.13, *) { + ISServiceProxy.genericShared().accountService.signOut() + } else { + // Using CKAccountStore to sign out does nothing on High Sierra + // https://github.com/mas-cli/mas/issues/129 + CKAccountStore.shared().signOut() + } + + return .success(()) + } } } diff --git a/Sources/mas/Commands/Uninstall.swift b/Sources/mas/Commands/Uninstall.swift index 0f3570e..cb6af8d 100644 --- a/Sources/mas/Commands/Uninstall.swift +++ b/Sources/mas/Commands/Uninstall.swift @@ -6,75 +6,52 @@ // Copyright © 2015 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser import CommerceKit import StoreFoundation -/// Command which uninstalls apps managed by the Mac App Store. -public struct UninstallCommand: CommandProtocol { - public typealias Options = UninstallOptions - public let verb = "uninstall" - public let function = "Uninstall app installed from the Mac App Store" +extension Mas { + /// Command which uninstalls apps managed by the Mac App Store. + struct Uninstall: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Uninstall app installed from the Mac App Store" + ) - private let appLibrary: AppLibrary + /// Flag indicating that removal shouldn't be performed + @Flag(help: "dry run") + var dryRun = false + @Argument(help: "ID of app to uninstall") + var appId: Int - /// Public initializer. - /// - Parameter appLibrary: AppLibrary manager. - public init() { - self.init(appLibrary: MasAppLibrary()) - } - - /// Internal initializer. - /// - Parameter appLibrary: AppLibrary manager. - init(appLibrary: AppLibrary = MasAppLibrary()) { - self.appLibrary = appLibrary - } - - /// Runs the uninstall command. - /// - /// - Parameter options: UninstallOptions (arguments) for this command - /// - Returns: Success or an error. - public func run(_ options: Options) -> Result { - let appId = UInt64(options.appId) - - guard let product = appLibrary.installedApp(forId: appId) else { - return .failure(.notInstalled) + /// Runs the uninstall command. + func run() throws { + let result = run(appLibrary: MasAppLibrary()) + if case .failure = result { + try result.get() + } } - if options.dryRun { - printInfo("\(product.appName) \(product.bundlePath)") - printInfo("(not removed, dry run)") + func run(appLibrary: AppLibrary) -> Result { + let appId = UInt64(appId) + + guard let product = appLibrary.installedApp(forId: appId) else { + return .failure(.notInstalled) + } + + if dryRun { + printInfo("\(product.appName) \(product.bundlePath)") + printInfo("(not removed, dry run)") + + return .success(()) + } + + do { + try appLibrary.uninstallApp(app: product) + } catch { + return .failure(.uninstallFailed) + } return .success(()) } - - do { - try appLibrary.uninstallApp(app: product) - } catch { - return .failure(.uninstallFailed) - } - - return .success(()) - } -} - -/// Options for the uninstall command. -public struct UninstallOptions: OptionsProtocol { - /// Numeric app ID - let appId: Int - - /// Flag indicating that removal shouldn't be performed - let dryRun: Bool - - static func create(_ appId: Int) -> (_ dryRun: Bool) -> UninstallOptions { - { dryRun in - UninstallOptions(appId: appId, dryRun: dryRun) - } - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Argument(usage: "ID of app to uninstall") - <*> mode <| Switch(flag: nil, key: "dry-run", usage: "dry run") } } diff --git a/Sources/mas/Commands/Upgrade.swift b/Sources/mas/Commands/Upgrade.swift index a6ba781..5a8e74c 100644 --- a/Sources/mas/Commands/Upgrade.swift +++ b/Sources/mas/Commands/Upgrade.swift @@ -6,104 +6,90 @@ // Copyright © 2015 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser import Foundation import PromiseKit import enum Swift.Result -/// Command which upgrades apps with new versions available in the Mac App Store. -public struct UpgradeCommand: CommandProtocol { - public typealias Options = UpgradeOptions - public let verb = "upgrade" - public let function = "Upgrade outdated apps from the Mac App Store" +extension Mas { + /// Command which upgrades apps with new versions available in the Mac App Store. + struct Upgrade: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Upgrade outdated apps from the Mac App Store" + ) - private let appLibrary: AppLibrary - private let storeSearch: StoreSearch + @Argument(help: "app(s) to upgrade") + var apps: [String] = [] - /// Public initializer. - public init() { - self.init(appLibrary: MasAppLibrary()) - } - - /// Internal initializer. - /// - Parameter appLibrary: AppLibrary manager. - /// - Parameter storeSearch: StoreSearch manager. - init(appLibrary: AppLibrary = MasAppLibrary(), storeSearch: StoreSearch = MasStoreSearch()) { - self.appLibrary = appLibrary - self.storeSearch = storeSearch - } - - /// Runs the command. - public func run(_ options: Options) -> Result { - let apps: [(installedApp: SoftwareProduct, storeApp: SearchResult)] - do { - apps = try findOutdatedApps(options) - } catch { - // Bubble up MASErrors - return .failure(error as? MASError ?? .searchFailed) + /// Runs the command. + func run() throws { + let result = run(appLibrary: MasAppLibrary(), storeSearch: MasStoreSearch()) + if case .failure = result { + try result.get() + } } - guard apps.count > 0 else { - printWarning("Nothing found to upgrade") + func run(appLibrary: AppLibrary, storeSearch: StoreSearch) -> Result { + let apps: [(installedApp: SoftwareProduct, storeApp: SearchResult)] + do { + apps = try findOutdatedApps(appLibrary: appLibrary, storeSearch: storeSearch) + } catch { + // Bubble up MASErrors + return .failure(error as? MASError ?? .searchFailed) + } + + guard apps.count > 0 else { + printWarning("Nothing found to upgrade") + return .success(()) + } + + print("Upgrading \(apps.count) outdated application\(apps.count > 1 ? "s" : ""):") + print( + apps.map { "\($0.installedApp.appName) (\($0.installedApp.bundleVersion)) -> (\($0.storeApp.version))" } + .joined(separator: "\n")) + + let appIds = apps.map(\.installedApp.itemIdentifier.uint64Value) + do { + try downloadAll(appIds).wait() + } catch { + return .failure(error as? MASError ?? .downloadFailed(error: error as NSError)) + } + return .success(()) } - print("Upgrading \(apps.count) outdated application\(apps.count > 1 ? "s" : ""):") - print( - apps.map { "\($0.installedApp.appName) (\($0.installedApp.bundleVersion)) -> (\($0.storeApp.version))" } - .joined(separator: "\n")) + private func findOutdatedApps( + appLibrary: AppLibrary, + storeSearch: StoreSearch + ) throws -> [(SoftwareProduct, SearchResult)] { + let apps: [SoftwareProduct] = + apps.isEmpty + ? appLibrary.installedApps + : apps.compactMap { + if let appId = UInt64($0) { + // if argument a UInt64, lookup app by id using argument + return appLibrary.installedApp(forId: appId) + } else { + // if argument not a UInt64, lookup app by name using argument + return appLibrary.installedApp(named: $0) + } + } - let appIds = apps.map(\.installedApp.itemIdentifier.uint64Value) - do { - try downloadAll(appIds).wait() - } catch { - return .failure(error as? MASError ?? .downloadFailed(error: error as NSError)) - } + let promises = apps.map { installedApp in + // only upgrade apps whose local version differs from the store version + firstly { + storeSearch.lookup(app: installedApp.itemIdentifier.intValue) + }.map { result -> (SoftwareProduct, SearchResult)? in + guard let storeApp = result, installedApp.isOutdatedWhenComparedTo(storeApp) else { + return nil + } - return .success(()) - } - - private func findOutdatedApps(_ options: Options) throws -> [(SoftwareProduct, SearchResult)] { - let apps: [SoftwareProduct] = - options.apps.isEmpty - ? appLibrary.installedApps - : options.apps.compactMap { - if let appId = UInt64($0) { - // if argument a UInt64, lookup app by id using argument - return appLibrary.installedApp(forId: appId) - } else { - // if argument not a UInt64, lookup app by name using argument - return appLibrary.installedApp(named: $0) + return (installedApp, storeApp) } } - let promises = apps.map { installedApp in - // only upgrade apps whose local version differs from the store version - firstly { - storeSearch.lookup(app: installedApp.itemIdentifier.intValue) - }.map { result -> (SoftwareProduct, SearchResult)? in - guard let storeApp = result, installedApp.isOutdatedWhenComparedTo(storeApp) else { - return nil - } - - return (installedApp, storeApp) - } + return try when(fulfilled: promises).wait().compactMap { $0 } } - - return try when(fulfilled: promises).wait().compactMap { $0 } - } -} - -public struct UpgradeOptions: OptionsProtocol { - let apps: [String] - - static func create(_ apps: [String]) -> UpgradeOptions { - UpgradeOptions(apps: apps) - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Argument(defaultValue: [], usage: "app(s) to upgrade") } } diff --git a/Sources/mas/Commands/Vendor.swift b/Sources/mas/Commands/Vendor.swift index f0f45f5..5057144 100644 --- a/Sources/mas/Commands/Vendor.swift +++ b/Sources/mas/Commands/Vendor.swift @@ -6,78 +6,57 @@ // Copyright © 2016 mas-cli. All rights reserved. // -import Commandant +import ArgumentParser -/// Opens vendor's app page in a browser. Uses the iTunes Lookup API: -/// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup -public struct VendorCommand: CommandProtocol { - public typealias Options = VendorOptions - - public let verb = "vendor" - public let function = "Opens vendor's app page in a browser" - - private let storeSearch: StoreSearch - private var openCommand: ExternalCommand - - public init() { - self.init( - storeSearch: MasStoreSearch(), - openCommand: OpenSystemCommand() +extension Mas { + /// Opens vendor's app page in a browser. Uses the iTunes Lookup API: + /// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/#lookup + struct Vendor: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Opens vendor's app page in a browser" ) - } - /// Designated initializer. - init( - storeSearch: StoreSearch = MasStoreSearch(), - openCommand: ExternalCommand = OpenSystemCommand() - ) { - self.storeSearch = storeSearch - self.openCommand = openCommand - } + @Argument(help: "the app ID to show the vendor's website") + var appId: Int - /// Runs the command. - public func run(_ options: VendorOptions) -> Result { - do { - guard let result = try storeSearch.lookup(app: options.appId).wait() - else { - return .failure(.noSearchResultsFound) + /// Runs the command. + func run() throws { + let result = run(storeSearch: MasStoreSearch(), openCommand: OpenSystemCommand()) + if case .failure = result { + try result.get() } - - guard let vendorWebsite = result.sellerUrl - else { throw MASError.noVendorWebsite } - - do { - try openCommand.run(arguments: vendorWebsite) - } catch { - printError("Unable to launch open command") - return .failure(.searchFailed) - } - if openCommand.failed { - let reason = openCommand.process.terminationReason - printError("Open failed: (\(reason)) \(openCommand.stderr)") - return .failure(.searchFailed) - } - } catch { - // Bubble up MASErrors - if let error = error as? MASError { - return .failure(error) - } - return .failure(.searchFailed) } - return .success(()) - } -} - -public struct VendorOptions: OptionsProtocol { - let appId: Int - - static func create(_ appId: Int) -> VendorOptions { - VendorOptions(appId: appId) - } - - public static func evaluate(_ mode: CommandMode) -> Result> { - create - <*> mode <| Argument(usage: "the app ID to show the vendor's website") + func run(storeSearch: StoreSearch, openCommand: ExternalCommand) -> Result { + do { + guard let result = try storeSearch.lookup(app: appId).wait() + else { + return .failure(.noSearchResultsFound) + } + + guard let vendorWebsite = result.sellerUrl + else { throw MASError.noVendorWebsite } + + do { + try openCommand.run(arguments: vendorWebsite) + } catch { + printError("Unable to launch open command") + return .failure(.searchFailed) + } + if openCommand.failed { + let reason = openCommand.process.terminationReason + printError("Open failed: (\(reason)) \(openCommand.stderr)") + return .failure(.searchFailed) + } + } catch { + // Bubble up MASErrors + if let error = error as? MASError { + return .failure(error) + } + return .failure(.searchFailed) + } + + return .success(()) + } } } diff --git a/Sources/mas/Commands/Version.swift b/Sources/mas/Commands/Version.swift index f5957a3..a2a0e51 100644 --- a/Sources/mas/Commands/Version.swift +++ b/Sources/mas/Commands/Version.swift @@ -6,17 +6,26 @@ // Copyright © 2015 Andrew Naylor. All rights reserved. // -import Commandant +import ArgumentParser -/// Command which displays the version of the mas tool. -public struct VersionCommand: CommandProtocol { - public typealias Options = NoOptions - public let verb = "version" - public let function = "Print version number" +extension Mas { + /// Command which displays the version of the mas tool. + struct Version: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print version number" + ) - /// Runs the command. - public func run(_: Options) -> Result { - print(Package.version) - return .success(()) + /// Runs the command. + func run() throws { + let result = runInternal() + if case .failure = result { + try result.get() + } + } + + func runInternal() -> Result { + print(Package.version) + return .success(()) + } } } diff --git a/Sources/mas/Errors/MASError.swift b/Sources/mas/Errors/MASError.swift index 9008c78..c799352 100644 --- a/Sources/mas/Errors/MASError.swift +++ b/Sources/mas/Errors/MASError.swift @@ -8,7 +8,7 @@ import Foundation -public enum MASError: Error, Equatable { +enum MASError: Error, Equatable { case notSupported case failed(error: NSError?) @@ -36,7 +36,7 @@ public enum MASError: Error, Equatable { // MARK: - CustomStringConvertible extension MASError: CustomStringConvertible { - public var description: String { + var description: String { switch self { case .notSignedIn: return "Not signed in" diff --git a/Sources/mas/Formatters/Utilities.swift b/Sources/mas/Formatters/Utilities.swift index 7092d57..f168e2c 100644 --- a/Sources/mas/Formatters/Utilities.swift +++ b/Sources/mas/Formatters/Utilities.swift @@ -91,7 +91,7 @@ func printInfo(_ message: String) { } /// Prints a message to stderr prefixed with "Warning:" underlined in yellow. -public func printWarning(_ message: String) { +func printWarning(_ message: String) { guard isatty(fileno(stderr)) != 0 else { print("Warning: \(message)", to: &standardError) return @@ -102,7 +102,7 @@ public func printWarning(_ message: String) { } /// Prints a message to stderr prefixed with "Error:" underlined in red. -public func printError(_ message: String) { +func printError(_ message: String) { guard isatty(fileno(stderr)) != 0 else { print("Error: \(message)", to: &standardError) return diff --git a/Sources/mas/Mas.swift b/Sources/mas/Mas.swift index 5000efe..1b95c02 100644 --- a/Sources/mas/Mas.swift +++ b/Sources/mas/Mas.swift @@ -6,10 +6,39 @@ // Copyright © 2021 mas-cli. All rights reserved. // +import ArgumentParser import PromiseKit -public enum Mas { - public static func initialize() { +@main +struct Mas: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Mac App Store command-line interface", + subcommands: [ + Account.self, + Home.self, + Info.self, + Install.self, + List.self, + Lucky.self, + Open.self, + Outdated.self, + Purchase.self, + Reset.self, + Search.self, + SignIn.self, + SignOut.self, + Uninstall.self, + Upgrade.self, + Vendor.self, + Version.self, + ] + ) + + func validate() throws { + Mas.initialize() + } + + static func initialize() { PromiseKit.conf.Q.map = .global() PromiseKit.conf.Q.return = .global() PromiseKit.conf.logHandler = { event in diff --git a/Sources/mas/Network/URLSession+NetworkSession.swift b/Sources/mas/Network/URLSession+NetworkSession.swift index 04db152..5dca9dd 100644 --- a/Sources/mas/Network/URLSession+NetworkSession.swift +++ b/Sources/mas/Network/URLSession+NetworkSession.swift @@ -10,7 +10,7 @@ import Foundation import PromiseKit extension URLSession: NetworkSession { - public func loadData(from url: URL) -> Promise { + func loadData(from url: URL) -> Promise { Promise { seal in dataTask(with: url) { data, _, error in if let data { diff --git a/Sources/mas/main.swift b/Sources/mas/main.swift deleted file mode 100644 index 3c0776e..0000000 --- a/Sources/mas/main.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// main.swift -// mas -// -// Created by Andrew Naylor on 11/07/2015. -// Copyright © 2015 Andrew Naylor. All rights reserved. -// - -import Commandant - -Mas.initialize() - -let registry = CommandRegistry() -let helpCommand = HelpCommand(registry: registry) - -registry.register(AccountCommand()) -registry.register(HomeCommand()) -registry.register(InfoCommand()) -registry.register(InstallCommand()) -registry.register(PurchaseCommand()) -registry.register(ListCommand()) -registry.register(LuckyCommand()) -registry.register(OpenCommand()) -registry.register(OutdatedCommand()) -registry.register(ResetCommand()) -registry.register(SearchCommand()) -registry.register(SignInCommand()) -registry.register(SignOutCommand()) -registry.register(UninstallCommand()) -registry.register(UpgradeCommand()) -registry.register(VendorCommand()) -registry.register(VersionCommand()) -registry.register(helpCommand) - -registry.main(defaultVerb: helpCommand.verb) { error in - printError(String(describing: error)) -} diff --git a/Tests/masTests/Commands/AccountCommandSpec.swift b/Tests/masTests/Commands/AccountSpec.swift similarity index 69% rename from Tests/masTests/Commands/AccountCommandSpec.swift rename to Tests/masTests/Commands/AccountSpec.swift index 73a187f..4195031 100644 --- a/Tests/masTests/Commands/AccountCommandSpec.swift +++ b/Tests/masTests/Commands/AccountSpec.swift @@ -1,5 +1,5 @@ // -// AccountCommandSpec.swift +// AccountSpec.swift // masTests // // Created by Ben Chatelain on 2018-12-28. @@ -12,7 +12,7 @@ import Quick @testable import mas // Deprecated test -public class AccountCommandSpec: QuickSpec { +public class AccountSpec: QuickSpec { override public func spec() { beforeSuite { Mas.initialize() @@ -20,9 +20,10 @@ public class AccountCommandSpec: QuickSpec { // account command disabled since macOS 12 Monterey https://github.com/mas-cli/mas#%EF%B8%8F-known-issues xdescribe("Account command") { xit("displays active account") { - let cmd = AccountCommand() - let result = cmd.run(AccountCommand.Options()) - expect(result).to(beSuccess()) + expect { + try Mas.Account.parse([]).runInternal() + } + .to(beSuccess()) } } } diff --git a/Tests/masTests/Commands/HomeCommandSpec.swift b/Tests/masTests/Commands/HomeSpec.swift similarity index 53% rename from Tests/masTests/Commands/HomeCommandSpec.swift rename to Tests/masTests/Commands/HomeSpec.swift index bfbe5c4..bd8bab8 100644 --- a/Tests/masTests/Commands/HomeCommandSpec.swift +++ b/Tests/masTests/Commands/HomeSpec.swift @@ -1,5 +1,5 @@ // -// HomeCommandSpec.swift +// HomeSpec.swift // masTests // // Created by Ben Chatelain on 2018-12-29. @@ -11,7 +11,7 @@ import Quick @testable import mas -public class HomeCommandSpec: QuickSpec { +public class HomeSpec: QuickSpec { override public func spec() { let result = SearchResult( trackId: 1111, @@ -20,7 +20,6 @@ public class HomeCommandSpec: QuickSpec { ) let storeSearch = StoreSearchMock() let openCommand = OpenSystemCommandMock() - let cmd = HomeCommand(storeSearch: storeSearch, openCommand: openCommand) beforeSuite { Mas.initialize() @@ -30,26 +29,32 @@ public class HomeCommandSpec: QuickSpec { storeSearch.reset() } it("fails to open app with invalid ID") { - let result = cmd.run(HomeCommand.Options(appId: -999)) - expect(result) - .to( - beFailure { error in - expect(error) == .searchFailed - }) + expect { + try Mas.Home.parse(["--", "-999"]).run(storeSearch: storeSearch, openCommand: openCommand) + } + .to( + beFailure { error in + expect(error) == .searchFailed + } + ) } it("can't find app with unknown ID") { - let result = cmd.run(HomeCommand.Options(appId: 999)) - expect(result) - .to( - beFailure { error in - expect(error) == .noSearchResultsFound - }) + expect { + try Mas.Home.parse(["999"]).run(storeSearch: storeSearch, openCommand: openCommand) + } + .to( + beFailure { error in + expect(error) == .noSearchResultsFound + } + ) } it("opens app on MAS Preview") { storeSearch.apps[result.trackId] = result - let cmdResult = cmd.run(HomeCommand.Options(appId: result.trackId)) - expect(cmdResult).to(beSuccess()) + expect { + try Mas.Home.parse([String(result.trackId)]).run(storeSearch: storeSearch, openCommand: openCommand) + } + .to(beSuccess()) expect(openCommand.arguments).toNot(beNil()) expect(openCommand.arguments!.first!) == result.trackViewUrl } diff --git a/Tests/masTests/Commands/InfoCommandSpec.swift b/Tests/masTests/Commands/InfoSpec.swift similarity index 63% rename from Tests/masTests/Commands/InfoCommandSpec.swift rename to Tests/masTests/Commands/InfoSpec.swift index 2cd8982..237b9bf 100644 --- a/Tests/masTests/Commands/InfoCommandSpec.swift +++ b/Tests/masTests/Commands/InfoSpec.swift @@ -1,5 +1,5 @@ // -// InfoCommandSpec.swift +// InfoSpec.swift // masTests // // Created by Ben Chatelain on 2018-12-28. @@ -11,7 +11,7 @@ import Quick @testable import mas -public class InfoCommandSpec: QuickSpec { +public class InfoSpec: QuickSpec { override public func spec() { let result = SearchResult( currentVersionReleaseDate: "2019-01-07T18:53:13Z", @@ -25,7 +25,6 @@ public class InfoCommandSpec: QuickSpec { version: "1.0" ) let storeSearch = StoreSearchMock() - let cmd = InfoCommand(storeSearch: storeSearch) let expectedOutput = """ Awesome App 1.0 [2.0] By: Awesome Dev @@ -44,28 +43,33 @@ public class InfoCommandSpec: QuickSpec { storeSearch.reset() } it("fails to open app with invalid ID") { - let result = cmd.run(InfoCommand.Options(appId: -999)) - expect(result) - .to( - beFailure { error in - expect(error) == .searchFailed - }) + expect { + try Mas.Info.parse(["--", "-999"]).run(storeSearch: storeSearch) + } + .to( + beFailure { error in + expect(error) == .searchFailed + } + ) } it("can't find app with unknown ID") { - let result = cmd.run(InfoCommand.Options(appId: 999)) - expect(result) - .to( - beFailure { error in - expect(error) == .noSearchResultsFound - }) + expect { + try Mas.Info.parse(["999"]).run(storeSearch: storeSearch) + } + .to( + beFailure { error in + expect(error) == .noSearchResultsFound + } + ) } it("displays app details") { storeSearch.apps[result.trackId] = result let output = OutputListener() - let result = cmd.run(InfoCommand.Options(appId: result.trackId)) - - expect(result).to(beSuccess()) + expect { + try Mas.Info.parse([String(result.trackId)]).run(storeSearch: storeSearch) + } + .to(beSuccess()) expect(output.contents) == expectedOutput } } diff --git a/Tests/masTests/Commands/InstallCommandSpec.swift b/Tests/masTests/Commands/InstallCommandSpec.swift index a3a009e..ac5d77f 100644 --- a/Tests/masTests/Commands/InstallCommandSpec.swift +++ b/Tests/masTests/Commands/InstallCommandSpec.swift @@ -11,16 +11,17 @@ import Quick @testable import mas -public class InstallCommandSpec: QuickSpec { +public class InstallSpec: QuickSpec { override public func spec() { beforeSuite { Mas.initialize() } - describe("install command") { - it("installs apps") { - let cmd = InstallCommand() - let result = cmd.run(InstallCommand.Options(appIds: [], forceInstall: false)) - expect(result).to(beSuccess()) + xdescribe("install command") { + xit("installs apps") { + expect { + try Mas.Install.parse([]).run(appLibrary: AppLibraryMock()) + } + .to(beSuccess()) } } } diff --git a/Tests/masTests/Commands/ListCommandSpec.swift b/Tests/masTests/Commands/ListSpec.swift similarity index 62% rename from Tests/masTests/Commands/ListCommandSpec.swift rename to Tests/masTests/Commands/ListSpec.swift index 300322e..8e12a7e 100644 --- a/Tests/masTests/Commands/ListCommandSpec.swift +++ b/Tests/masTests/Commands/ListSpec.swift @@ -1,5 +1,5 @@ // -// ListCommandSpec.swift +// ListSpec.swift // masTests // // Created by Ben Chatelain on 2018-12-27. @@ -11,16 +11,17 @@ import Quick @testable import mas -public class ListCommandSpec: QuickSpec { +public class ListSpec: QuickSpec { override public func spec() { beforeSuite { Mas.initialize() } describe("list command") { it("lists apps") { - let list = ListCommand() - let result = list.run(ListCommand.Options()) - expect(result).to(beSuccess()) + expect { + try Mas.List.parse([]).run(appLibrary: AppLibraryMock()) + } + .to(beSuccess()) } } } diff --git a/Tests/masTests/Commands/LuckyCommandSpec.swift b/Tests/masTests/Commands/LuckySpec.swift similarity index 67% rename from Tests/masTests/Commands/LuckyCommandSpec.swift rename to Tests/masTests/Commands/LuckySpec.swift index 8fbda3c..dadbe3c 100644 --- a/Tests/masTests/Commands/LuckyCommandSpec.swift +++ b/Tests/masTests/Commands/LuckySpec.swift @@ -1,5 +1,5 @@ // -// LuckyCommandSpec.swift +// LuckySpec.swift // masTests // // Created by Ben Chatelain on 2018-12-28. @@ -11,7 +11,7 @@ import Quick @testable import mas -public class LuckyCommandSpec: QuickSpec { +public class LuckySpec: QuickSpec { override public func spec() { let networkSession = NetworkSessionMockFromFile(responseFile: "search/slack.json") let storeSearch = MasStoreSearch(networkManager: NetworkManager(session: networkSession)) @@ -21,9 +21,10 @@ public class LuckyCommandSpec: QuickSpec { } describe("lucky command") { xit("installs the first app matching a search") { - let cmd = LuckyCommand(storeSearch: storeSearch) - let result = cmd.run(LuckyCommand.Options(appName: "Slack", forceInstall: false)) - expect(result).to(beSuccess()) + expect { + try Mas.Lucky.parse(["Slack"]).run(appLibrary: AppLibraryMock(), storeSearch: storeSearch) + } + .to(beSuccess()) } } } diff --git a/Tests/masTests/Commands/OpenCommandSpec.swift b/Tests/masTests/Commands/OpenSpec.swift similarity index 56% rename from Tests/masTests/Commands/OpenCommandSpec.swift rename to Tests/masTests/Commands/OpenSpec.swift index 7a8562e..6c4642f 100644 --- a/Tests/masTests/Commands/OpenCommandSpec.swift +++ b/Tests/masTests/Commands/OpenSpec.swift @@ -1,5 +1,5 @@ // -// OpenCommandSpec.swift +// OpenSpec.swift // masTests // // Created by Ben Chatelain on 2019-01-03. @@ -12,7 +12,7 @@ import Quick @testable import mas -public class OpenCommandSpec: QuickSpec { +public class OpenSpec: QuickSpec { override public func spec() { let result = SearchResult( trackId: 1111, @@ -21,7 +21,6 @@ public class OpenCommandSpec: QuickSpec { ) let storeSearch = StoreSearchMock() let openCommand = OpenSystemCommandMock() - let cmd = OpenCommand(storeSearch: storeSearch, openCommand: openCommand) beforeSuite { Mas.initialize() @@ -31,34 +30,43 @@ public class OpenCommandSpec: QuickSpec { storeSearch.reset() } it("fails to open app with invalid ID") { - let result = cmd.run(OpenCommand.Options(appId: "-999")) - expect(result) - .to( - beFailure { error in - expect(error) == .searchFailed - }) + expect { + try Mas.Open.parse(["--", "-999"]).run(storeSearch: storeSearch, openCommand: openCommand) + } + .to( + beFailure { error in + expect(error) == .searchFailed + } + ) } it("can't find app with unknown ID") { - let result = cmd.run(OpenCommand.Options(appId: "999")) - expect(result) - .to( - beFailure { error in - expect(error) == .noSearchResultsFound - }) + expect { + try Mas.Open.parse(["999"]).run(storeSearch: storeSearch, openCommand: openCommand) + } + .to( + beFailure { error in + expect(error) == .noSearchResultsFound + } + ) } it("opens app in MAS") { storeSearch.apps[result.trackId] = result - let cmdResult = cmd.run(OpenCommand.Options(appId: result.trackId.description)) - expect(cmdResult).to(beSuccess()) + expect { + try Mas.Open.parse([result.trackId.description]) + .run(storeSearch: storeSearch, openCommand: openCommand) + } + .to(beSuccess()) expect(openCommand.arguments).toNot(beNil()) let url = URL(string: openCommand.arguments!.first!) expect(url).toNot(beNil()) expect(url?.scheme) == "macappstore" } it("just opens MAS if no app specified") { - let cmdResult = cmd.run(OpenCommand.Options(appId: "appstore")) - expect(cmdResult).to(beSuccess()) + expect { + try Mas.Open.parse(["appstore"]).run(storeSearch: storeSearch, openCommand: openCommand) + } + .to(beSuccess()) expect(openCommand.arguments).toNot(beNil()) let url = URL(string: openCommand.arguments!.first!) expect(url).toNot(beNil()) diff --git a/Tests/masTests/Commands/OutdatedCommandSpec.swift b/Tests/masTests/Commands/OutdatedSpec.swift similarity index 57% rename from Tests/masTests/Commands/OutdatedCommandSpec.swift rename to Tests/masTests/Commands/OutdatedSpec.swift index 22a6947..be831ba 100644 --- a/Tests/masTests/Commands/OutdatedCommandSpec.swift +++ b/Tests/masTests/Commands/OutdatedSpec.swift @@ -1,5 +1,5 @@ // -// OutdatedCommandSpec.swift +// OutdatedSpec.swift // masTests // // Created by Ben Chatelain on 2018-12-28. @@ -11,17 +11,18 @@ import Quick @testable import mas -public class OutdatedCommandSpec: QuickSpec { +public class OutdatedSpec: QuickSpec { override public func spec() { beforeSuite { Mas.initialize() } describe("outdated command") { it("displays apps with pending updates") { - let cmd = OutdatedCommand() - let result = cmd.run(OutdatedCommand.Options(verbose: true)) - print(result) - expect(result).to(beSuccess()) + expect { + try Mas.Outdated.parse(["--verbose"]) + .run(appLibrary: AppLibraryMock(), storeSearch: StoreSearchMock()) + } + .to(beSuccess()) } } } diff --git a/Tests/masTests/Commands/PurchaseCommandSpec.swift b/Tests/masTests/Commands/PurchaseCommandSpec.swift index 4db873f..f75a557 100644 --- a/Tests/masTests/Commands/PurchaseCommandSpec.swift +++ b/Tests/masTests/Commands/PurchaseCommandSpec.swift @@ -11,16 +11,17 @@ import Quick @testable import mas -public class PurchaseCommandSpec: QuickSpec { +public class PurchaseSpec: QuickSpec { override public func spec() { beforeSuite { Mas.initialize() } - describe("purchase command") { - it("purchases apps") { - let cmd = PurchaseCommand() - let result = cmd.run(PurchaseCommand.Options(appIds: [])) - expect(result).to(beSuccess()) + xdescribe("purchase command") { + xit("purchases apps") { + expect { + try Mas.Purchase.parse(["999"]).run(appLibrary: AppLibraryMock()) + } + .toNot(throwError()) } } } diff --git a/Tests/masTests/Commands/ResetCommandSpec.swift b/Tests/masTests/Commands/ResetSpec.swift similarity index 62% rename from Tests/masTests/Commands/ResetCommandSpec.swift rename to Tests/masTests/Commands/ResetSpec.swift index 6405025..53e3020 100644 --- a/Tests/masTests/Commands/ResetCommandSpec.swift +++ b/Tests/masTests/Commands/ResetSpec.swift @@ -1,5 +1,5 @@ // -// ResetCommandSpec.swift +// ResetSpec.swift // masTests // // Created by Ben Chatelain on 2018-12-28. @@ -11,16 +11,17 @@ import Quick @testable import mas -public class ResetCommandSpec: QuickSpec { +public class ResetSpec: QuickSpec { override public func spec() { beforeSuite { Mas.initialize() } describe("reset command") { it("resets the App Store state") { - let cmd = ResetCommand() - let result = cmd.run(ResetCommand.Options(debug: false)) - expect(result).to(beSuccess()) + expect { + try Mas.Reset.parse([]).runInternal() + } + .to(beSuccess()) } } } diff --git a/Tests/masTests/Commands/SearchCommandSpec.swift b/Tests/masTests/Commands/SearchSpec.swift similarity index 52% rename from Tests/masTests/Commands/SearchCommandSpec.swift rename to Tests/masTests/Commands/SearchSpec.swift index f15f812..4c0d9dc 100644 --- a/Tests/masTests/Commands/SearchCommandSpec.swift +++ b/Tests/masTests/Commands/SearchSpec.swift @@ -1,5 +1,5 @@ // -// SearchCommandSpec.swift +// SearchSpec.swift // masTests // // Created by Ben Chatelain on 2018-12-28. @@ -11,7 +11,7 @@ import Quick @testable import mas -public class SearchCommandSpec: QuickSpec { +public class SearchSpec: QuickSpec { override public func spec() { let result = SearchResult( trackId: 1111, @@ -30,21 +30,20 @@ public class SearchCommandSpec: QuickSpec { } it("can find slack") { storeSearch.apps[result.trackId] = result - - let search = SearchCommand(storeSearch: storeSearch) - let searchOptions = SearchOptions(appName: "slack", price: false) - let result = search.run(searchOptions) - expect(result).to(beSuccess()) + expect { + try Mas.Search.parse(["slack"]).run(storeSearch: storeSearch) + } + .to(beSuccess()) } it("fails when searching for nonexistent app") { - let search = SearchCommand(storeSearch: storeSearch) - let searchOptions = SearchOptions(appName: "nonexistent", price: false) - let result = search.run(searchOptions) - expect(result) - .to( - beFailure { error in - expect(error) == .noSearchResultsFound - }) + expect { + try Mas.Search.parse(["nonexistent"]).run(storeSearch: storeSearch) + } + .to( + beFailure { error in + expect(error) == .noSearchResultsFound + } + ) } } } diff --git a/Tests/masTests/Commands/SignInCommandSpec.swift b/Tests/masTests/Commands/SignInSpec.swift similarity index 65% rename from Tests/masTests/Commands/SignInCommandSpec.swift rename to Tests/masTests/Commands/SignInSpec.swift index 183b2d5..9b70fd2 100644 --- a/Tests/masTests/Commands/SignInCommandSpec.swift +++ b/Tests/masTests/Commands/SignInSpec.swift @@ -1,5 +1,5 @@ // -// SignInCommandSpec.swift +// SignInSpec.swift // masTests // // Created by Ben Chatelain on 2018-12-28. @@ -12,7 +12,7 @@ import Quick @testable import mas // Deprecated test -public class SignInCommandSpec: QuickSpec { +public class SignInSpec: QuickSpec { override public func spec() { beforeSuite { Mas.initialize() @@ -20,9 +20,10 @@ public class SignInCommandSpec: QuickSpec { // account command disabled since macOS 10.13 High Sierra https://github.com/mas-cli/mas#%EF%B8%8F-known-issues xdescribe("signin command") { xit("signs in") { - let cmd = SignInCommand() - let result = cmd.run(SignInCommand.Options(username: "", password: "", dialog: false)) - expect(result).to(beSuccess()) + expect { + try Mas.SignIn.parse(["", ""]).runInternal() + } + .to(beSuccess()) } } } diff --git a/Tests/masTests/Commands/SignOutCommandSpec.swift b/Tests/masTests/Commands/SignOutSpec.swift similarity index 61% rename from Tests/masTests/Commands/SignOutCommandSpec.swift rename to Tests/masTests/Commands/SignOutSpec.swift index c746deb..a987d5c 100644 --- a/Tests/masTests/Commands/SignOutCommandSpec.swift +++ b/Tests/masTests/Commands/SignOutSpec.swift @@ -1,5 +1,5 @@ // -// SignOutCommandSpec.swift +// SignOutSpec.swift // masTests // // Created by Ben Chatelain on 2018-12-28. @@ -11,16 +11,17 @@ import Quick @testable import mas -public class SignOutCommandSpec: QuickSpec { +public class SignOutSpec: QuickSpec { override public func spec() { beforeSuite { Mas.initialize() } describe("signout command") { it("signs out") { - let cmd = SignOutCommand() - let result = cmd.run(SignOutCommand.Options()) - expect(result).to(beSuccess()) + expect { + try Mas.SignOut.parse([]).runInternal() + } + .to(beSuccess()) } } } diff --git a/Tests/masTests/Commands/UninstallCommandSpec.swift b/Tests/masTests/Commands/UninstallSpec.swift similarity index 53% rename from Tests/masTests/Commands/UninstallCommandSpec.swift rename to Tests/masTests/Commands/UninstallSpec.swift index 53a7410..1d41a1e 100644 --- a/Tests/masTests/Commands/UninstallCommandSpec.swift +++ b/Tests/masTests/Commands/UninstallSpec.swift @@ -1,5 +1,5 @@ // -// UninstallCommandSpec.swift +// UninstallSpec.swift // masTests // // Created by Ben Chatelain on 2018-12-27. @@ -12,7 +12,7 @@ import Quick @testable import mas -public class UninstallCommandSpec: QuickSpec { +public class UninstallSpec: QuickSpec { override public func spec() { beforeSuite { Mas.initialize() @@ -27,60 +27,69 @@ public class UninstallCommandSpec: QuickSpec { itemIdentifier: NSNumber(value: appId) ) let mockLibrary = AppLibraryMock() - let uninstall = UninstallCommand(appLibrary: mockLibrary) context("dry run") { - let options = UninstallCommand.Options(appId: appId, dryRun: true) + let uninstall = try! Mas.Uninstall.parse(["--dry-run", String(appId)]) beforeEach { mockLibrary.reset() } it("can't remove a missing app") { - let result = uninstall.run(options) - expect(result) - .to( - beFailure { error in - expect(error) == .notInstalled - }) + expect { + uninstall.run(appLibrary: mockLibrary) + } + .to( + beFailure { error in + expect(error) == .notInstalled + } + ) } it("finds an app") { mockLibrary.installedApps.append(app) - let result = uninstall.run(options) - expect(result).to(beSuccess()) + expect { + uninstall.run(appLibrary: mockLibrary) + } + .to(beSuccess()) } } context("wet run") { - let options = UninstallCommand.Options(appId: appId, dryRun: false) + let uninstall = try! Mas.Uninstall.parse([String(appId)]) beforeEach { mockLibrary.reset() } it("can't remove a missing app") { - let result = uninstall.run(options) - expect(result) - .to( - beFailure { error in - expect(error) == .notInstalled - }) + expect { + uninstall.run(appLibrary: mockLibrary) + } + .to( + beFailure { error in + expect(error) == .notInstalled + } + ) } it("removes an app") { mockLibrary.installedApps.append(app) - let result = uninstall.run(options) - expect(result).to(beSuccess()) + expect { + uninstall.run(appLibrary: mockLibrary) + } + .to(beSuccess()) } it("fails if there is a problem with the trash command") { var brokenUninstall = app // make mutable copy brokenUninstall.bundlePath = "/dev/null" mockLibrary.installedApps.append(brokenUninstall) - let result = uninstall.run(options) - expect(result) - .to( - beFailure { error in - expect(error) == .uninstallFailed - }) + expect { + uninstall.run(appLibrary: mockLibrary) + } + .to( + beFailure { error in + expect(error) == .uninstallFailed + } + ) } } } diff --git a/Tests/masTests/Commands/UpgradeCommandSpec.swift b/Tests/masTests/Commands/UpgradeSpec.swift similarity index 59% rename from Tests/masTests/Commands/UpgradeCommandSpec.swift rename to Tests/masTests/Commands/UpgradeSpec.swift index 2b9b173..0b281b8 100644 --- a/Tests/masTests/Commands/UpgradeCommandSpec.swift +++ b/Tests/masTests/Commands/UpgradeSpec.swift @@ -1,5 +1,5 @@ // -// UpgradeCommandSpec.swift +// UpgradeSpec.swift // masTests // // Created by Ben Chatelain on 2018-12-28. @@ -11,16 +11,17 @@ import Quick @testable import mas -public class UpgradeCommandSpec: QuickSpec { +public class UpgradeSpec: QuickSpec { override public func spec() { beforeSuite { Mas.initialize() } describe("upgrade command") { it("upgrades stuff") { - let cmd = UpgradeCommand() - let result = cmd.run(UpgradeCommand.Options(apps: [""])) - expect(result).to(beSuccess()) + expect { + try Mas.Upgrade.parse([]).run(appLibrary: AppLibraryMock(), storeSearch: StoreSearchMock()) + } + .to(beSuccess()) } } } diff --git a/Tests/masTests/Commands/VendorCommandSpec.swift b/Tests/masTests/Commands/VendorSpec.swift similarity index 53% rename from Tests/masTests/Commands/VendorCommandSpec.swift rename to Tests/masTests/Commands/VendorSpec.swift index 2c5fe94..173cfea 100644 --- a/Tests/masTests/Commands/VendorCommandSpec.swift +++ b/Tests/masTests/Commands/VendorSpec.swift @@ -1,5 +1,5 @@ // -// VendorCommandSpec.swift +// VendorSpec.swift // masTests // // Created by Ben Chatelain on 2019-01-03. @@ -11,7 +11,7 @@ import Quick @testable import mas -public class VendorCommandSpec: QuickSpec { +public class VendorSpec: QuickSpec { override public func spec() { let result = SearchResult( trackId: 1111, @@ -20,7 +20,6 @@ public class VendorCommandSpec: QuickSpec { ) let storeSearch = StoreSearchMock() let openCommand = OpenSystemCommandMock() - let cmd = VendorCommand(storeSearch: storeSearch, openCommand: openCommand) beforeSuite { Mas.initialize() @@ -30,26 +29,32 @@ public class VendorCommandSpec: QuickSpec { storeSearch.reset() } it("fails to open app with invalid ID") { - let result = cmd.run(VendorCommand.Options(appId: -999)) - expect(result) - .to( - beFailure { error in - expect(error) == .searchFailed - }) + expect { + try Mas.Vendor.parse(["--", "-999"]).run(storeSearch: storeSearch, openCommand: openCommand) + } + .to( + beFailure { error in + expect(error) == .searchFailed + } + ) } it("can't find app with unknown ID") { - let result = cmd.run(VendorCommand.Options(appId: 999)) - expect(result) - .to( - beFailure { error in - expect(error) == .noSearchResultsFound - }) + expect { + try Mas.Vendor.parse(["999"]).run(storeSearch: storeSearch, openCommand: openCommand) + } + .to( + beFailure { error in + expect(error) == .noSearchResultsFound + } + ) } it("opens vendor app page in browser") { storeSearch.apps[result.trackId] = result - - let cmdResult = cmd.run(VendorCommand.Options(appId: result.trackId)) - expect(cmdResult).to(beSuccess()) + expect { + try Mas.Vendor.parse([String(result.trackId)]) + .run(storeSearch: storeSearch, openCommand: openCommand) + } + .to(beSuccess()) expect(openCommand.arguments).toNot(beNil()) expect(openCommand.arguments!.first!) == result.sellerUrl } diff --git a/Tests/masTests/Commands/VersionCommandSpec.swift b/Tests/masTests/Commands/VersionSpec.swift similarity index 62% rename from Tests/masTests/Commands/VersionCommandSpec.swift rename to Tests/masTests/Commands/VersionSpec.swift index 13ea5d4..5b2c773 100644 --- a/Tests/masTests/Commands/VersionCommandSpec.swift +++ b/Tests/masTests/Commands/VersionSpec.swift @@ -1,5 +1,5 @@ // -// VersionCommandSpec.swift +// VersionSpec.swift // masTests // // Created by Ben Chatelain on 2018-12-28. @@ -11,16 +11,17 @@ import Quick @testable import mas -public class VersionCommandSpec: QuickSpec { +public class VersionSpec: QuickSpec { override public func spec() { beforeSuite { Mas.initialize() } describe("version command") { it("displays the current version") { - let cmd = VersionCommand() - let result = cmd.run(VersionCommand.Options()) - expect(result).to(beSuccess()) + expect { + try Mas.Version.parse([]).runInternal() + } + .to(beSuccess()) } } } diff --git a/Tests/masTests/OutputListenerSpec.swift b/Tests/masTests/OutputListenerSpec.swift index b95240f..faef8c7 100644 --- a/Tests/masTests/OutputListenerSpec.swift +++ b/Tests/masTests/OutputListenerSpec.swift @@ -19,22 +19,20 @@ public class OutputListenerSpec: QuickSpec { describe("output listener") { it("can intercept a single line written stdout") { let output = OutputListener() - let expectedOutput = "hi there" print("hi there", terminator: "") - expect(output.contents) == expectedOutput + expect(output.contents) == "hi there" } it("can intercept multiple lines written stdout") { let output = OutputListener() - let expectedOutput = """ - hi there - - """ print("hi there") - expect(output.contents) == expectedOutput + expect(output.contents) == """ + hi there + + """ } } }