Skip to content

Commit 5699c19

Browse files
committed
Add ability to temporarily disable swiftly
Adds two new commands, `swiftly unlink` and `swiftly link`, which will disable and reenable swiftly's management of the active toolchain. The `unlink` command removes the symlinks to toolchain binaries in the swiftly bin directory that is in the user's path. This allows the rest of the `$PATH` to be searched for available toolchain installations, falling back to the system default. On macOS with Xcode installed this has the effect of falling back to the toolchain in the user's installed Xcode. The `link` command reinstates the symlinks to the `inUse` toolchain, which allows swiftly to resume management of the active toolchain.
1 parent 8f0bf20 commit 5699c19

File tree

6 files changed

+256
-72
lines changed

6 files changed

+256
-72
lines changed

Sources/Swiftly/Install.swift

Lines changed: 94 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -76,28 +76,8 @@ struct Install: SwiftlyCommand {
7676
try validateSwiftly(ctx)
7777

7878
var config = try Config.load(ctx)
79+
let toolchainVersion = try await Self.determineToolchainVersion(ctx, version: self.version, config: &config)
7980

80-
var selector: ToolchainSelector
81-
82-
if let version = self.version {
83-
selector = try ToolchainSelector(parsing: version)
84-
} else {
85-
if case let (_, result) = try await selectToolchain(ctx, config: &config),
86-
case let .swiftVersionFile(_, sel, error) = result
87-
{
88-
if let sel = sel {
89-
selector = sel
90-
} else if let error = error {
91-
throw error
92-
} else {
93-
throw SwiftlyError(message: "Internal error selecting toolchain to install.")
94-
}
95-
} else {
96-
throw SwiftlyError(message: "Swiftly couldn't determine the toolchain version to install. Please set a version like this and try again: `swiftly install latest`")
97-
}
98-
}
99-
100-
let toolchainVersion = try await Self.resolve(ctx, config: config, selector: selector)
10181
let (postInstallScript, pathChanged) = try await Self.execute(
10282
ctx,
10383
version: toolchainVersion,
@@ -141,6 +121,93 @@ struct Install: SwiftlyCommand {
141121

142122
try Data(postInstallScript.utf8).write(to: URL(fileURLWithPath: postInstallFile), options: .atomic)
143123
}
124+
}
125+
126+
public static func setupProxies(
127+
_ ctx: SwiftlyCoreContext,
128+
version: ToolchainVersion,
129+
verbose: Bool,
130+
assumeYes: Bool
131+
) throws -> Bool {
132+
var pathChanged = false
133+
134+
// Create proxies if we have a location where we can point them
135+
if let proxyTo = try? Swiftly.currentPlatform.findSwiftlyBin(ctx) {
136+
// Ensure swiftly doesn't overwrite any existing executables without getting confirmation first.
137+
let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx)
138+
let swiftlyBinDirContents = (try? FileManager.default.contentsOfDirectory(atPath: swiftlyBinDir.path)) ?? [String]()
139+
let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version)
140+
let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(atPath: toolchainBinDir.path)
141+
142+
let existingProxies = swiftlyBinDirContents.filter { bin in
143+
do {
144+
let linkTarget = try FileManager.default.destinationOfSymbolicLink(atPath: swiftlyBinDir.appendingPathComponent(bin).path)
145+
return linkTarget == proxyTo
146+
} catch { return false }
147+
}
148+
149+
let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection(swiftlyBinDirContents)
150+
if !overwrite.isEmpty && !assumeYes {
151+
ctx.print("The following existing executables will be overwritten:")
152+
153+
for executable in overwrite {
154+
ctx.print(" \(swiftlyBinDir.appendingPathComponent(executable).path)")
155+
}
156+
157+
guard ctx.promptForConfirmation(defaultBehavior: false) else {
158+
throw SwiftlyError(message: "Toolchain installation has been cancelled")
159+
}
160+
}
161+
162+
if verbose {
163+
ctx.print("Setting up toolchain proxies...")
164+
}
165+
166+
let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union(overwrite)
167+
168+
for p in proxiesToCreate {
169+
let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx).appendingPathComponent(p)
170+
171+
if proxy.fileExists() {
172+
try FileManager.default.removeItem(at: proxy)
173+
}
174+
175+
try FileManager.default.createSymbolicLink(
176+
atPath: proxy.path,
177+
withDestinationPath: proxyTo
178+
)
179+
180+
pathChanged = true
181+
}
182+
}
183+
return pathChanged
184+
}
185+
186+
static func determineToolchainVersion(
187+
_ ctx: SwiftlyCoreContext,
188+
version: String?,
189+
config: inout Config
190+
) async throws -> ToolchainVersion {
191+
let selector: ToolchainSelector
192+
193+
if let version = version {
194+
selector = try ToolchainSelector(parsing: version)
195+
} else {
196+
if case let (_, result) = try await selectToolchain(ctx, config: &config),
197+
case let .swiftVersionFile(_, sel, error) = result {
198+
if let sel = sel {
199+
selector = sel
200+
} else if let error = error {
201+
throw error
202+
} else {
203+
throw SwiftlyError(message: "Internal error selecting toolchain to install.")
204+
}
205+
} else {
206+
throw SwiftlyError(message: "Swiftly couldn't determine the toolchain version to install. Please set a version like this and try again: `swiftly install latest`")
207+
}
208+
}
209+
210+
return try await Self.resolve(ctx, config: config, selector: selector)
144211
}
145212

146213
public static func execute(
@@ -256,57 +323,12 @@ struct Install: SwiftlyCommand {
256323

257324
try Swiftly.currentPlatform.install(ctx, from: tmpFile, version: version, verbose: verbose)
258325

259-
var pathChanged = false
260-
261-
// Create proxies if we have a location where we can point them
262-
if let proxyTo = try? Swiftly.currentPlatform.findSwiftlyBin(ctx) {
263-
// Ensure swiftly doesn't overwrite any existing executables without getting confirmation first.
264-
let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx)
265-
let swiftlyBinDirContents = (try? FileManager.default.contentsOfDirectory(atPath: swiftlyBinDir.path)) ?? [String]()
266-
let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version)
267-
let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(atPath: toolchainBinDir.path)
268-
269-
let existingProxies = swiftlyBinDirContents.filter { bin in
270-
do {
271-
let linkTarget = try FileManager.default.destinationOfSymbolicLink(atPath: swiftlyBinDir.appendingPathComponent(bin).path)
272-
return linkTarget == proxyTo
273-
} catch { return false }
274-
}
275-
276-
let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection(swiftlyBinDirContents)
277-
if !overwrite.isEmpty && !assumeYes {
278-
ctx.print("The following existing executables will be overwritten:")
279-
280-
for executable in overwrite {
281-
ctx.print(" \(swiftlyBinDir.appendingPathComponent(executable).path)")
282-
}
283-
284-
guard ctx.promptForConfirmation(defaultBehavior: false) else {
285-
throw SwiftlyError(message: "Toolchain installation has been cancelled")
286-
}
287-
}
288-
289-
if verbose {
290-
ctx.print("Setting up toolchain proxies...")
291-
}
292-
293-
let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union(overwrite)
294-
295-
for p in proxiesToCreate {
296-
let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx).appendingPathComponent(p)
297-
298-
if proxy.fileExists() {
299-
try FileManager.default.removeItem(at: proxy)
300-
}
301-
302-
try FileManager.default.createSymbolicLink(
303-
atPath: proxy.path,
304-
withDestinationPath: proxyTo
305-
)
306-
307-
pathChanged = true
308-
}
309-
}
326+
let pathChanged = try Self.setupProxies(
327+
ctx,
328+
version: version,
329+
verbose: verbose,
330+
assumeYes: assumeYes
331+
)
310332

311333
config.installedToolchains.insert(version)
312334

Sources/Swiftly/Link.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import ArgumentParser
2+
import SwiftlyCore
3+
import Foundation
4+
5+
struct Link: SwiftlyCommand {
6+
public static var configuration = CommandConfiguration(
7+
abstract: "Link swiftly so it resumes management of the active toolchain."
8+
)
9+
10+
@Argument(help: ArgumentHelp(
11+
"Links swiftly if it has been disabled.",
12+
discussion: """
13+
14+
Links swiftly if it has been disabled.
15+
"""
16+
))
17+
var toolchainSelector: String?
18+
19+
@OptionGroup var root: GlobalOptions
20+
21+
mutating func run() async throws {
22+
try await self.run(Swiftly.createDefaultContext())
23+
}
24+
25+
mutating func run(_ ctx: SwiftlyCoreContext) async throws {
26+
try validateSwiftly(ctx)
27+
28+
var config = try Config.load(ctx)
29+
let toolchainVersion = try await Install.determineToolchainVersion(
30+
ctx,
31+
version: nil,
32+
config: &config
33+
)
34+
35+
let _ = try Install.setupProxies(
36+
ctx,
37+
version: toolchainVersion,
38+
verbose: self.root.verbose,
39+
assumeYes: self.root.assumeYes
40+
)
41+
}
42+
}

Sources/Swiftly/Swiftly.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public struct Swiftly: SwiftlyCommand {
3333
Init.self,
3434
SelfUpdate.self,
3535
Run.self,
36+
Link.self,
37+
Unlink.self
3638
]
3739
)
3840

Sources/Swiftly/Unlink.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import ArgumentParser
2+
import SwiftlyCore
3+
import Foundation
4+
5+
struct Unlink: SwiftlyCommand {
6+
public static var configuration = CommandConfiguration(
7+
abstract: "Unlinks swiftly so it no longer manages the active toolchain."
8+
)
9+
10+
@Argument(help: ArgumentHelp(
11+
"Unlinks swiftly, allowing the system default toolchain to be used.",
12+
discussion: """
13+
14+
Unlinks swiftly until swiftly is linked again with:
15+
16+
$ swiftly link
17+
"""
18+
))
19+
var toolchainSelector: String?
20+
21+
@OptionGroup var root: GlobalOptions
22+
23+
mutating func run() async throws {
24+
try await self.run(Swiftly.createDefaultContext())
25+
}
26+
27+
mutating func run(_ ctx: SwiftlyCoreContext) async throws {
28+
try validateSwiftly(ctx)
29+
30+
if let proxyTo = try? Swiftly.currentPlatform.findSwiftlyBin(ctx) {
31+
let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx)
32+
let swiftlyBinDirContents = (try? FileManager.default.contentsOfDirectory(atPath: swiftlyBinDir.path)) ?? [String]()
33+
34+
let existingProxies = swiftlyBinDirContents.filter { bin in
35+
do {
36+
let linkTarget = try FileManager.default.destinationOfSymbolicLink(atPath: swiftlyBinDir.appendingPathComponent(bin).path)
37+
return linkTarget == proxyTo
38+
} catch { return false }
39+
}
40+
41+
for p in existingProxies {
42+
let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx).appendingPathComponent(p)
43+
44+
if proxy.fileExists() {
45+
try FileManager.default.removeItem(at: proxy)
46+
}
47+
}
48+
}
49+
}
50+
}

Tests/SwiftlyTests/LinkTests.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Foundation
2+
@testable import Swiftly
3+
@testable import SwiftlyCore
4+
import Testing
5+
6+
@Suite struct LinkTests {
7+
/// Tests that enabling swiftly results in swiftlyBinDir being populated with symlinks.
8+
@Test func testLink() async throws {
9+
try await SwiftlyTests.withTestHome {
10+
let fm = FileManager.default
11+
let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx)
12+
let swiftlyBinaryPath = swiftlyBinDir.appendingPathComponent("swiftly")
13+
let swiftVersionFilename = SwiftlyTests.ctx.currentDirectory.appendingPathComponent(".swift-version")
14+
15+
// Configure a mock toolchain
16+
let versionString = "6.0.3"
17+
let toolchainVersion = try ToolchainVersion(parsing: versionString)
18+
try versionString.write(to: swiftVersionFilename, atomically: true, encoding: .utf8)
19+
20+
// And start creating a mock folder structure for that toolchain.
21+
try "swiftly binary".write(to: swiftlyBinaryPath, atomically: true, encoding: .utf8)
22+
23+
let toolchainDir = Swiftly.currentPlatform.findToolchainLocation(SwiftlyTests.ctx, toolchainVersion)
24+
.appendingPathComponent("usr")
25+
.appendingPathComponent("bin")
26+
try fm.createDirectory(at: toolchainDir, withIntermediateDirectories: true)
27+
28+
let proxies = ["swift-build", "swift-test", "swift-run"]
29+
for proxy in proxies {
30+
let proxyPath = toolchainDir.appendingPathComponent(proxy)
31+
try fm.createSymbolicLink(at: proxyPath, withDestinationURL: swiftlyBinaryPath)
32+
}
33+
34+
_ = try await SwiftlyTests.runWithMockedIO(Link.self, ["link"])
35+
36+
let enabledSwiftlyBinDirContents = try fm.contentsOfDirectory(atPath: swiftlyBinDir.path).sorted()
37+
let expectedProxies = (["swiftly"] + proxies).sorted()
38+
#expect(enabledSwiftlyBinDirContents == expectedProxies)
39+
}
40+
}
41+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Foundation
2+
@testable import Swiftly
3+
@testable import SwiftlyCore
4+
import Testing
5+
6+
@Suite struct UnlinkTests {
7+
/// Tests that disabling swiftly results in swiftlyBinDir with no symlinks to toolchain binaries in it.
8+
@Test func testUnlink() async throws {
9+
try await SwiftlyTests.withTestHome {
10+
let fm = FileManager.default
11+
let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx)
12+
let swiftlyBinaryPath = swiftlyBinDir.appendingPathComponent("swiftly")
13+
try "mockBinary".write(to: swiftlyBinaryPath, atomically: true, encoding: .utf8)
14+
15+
let proxies = ["swift-build", "swift-test", "swift-run"]
16+
for proxy in proxies {
17+
let proxyPath = swiftlyBinDir.appendingPathComponent(proxy)
18+
try fm.createSymbolicLink(at: proxyPath, withDestinationURL: swiftlyBinaryPath)
19+
}
20+
21+
_ = try await SwiftlyTests.runWithMockedIO(Unlink.self, ["unlink"])
22+
23+
let disabledSwiftlyBinDirContents = try fm.contentsOfDirectory(atPath: swiftlyBinDir.path)
24+
#expect(disabledSwiftlyBinDirContents == ["swiftly"])
25+
}
26+
}
27+
}

0 commit comments

Comments
 (0)