Skip to content

Make the toolchains directory location configurable #324

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

Merged
merged 8 commits into from
Apr 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ jobs:
- name: Extract and Run Workflow Tests
run: cp swiftly-*.tar.gz /root/swiftly.tar.gz && cp test-swiftly-*.tar.gz /root && cd /root && tar zxf test-swiftly-*.tar.gz && ./test-swiftly -y ./swiftly.tar.gz

release-custom-install-test:
name: Test Release - Custom Install Location
needs: releasebuildcheck
runs-on: ubuntu-latest
container:
image: "ubuntu:24.04"
steps:
- name: Prepare System
run: apt-get update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get -y install ca-certificates gpg tzdata
- name: Download Release
uses: actions/download-artifact@v4
with:
name: swiftly-release-x86_64
- name: Download Tests
uses: actions/download-artifact@v4
with:
name: swiftly-tests-x86_64
- name: Extract and Run Workflow Tests
run: cp swiftly-*.tar.gz /root/swiftly.tar.gz && cp test-swiftly-*.tar.gz /root && cd /root && tar zxf test-swiftly-*.tar.gz && ./test-swiftly -y --custom-location ./swiftly.tar.gz

formatcheck:
name: Format Check
runs-on: ubuntu-latest
Expand Down
4 changes: 3 additions & 1 deletion Sources/LinuxPlatform/Linux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ public struct Linux: Platform {
}

public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL {
self.swiftlyHomeDir(ctx).appendingPathComponent("toolchains", isDirectory: true)
ctx.mockedHomeDir.map { $0.appendingPathComponent("toolchains", isDirectory: true) }
?? ProcessInfo.processInfo.environment["SWIFTLY_TOOLCHAINS_DIR"].map { URL(fileURLWithPath: $0) }
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/share/swiftly/toolchains")
}

public var toolchainFileExtension: String {
Expand Down
40 changes: 30 additions & 10 deletions Sources/MacOSPlatform/MacOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ public struct MacOS: Platform {
.appendingPathComponent(".swiftly", isDirectory: true)
}

public var defaultToolchainsDirectory: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Developer/Toolchains", isDirectory: true)
}

public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> URL {
ctx.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) }
?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) }
Expand All @@ -27,10 +32,9 @@ public struct MacOS: Platform {

public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL {
ctx.mockedHomeDir.map { $0.appendingPathComponent("Toolchains", isDirectory: true) }
// The toolchains are always installed here by the installer. We bypass the installer in the case of test mocks
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(
"Library/Developer/Toolchains", isDirectory: true
)
?? ProcessInfo.processInfo.environment["SWIFTLY_TOOLCHAINS_DIR"].map { URL(fileURLWithPath: $0) }
// This is where the installer will put the toolchains, and where Xcode can find them
?? self.defaultToolchainsDirectory
}

public var toolchainFileExtension: String {
Expand All @@ -56,31 +60,47 @@ public struct MacOS: Platform {
throw SwiftlyError(message: "\(tmpFile) doesn't exist")
}

if !self.swiftlyToolchainsDir(ctx).fileExists() {
let toolchainsDir = self.swiftlyToolchainsDir(ctx)

if !toolchainsDir.fileExists() {
try FileManager.default.createDirectory(
at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: false
at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: true
)
}

if ctx.mockedHomeDir == nil {
if toolchainsDir == self.defaultToolchainsDirectory {
// If the toolchains go into the default user location then we use the installer to install them
await ctx.print("Installing package in user home directory...")
try runProgram(
"installer", "-verbose", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory",
quiet: !verbose
)
} else {
// In the case of a mock for testing purposes we won't use the installer, perferring a manual process because
// the installer will not install to an arbitrary path, only a volume or user home directory.
// Otherwise, we extract the pkg into the requested toolchains directory.
await ctx.print("Expanding pkg...")
let tmpDir = self.getTempFilePath()
let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent(
let toolchainDir = toolchainsDir.appendingPathComponent(
"\(version.identifier).xctoolchain", isDirectory: true
)

if !toolchainDir.fileExists() {
try FileManager.default.createDirectory(
at: toolchainDir, withIntermediateDirectories: false
)
}

await ctx.print("Checking package signature...")
do {
try runProgram("pkgutil", "--check-signature", tmpFile.path, quiet: !verbose)
} catch {
// If this is not a test that uses mocked toolchains then we must throw this error and abort installation
guard ctx.mockedHomeDir != nil else {
throw error
}

// We permit the signature verification to fail during testing
await ctx.print("Signature verification failed, which is allowable during testing with mocked toolchains")
}
try runProgram("pkgutil", "--verbose", "--expand", tmpFile.path, tmpDir.path, quiet: !verbose)
// There's a slight difference in the location of the special Payload file between official swift packages
// and the ones that are mocked here in the test framework.
Expand Down
82 changes: 47 additions & 35 deletions Sources/Swiftly/Init.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ struct Init: SwiftlyCommand {

// Give the user the prompt and the choice to abort at this point.
if !assumeYes {
let toolchainsDir = Swiftly.currentPlatform.swiftlyToolchainsDir(ctx)

var msg = """
Welcome to swiftly, the Swift toolchain manager for Linux and macOS!

Expand All @@ -81,12 +83,20 @@ struct Init: SwiftlyCommand {

\(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path) - Directory for configuration files
\(Swiftly.currentPlatform.swiftlyBinDir(ctx).path) - Links to the binaries of the active toolchain
\(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx).path) - Directory hosting installed toolchains
\(toolchainsDir.path) - Directory hosting installed toolchains

These locations can be changed by setting the environment variables
SWIFTLY_HOME_DIR and SWIFTLY_BIN_DIR before running 'swiftly init' again.
SWIFTLY_HOME_DIR, SWIFTLY_BIN_DIR, and SWIFTLY_TOOLCHAINS_DIR before running 'swiftly init' again.

"""
#if os(macOS)
if toolchainsDir != FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Developer/Toolchains", isDirectory: true) {
msg += """

NOTE: The toolchains are not being installed in a standard macOS location, so Xcode may not be able to find them.
"""
}
#endif
if !skipInstall {
msg += """

Expand Down Expand Up @@ -186,6 +196,7 @@ struct Init: SwiftlyCommand {
env = """
set -x SWIFTLY_HOME_DIR "\(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path)"
set -x SWIFTLY_BIN_DIR "\(Swiftly.currentPlatform.swiftlyBinDir(ctx).path)"
set -x SWIFTLY_TOOLCHAINS_DIR "\(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx).path)"
if not contains "$SWIFTLY_BIN_DIR" $PATH
set -x PATH "$SWIFTLY_BIN_DIR" $PATH
end
Expand All @@ -195,6 +206,7 @@ struct Init: SwiftlyCommand {
env = """
export SWIFTLY_HOME_DIR="\(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path)"
export SWIFTLY_BIN_DIR="\(Swiftly.currentPlatform.swiftlyBinDir(ctx).path)"
export SWIFTLY_TOOLCHAINS_DIR="\(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx).path)"
if [[ ":$PATH:" != *":$SWIFTLY_BIN_DIR:"* ]]; then
export PATH="$SWIFTLY_BIN_DIR:$PATH"
fi
Expand Down Expand Up @@ -250,50 +262,50 @@ struct Init: SwiftlyCommand {
addEnvToProfile = true
}

var postInstall: String?
var pathChanged = false

if !skipInstall {
let latestVersion = try await Install.resolve(ctx, config: config, selector: ToolchainSelector.latest)
(postInstall, pathChanged) = try await Install.execute(ctx, version: latestVersion, &config, useInstalledToolchain: true, verifySignature: true, verbose: verbose, assumeYes: assumeYes)
}

if addEnvToProfile {
try Data(sourceLine.utf8).append(to: profileHome)
}
}

if !quietShellFollowup {
await ctx.print("""
To begin using installed swiftly from your current shell, first run the following command:
\(sourceLine)
var postInstall: String?
var pathChanged = false

""")
}
}
if !skipInstall {
let latestVersion = try await Install.resolve(ctx, config: config, selector: ToolchainSelector.latest)
(postInstall, pathChanged) = try await Install.execute(ctx, version: latestVersion, &config, useInstalledToolchain: true, verifySignature: true, verbose: verbose, assumeYes: assumeYes)
}

if !quietShellFollowup {
await ctx.print("""
To begin using installed swiftly from your current shell, first run the following command:
\(sourceLine)

// Fish doesn't have path caching, so this might only be needed for bash/zsh
if pathChanged && !quietShellFollowup && !shell.hasSuffix("fish") {
await ctx.print("""
Your shell caches items on your path for better performance. Swiftly has added
items to your path that may not get picked up right away. You can update your
shell's environment by running
""")
}

hash -r
// Fish doesn't have path caching, so this might only be needed for bash/zsh
if pathChanged && !quietShellFollowup && !shell.hasSuffix("fish") {
await ctx.print("""
Your shell caches items on your path for better performance. Swiftly has added
items to your path that may not get picked up right away. You can update your
shell's environment by running

or restarting your shell.
hash -r

""")
}
or restarting your shell.

if let postInstall {
await ctx.print("""
There are some dependencies that should be installed before using this toolchain.
You can run the following script as the system administrator (e.g. root) to prepare
your system:
""")
}

\(postInstall)
if let postInstall {
await ctx.print("""
There are some dependencies that should be installed before using this toolchain.
You can run the following script as the system administrator (e.g. root) to prepare
your system:

""")
}
\(postInstall)

""")
}
}
}
2 changes: 1 addition & 1 deletion Sources/SwiftlyCore/Platform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ extension Platform {
guard tcPath.fileExists() else {
throw SwiftlyError(
message:
"Toolchain \(toolchain) could not be located. You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again."
"Toolchain \(toolchain) could not be located in \(tcPath). You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again."
)
}

Expand Down
45 changes: 30 additions & 15 deletions Sources/TestSwiftly/TestSwiftly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ struct TestSwiftly: AsyncParsableCommand {
@Flag(name: [.customShort("y"), .long], help: "Disable confirmation prompts by assuming 'yes'")
var assumeYes: Bool = false

@Flag(help: "Install swiftly to a custom location, not added to the user profile.")
var customLocation: Bool = false

@Argument var swiftlyArchive: String? = nil

mutating func run() async throws {
Expand All @@ -41,30 +44,40 @@ struct TestSwiftly: AsyncParsableCommand {
try currentPlatform.runProgram("installer", "-pkg", swiftlyArchive, "-target", "CurrentUserHomeDirectory", quiet: false)
#endif

print("Running 'swiftly init --assume-yes --verbose' to install swiftly and the latest toolchain")

#if os(Linux)
let extractedSwiftly = "./swiftly"
#elseif os(macOS)
let extractedSwiftly = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".swiftly/bin/swiftly").path
#endif

try currentPlatform.runProgram(extractedSwiftly, "init", "--assume-yes", "--skip-install", quiet: false)

var env = ProcessInfo.processInfo.environment
let shell = try await currentPlatform.getShell()
var customLoc: URL?

var env = ProcessInfo.processInfo.environment
if self.customLocation {
customLoc = currentPlatform.getTempFilePath()

// Setting this environment helps to ensure that the profile gets sourced with bash, even if it is not in an interactive shell
if shell.hasSuffix("bash") {
env["BASH_ENV"] = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".profile").path
} else if shell.hasSuffix("zsh") {
env["ZDOTDIR"] = FileManager.default.homeDirectoryForCurrentUser.path
} else if shell.hasSuffix("fish") {
env["XDG_CONFIG_HOME"] = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config").path
}
print("Installing swiftly to custom location \(customLoc!.path)")
env["SWIFTLY_HOME_DIR"] = customLoc!.path
env["SWIFTLY_BIN_DIR"] = customLoc!.appendingPathComponent("bin").path
env["SWIFTLY_TOOLCHAINS_DIR"] = customLoc!.appendingPathComponent("toolchains").path

try currentPlatform.runProgram(shell, "-l", "-c", "swiftly install --assume-yes latest --post-install-file=./post-install.sh", quiet: false, env: env)
try currentPlatform.runProgram(extractedSwiftly, "init", "--assume-yes", "--no-modify-profile", "--skip-install", quiet: false, env: env)
try currentPlatform.runProgram(shell, "-l", "-c", ". \"\(customLoc!.path)/env.sh\" && swiftly install --assume-yes latest --post-install-file=./post-install.sh", quiet: false, env: env)
} else {
print("Installing swiftly to the default location.")
// Setting this environment helps to ensure that the profile gets sourced with bash, even if it is not in an interactive shell
if shell.hasSuffix("bash") {
env["BASH_ENV"] = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".profile").path
} else if shell.hasSuffix("zsh") {
env["ZDOTDIR"] = FileManager.default.homeDirectoryForCurrentUser.path
} else if shell.hasSuffix("fish") {
env["XDG_CONFIG_HOME"] = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config").path
}

try currentPlatform.runProgram(extractedSwiftly, "init", "--assume-yes", "--skip-install", quiet: false, env: env)
try currentPlatform.runProgram(shell, "-l", "-c", "swiftly install --assume-yes latest --post-install-file=./post-install.sh", quiet: false, env: env)
}

var swiftReady = false

Expand All @@ -77,7 +90,9 @@ struct TestSwiftly: AsyncParsableCommand {
swiftReady = true
}

if swiftReady {
if let customLoc = customLoc, swiftReady {
try currentPlatform.runProgram(shell, "-l", "-c", ". \"\(customLoc.path)/env.sh\" && swift --version", quiet: false, env: env)
} else if swiftReady {
try currentPlatform.runProgram(shell, "-l", "-c", "swift --version", quiet: false, env: env)
}
}
Expand Down