Skip to content
Closed
365 changes: 365 additions & 0 deletions Sources/Commands/SwiftPackageTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ public struct SwiftPackageTool: ParsableCommand {
ToolsVersionCommand.self,
ComputeChecksum.self,
ArchiveSource.self,
InstallPackage.self,
RemovePackage.self,
UpdatePackage.self,
CompletionTool.self,
PluginCommand.self,

Expand Down Expand Up @@ -896,6 +899,226 @@ extension SwiftPackageTool {
}
}

struct InstallPackage: SwiftCommand {
Copy link
Contributor

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.

Copy link
Contributor Author

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

Copy link
Contributor

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 update and remove subcommands.

static let configuration: CommandConfiguration = CommandConfiguration(
abstract: "Clone, compile, and copy a remote executable target"
Copy link
Contributor

Choose a reason for hiding this comment

The 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
abstract: "Clone, compile, and copy a remote executable target"
abstract: "Clone, compile, and install an executable product."

)

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO for an initial implementation it's better to always operate on Package.swift that's already in cwd or specified by --package-path like the rest of swift package subcommands do. I suggest removing these arguments and flags and just assuming this subcommand always runs where a package manifest to parse is available.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted for rewrite

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MaxDesiatov @SerenaKit would be interesting to consider a swift package install <scm url> and swift package install <registry identifier>, otherwise it would be a two step process

Copy link
Contributor

Choose a reason for hiding this comment

The 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 experimental- prefix if we agree on this direction in principle.

Then we can implement installation of remote repositories in a subsequent iteration, depending on community feedback in the pitch thread.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also we would first need to redesign SwiftPM's command structure, swift package install cannot install a remote package today by definition (since swift package implies being in a package).


@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",
Expand Down Expand Up @@ -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."
}
}
}
}