-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Add Subcommands to install, update, and remove packages. #5591
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
edf4589
3ed6f5e
a2c8c40
0127b57
2069d14
62cb55b
df92267
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -59,6 +59,9 @@ public struct SwiftPackageTool: ParsableCommand { | |||||
| ToolsVersionCommand.self, | ||||||
| ComputeChecksum.self, | ||||||
| ArchiveSource.self, | ||||||
| InstallPackage.self, | ||||||
| RemovePackage.self, | ||||||
| UpdatePackage.self, | ||||||
| CompletionTool.self, | ||||||
| PluginCommand.self, | ||||||
|
|
||||||
|
|
@@ -896,6 +899,226 @@ extension SwiftPackageTool { | |||||
| } | ||||||
| } | ||||||
|
|
||||||
| struct InstallPackage: SwiftCommand { | ||||||
| static let configuration: CommandConfiguration = CommandConfiguration( | ||||||
| abstract: "Clone, compile, and copy a remote executable target" | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think it makes sense to install targets. A target is "private" in a package, while a product is what's actually exposed and is public. @neonichu WDYT?
Suggested change
|
||||||
| ) | ||||||
|
|
||||||
| enum RepoAccessType: String, EnumerableFlag { | ||||||
| case url, path | ||||||
| } | ||||||
|
|
||||||
| @Flag(help: "The way to access the given repo, either by a remote git URL or a on-disk path") | ||||||
| var accessType: RepoAccessType | ||||||
|
|
||||||
| @Argument(help: "URL of the remote target if using --url, or path to the repo when using --path") | ||||||
| var repoPathOrURL: String | ||||||
|
Comment on lines
+911
to
+915
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMO for an initial implementation it's better to always operate on
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Noted for rewrite
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @MaxDesiatov @SerenaKit would be interesting to consider a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I totally support both of those options for installing remote packages, they are much more complex and will take more time to get right though. To keep iterations small it would be great to start with a relatively small implementation for installing locally cloned repositories, get that reviewed and merged under an Then we can implement installation of remote repositories in a subsequent iteration, depending on community feedback in the pitch thread.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also we would first need to redesign SwiftPM's command structure, |
||||||
|
|
||||||
| @OptionGroup(_hiddenFromHelp: true) | ||||||
| var globalOptions: GlobalOptions | ||||||
|
|
||||||
| @Option( | ||||||
| name: .customLong("target"), | ||||||
| help: "The executable target to install." | ||||||
| ) | ||||||
| var targetName: String? | ||||||
|
|
||||||
| func run(_ swiftTool: SwiftTool) throws { | ||||||
| let spmHomeDir = URL(fileURLWithPath: NSHomeDirectory()) | ||||||
| .appendingPathComponent(".swiftpm") | ||||||
|
|
||||||
| let spmBinPath = spmHomeDir.appendingPathComponent("bin") | ||||||
| if !FileManager.default.fileExists(atPath: spmBinPath.path) { | ||||||
| try FileManager.default.createDirectory(at: spmBinPath, withIntermediateDirectories: true) | ||||||
| } | ||||||
|
|
||||||
| // If ~/.swiftpm/bin isn't in the user PATH, emit a warning. | ||||||
| if let userPATH = ProcessInfo.processInfo.environment["PATH"], !userPATH.contains(spmBinPath.path) { | ||||||
| swiftTool.observabilityScope.emit(warning: "User PATH doesn't include \(spmBinPath.path)! This means you won't be able to access the installed executables by default, and will need to specify the full path.") | ||||||
| } | ||||||
|
|
||||||
| let repoPath: AbsolutePath | ||||||
| switch accessType { | ||||||
| case .url: | ||||||
| let buildTmpDir = spmHomeDir | ||||||
| .appendingPathComponent("tmp") | ||||||
| .appendingPathComponent("swiftpm-install-\(String(UUID().uuidString.prefix(5)))") | ||||||
| try FileManager.default.createDirectory(at: buildTmpDir, withIntermediateDirectories: true) | ||||||
|
|
||||||
| repoPath = AbsolutePath(buildTmpDir.path) | ||||||
|
|
||||||
| let gitCloneResult = try tsc_await { | ||||||
| Process.popen(arguments: ["git", "clone", repoPathOrURL, repoPath.pathString], completion: $0) | ||||||
| } | ||||||
|
|
||||||
| guard gitCloneResult.exitStatus == .terminated(code: 0) else { | ||||||
| throw StringError("Failed to clone remote target.") | ||||||
| } | ||||||
| case .path: | ||||||
| repoPath = AbsolutePath(URL(fileURLWithPath: repoPathOrURL).path) | ||||||
| } | ||||||
|
|
||||||
| let gitRepo = GitRepository(path: repoPath) | ||||||
|
|
||||||
| let commitHash = try? gitRepo.getCurrentRevision().identifier | ||||||
| var branch = try? tsc_await { | ||||||
| Process.popen(arguments: [ | ||||||
| "git", | ||||||
| "--git-dir", | ||||||
| repoPath.appending(component: ".git").pathString, | ||||||
| "branch", | ||||||
| "--show-current"], completion: $0) | ||||||
| } | ||||||
| .utf8Output().trimmingCharacters(in: .whitespacesAndNewlines) | ||||||
| // if empty, just nil it. | ||||||
| if branch == "" { | ||||||
| branch = nil | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| var globalOpts = globalOptions | ||||||
| globalOpts.locations._packageDirectory = repoPath | ||||||
| let tool = try SwiftTool(options: globalOpts) | ||||||
| let workspace = try tool.getActiveWorkspace() | ||||||
|
|
||||||
| let packageGraph = try tsc_await { | ||||||
| workspace.loadRootPackage(at: repoPath, observabilityScope: tool.observabilityScope, completion: $0) | ||||||
| } | ||||||
|
|
||||||
| let executableTargets = packageGraph.targets.filter { $0.type == .executable } | ||||||
| // Make sure that there are executable targets. | ||||||
| guard !executableTargets.isEmpty else { | ||||||
| throw InstallErrors.noExecutableTargetsFound | ||||||
| } | ||||||
|
|
||||||
| let binName: String | ||||||
| if let targetName { | ||||||
| guard executableTargets.contains(where: { $0.name == targetName }) else { | ||||||
| throw InstallErrors.noExecutableTargetsFoundWithName(name: targetName) | ||||||
| } | ||||||
| binName = targetName | ||||||
| } else { | ||||||
| // if the user didn't specify the executable target to install, | ||||||
| // make sure there's only one. | ||||||
| guard executableTargets.count == 1 else { | ||||||
| throw InstallErrors.multipleExecutibleTargetsFound | ||||||
| } | ||||||
|
|
||||||
| binName = executableTargets[0].name | ||||||
| } | ||||||
|
|
||||||
| // Make sure that the binary with the same name in ~/.swiftpm/bin/ doesn't exist. | ||||||
| guard !FileManager.default.fileExists(atPath: spmBinPath.appendingPathComponent(binName).path) else { | ||||||
| throw InstallErrors.outputBinaryPathAlreadyExists(path: spmBinPath.appendingPathComponent(binName).path) | ||||||
| } | ||||||
|
|
||||||
| try tool.createBuildSystem().build() | ||||||
|
|
||||||
| let binFullPath = try tool.buildParameters().buildPath.appending(component: binName) | ||||||
|
|
||||||
| try FileManager.default.moveItem( | ||||||
| at: URL(fileURLWithPath: binFullPath.pathString), | ||||||
| to: spmBinPath.appendingPathComponent(binName) | ||||||
| ) | ||||||
|
|
||||||
| switch accessType { | ||||||
| case .url: | ||||||
| try InstalledPackage(name: binName, branch: branch, commitSHA: commitHash, installedPath: spmBinPath.appendingPathComponent(binName).path, remoteRepoURL: repoPathOrURL) | ||||||
| .writeToFile() | ||||||
| case .path: | ||||||
| // if the user is installing a package from a local repo path, | ||||||
| // don't include a remote repo URL if we can't can't get it | ||||||
| var repoRemoteURL = try? tsc_await { | ||||||
| Process.popen(arguments: [ | ||||||
| "git", | ||||||
| "--git-dir", | ||||||
| repoPath.appending(component: ".git").pathString, | ||||||
| "config", | ||||||
| "--get", | ||||||
| "remote.origin.url"], completion: $0) | ||||||
| } | ||||||
| .utf8Output().trimmingCharacters(in: .whitespacesAndNewlines) | ||||||
| if repoRemoteURL == "" { | ||||||
| repoRemoteURL = nil | ||||||
| } | ||||||
|
|
||||||
| try InstalledPackage(name: binName, branch: branch, commitSHA: commitHash, installedPath: spmBinPath.appendingPathComponent(binName).path, remoteRepoURL: repoRemoteURL) | ||||||
| .writeToFile() | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| enum InstallErrors: Swift.Error, LocalizedError { | ||||||
| case remoteTargetURLProvidedNotValid(url: String) | ||||||
| case outputBinaryPathAlreadyExists(path: String) | ||||||
| case noExecutableTargetsFoundWithName(name: String) | ||||||
| case noExecutableTargetsFound | ||||||
| case multipleExecutibleTargetsFound | ||||||
|
|
||||||
| var errorDescription: String? { | ||||||
| switch self { | ||||||
| case .remoteTargetURLProvidedNotValid(let url): | ||||||
| return "Remote Target URL \"\(url)\" is not a valid URL." | ||||||
| case .noExecutableTargetsFound: | ||||||
| return "No executable targets found." | ||||||
| case .outputBinaryPathAlreadyExists(let path): | ||||||
| return "\(path) already exists." | ||||||
| case .noExecutableTargetsFoundWithName(let name): | ||||||
| return "No executable targets with name \"\(name)\" were found." | ||||||
| case .multipleExecutibleTargetsFound: | ||||||
| return "Multiple executable targets found, but the target to install wasn't specified. Please specify one with --target." | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| struct RemovePackage: SwiftCommand { | ||||||
|
|
||||||
| static var configuration: CommandConfiguration = CommandConfiguration( | ||||||
| abstract: "Remove a package previously installed with `swift package install-package`" | ||||||
| ) | ||||||
|
|
||||||
| @OptionGroup(_hiddenFromHelp: true) | ||||||
| var globalOptions: GlobalOptions | ||||||
|
|
||||||
| @Argument(help: "Name of the installed package to remove") | ||||||
| var name: String | ||||||
|
|
||||||
| func run(_ swiftTool: SwiftTool) throws { | ||||||
| guard let package = try InstalledPackage.getInstalledPackages().first(where: { installedPkg in | ||||||
| installedPkg.name == name | ||||||
| }) else { | ||||||
| throw StringError("No installed packages with the name \"\(name)\" found.") | ||||||
| } | ||||||
|
|
||||||
| try package.remove() | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| struct UpdatePackage: SwiftCommand { | ||||||
| static var configuration: CommandConfiguration = CommandConfiguration( | ||||||
| abstract: "Update a package previously installed with `swift package install-package`" | ||||||
| ) | ||||||
|
|
||||||
| @OptionGroup(_hiddenFromHelp: true) | ||||||
| var globalOptions: GlobalOptions | ||||||
|
|
||||||
| @Argument(help: "The name of the installed package to update") | ||||||
| var name: String | ||||||
|
|
||||||
| @Flag(help: "Force update the installed package") | ||||||
| var force: Bool = false | ||||||
|
|
||||||
| func run(_ swiftTool: SwiftTool) throws { | ||||||
| guard let package = try InstalledPackage.getInstalledPackages().first(where: { installedPkg in | ||||||
| installedPkg.name == name | ||||||
| }) else { | ||||||
| throw StringError("No installed packages with the name \"\(name)\" found") | ||||||
| } | ||||||
|
|
||||||
| try package.update(globalOptions: self.globalOptions, force: force) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| struct ArchiveSource: SwiftCommand { | ||||||
| static let configuration = CommandConfiguration( | ||||||
| commandName: "archive-source", | ||||||
|
|
@@ -1893,3 +2116,145 @@ extension BuildSystem { | |||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| struct InstalledPackage: Codable, Equatable { | ||||||
| let name: String | ||||||
| let branch: String? | ||||||
| let commitSHA: String? | ||||||
| let installedPath: String | ||||||
| let remoteRepoURL: String? | ||||||
|
|
||||||
| static private var InstalledPackagesFileURL = URL(fileURLWithPath: NSHomeDirectory()) | ||||||
| .appendingPathComponent(".swiftpm") | ||||||
| .appendingPathComponent("installed-packages.json") | ||||||
|
|
||||||
| static public func getInstalledPackages() throws -> [InstalledPackage] { | ||||||
| guard FileManager.default.fileExists(atPath: self.InstalledPackagesFileURL.path) else { | ||||||
| throw Errors.faildToLoadPackagesJSONFile(reason: "installed-packages.json file doesn't exist") | ||||||
| } | ||||||
|
|
||||||
| do { | ||||||
| let data = try Data(contentsOf: self.InstalledPackagesFileURL) | ||||||
| return try JSONDecoder().decode([InstalledPackage].self, from: data) | ||||||
| } catch { | ||||||
| throw Errors.faildToLoadPackagesJSONFile(reason: "Failed to load or decode file, check if it's corrupted?") | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// Registers the item to the installed-packages.json file | ||||||
| func writeToFile() throws { | ||||||
| let installedPackagesFileURL = InstalledPackage.InstalledPackagesFileURL | ||||||
|
|
||||||
| var array: [InstalledPackage] = [] | ||||||
| if FileManager.default.fileExists(atPath: Self.InstalledPackagesFileURL.path) { | ||||||
| array = try Self.getInstalledPackages() | ||||||
| } | ||||||
|
|
||||||
| // Make sure the item doesn't already exist | ||||||
| guard !array.contains(self) || !array.map(\.name).contains(self.name) else { | ||||||
| throw Errors.itemAlreadyExists(item: self) | ||||||
| } | ||||||
|
|
||||||
| array.append(self) | ||||||
|
|
||||||
| if FileManager.default.fileExists(atPath: installedPackagesFileURL.path) { | ||||||
| try FileManager.default.removeItem(at: installedPackagesFileURL) | ||||||
| } | ||||||
|
|
||||||
| let encoder = JSONEncoder() | ||||||
| encoder.outputFormatting = .prettyPrinted | ||||||
|
|
||||||
| let didCreateFile = FileManager.default.createFile(atPath: installedPackagesFileURL.path, contents: try encoder.encode(array)) | ||||||
|
|
||||||
| guard didCreateFile else { | ||||||
| throw Errors.failedToCreatePackagesJSONFile | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// Removes the installed binary of the installed package, | ||||||
| /// and removes the item from the installed-packages.json file | ||||||
| func remove() throws { | ||||||
| // Get rid of the installed binary | ||||||
| if FileManager.default.fileExists(atPath: self.installedPath) { | ||||||
| try FileManager.default.removeItem(at: URL(fileURLWithPath: self.installedPath)) | ||||||
| } | ||||||
|
|
||||||
| var installedPackages = try Self.getInstalledPackages() | ||||||
|
|
||||||
| installedPackages.removeAll { | ||||||
| $0 == self | ||||||
| } | ||||||
|
|
||||||
| let encoder = JSONEncoder() | ||||||
| encoder.outputFormatting = .prettyPrinted | ||||||
|
|
||||||
| try FileManager.default.removeItem(at: Self.InstalledPackagesFileURL) | ||||||
| let didCreateFile = FileManager.default.createFile(atPath: Self.InstalledPackagesFileURL.path, contents: try encoder.encode(installedPackages)) | ||||||
|
|
||||||
| guard didCreateFile else { | ||||||
| throw Errors.failedToCreatePackagesJSONFile | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| func update(globalOptions: GlobalOptions, force: Bool) throws { | ||||||
| guard let remoteRepoURL = self.remoteRepoURL, | ||||||
| let branch = self.branch, | ||||||
| let instanceCommitSHA = self.commitSHA else { | ||||||
| throw StringError("Can't update package because there is no git repo URL, branch, or commit SHA set for it, this probably means the package was installed with --path, meaning it does not support updates.") | ||||||
| } | ||||||
|
|
||||||
| let updateTmpDir = URL(fileURLWithPath: NSHomeDirectory()) | ||||||
| .appendingPathComponent(".swiftpm") | ||||||
| .appendingPathComponent("tmp") | ||||||
| .appendingPathComponent("spm-update-\(name)-\(String(UUID().uuidString.prefix(5)))") | ||||||
|
|
||||||
| let gitCloneResult = try tsc_await { | ||||||
| Process.popen(arguments: ["git", "clone", remoteRepoURL, updateTmpDir.path, "-b", branch], completion: $0) | ||||||
| } | ||||||
|
|
||||||
| guard gitCloneResult.exitStatus == .terminated(code: 0) else { | ||||||
| throw StringError("Failed to clone remote target.") | ||||||
| } | ||||||
|
|
||||||
| let gitRepo = GitRepository(path: .init(updateTmpDir.path)) | ||||||
| let commitHash = try gitRepo.getCurrentRevision().identifier | ||||||
| if commitHash == instanceCommitSHA && FileManager.default.fileExists(atPath: installedPath) && !force { | ||||||
| print("Commit hash of latest commit from remote repositery is the same, no need to update. Exiting gracefully") | ||||||
| exit(0) | ||||||
| } | ||||||
|
|
||||||
| var globalOpts = globalOptions | ||||||
| globalOpts.locations._packageDirectory = AbsolutePath(updateTmpDir.path) | ||||||
| let tool = try SwiftTool(options: globalOpts) | ||||||
| try tool.createBuildSystem().build() | ||||||
|
|
||||||
| // The path to which the executable that was just built is placed in | ||||||
| let executableBuiltPath = try tool.buildParameters().buildPath.appending(component: self.name) | ||||||
|
|
||||||
| // Replace the old instance in the JSON file | ||||||
| try self.remove() | ||||||
|
|
||||||
| try FileManager.default.moveItem(at: URL(fileURLWithPath: executableBuiltPath.pathString), to: URL(fileURLWithPath: installedPath)) | ||||||
|
|
||||||
| let newInstance = InstalledPackage(name: self.name, branch: self.branch, commitSHA: commitHash, installedPath: self.installedPath, remoteRepoURL: self.remoteRepoURL) | ||||||
| try newInstance.writeToFile() | ||||||
| } | ||||||
|
|
||||||
| enum Errors: Swift.Error, LocalizedError { | ||||||
| case faildToLoadPackagesJSONFile(reason: String) | ||||||
| case failedToCreatePackagesJSONFile | ||||||
| case itemAlreadyExists(item: InstalledPackage) | ||||||
|
|
||||||
|
|
||||||
| var errorDescription: String? { | ||||||
| switch self { | ||||||
| case .failedToCreatePackagesJSONFile: | ||||||
| return "Failed to create installed-packages.json file." | ||||||
| case .faildToLoadPackagesJSONFile(let reason): | ||||||
| return "Failed to access / load installed-packages.json file: \(reason)" | ||||||
| case .itemAlreadyExists(let package): | ||||||
| return "Cannot add \(package) to installed-packages.json because it already exists there." | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's put this type into a separate file so that this one stays as small as possible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, think I'll close this specific PR, rewriting rn
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You may want to have a look at this PR which I opened before discovering your implementation #6758. Please feel free to cherry-pick that commit into your rewrite branch if you'd like, I'll close my PR in favor of your implementation then since it's more complete with
updateandremovesubcommands.