Skip to content

Commit 6862cb7

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 cdddd4e commit 6862cb7

File tree

6 files changed

+264
-80
lines changed

6 files changed

+264
-80
lines changed

Sources/Swiftly/Install.swift

Lines changed: 102 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -84,31 +84,8 @@ struct Install: SwiftlyCommand {
8484
try validateSwiftly(ctx)
8585

8686
var config = try Config.load(ctx)
87+
let toolchainVersion = try await Self.determineToolchainVersion(ctx, version: self.version, config: &config)
8788

88-
var selector: ToolchainSelector
89-
90-
if let version = self.version {
91-
selector = try ToolchainSelector(parsing: version)
92-
} else {
93-
if case let (_, result) = try await selectToolchain(ctx, config: &config),
94-
case let .swiftVersionFile(_, sel, error) = result
95-
{
96-
if let sel = sel {
97-
selector = sel
98-
} else if let error = error {
99-
throw error
100-
} else {
101-
throw SwiftlyError(message: "Internal error selecting toolchain to install.")
102-
}
103-
} else {
104-
throw SwiftlyError(
105-
message:
106-
"Swiftly couldn't determine the toolchain version to install. Please set a version like this and try again: `swiftly install latest`"
107-
)
108-
}
109-
}
110-
111-
let toolchainVersion = try await Self.resolve(ctx, config: config, selector: selector)
11289
let (postInstallScript, pathChanged) = try await Self.execute(
11390
ctx,
11491
version: toolchainVersion,
@@ -158,6 +135,101 @@ struct Install: SwiftlyCommand {
158135
to: URL(fileURLWithPath: postInstallFile), options: .atomic
159136
)
160137
}
138+
}
139+
140+
public static func setupProxies(
141+
_ ctx: SwiftlyCoreContext,
142+
version: ToolchainVersion,
143+
verbose: Bool,
144+
assumeYes: Bool
145+
) async throws -> Bool {
146+
var pathChanged = false
147+
148+
// Create proxies if we have a location where we can point them
149+
if let proxyTo = try? Swiftly.currentPlatform.findSwiftlyBin(ctx) {
150+
// Ensure swiftly doesn't overwrite any existing executables without getting confirmation first.
151+
let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx)
152+
let swiftlyBinDirContents =
153+
(try? FileManager.default.contentsOfDirectory(atPath: swiftlyBinDir.path)) ?? [String]()
154+
let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version)
155+
let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(
156+
atPath: toolchainBinDir.path)
157+
158+
let existingProxies = swiftlyBinDirContents.filter { bin in
159+
do {
160+
let linkTarget = try FileManager.default.destinationOfSymbolicLink(
161+
atPath: swiftlyBinDir.appendingPathComponent(bin).path)
162+
return linkTarget == proxyTo
163+
} catch { return false }
164+
}
165+
166+
let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection(
167+
swiftlyBinDirContents)
168+
if !overwrite.isEmpty && !assumeYes {
169+
await ctx.print("The following existing executables will be overwritten:")
170+
171+
for executable in overwrite {
172+
await ctx.print(" \(swiftlyBinDir.appendingPathComponent(executable).path)")
173+
}
174+
175+
guard await ctx.promptForConfirmation(defaultBehavior: false) else {
176+
throw SwiftlyError(message: "Toolchain installation has been cancelled")
177+
}
178+
}
179+
180+
if verbose {
181+
await ctx.print("Setting up toolchain proxies...")
182+
}
183+
184+
let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union(
185+
overwrite)
186+
187+
for p in proxiesToCreate {
188+
let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx).appendingPathComponent(p)
189+
190+
if proxy.fileExists() {
191+
try FileManager.default.removeItem(at: proxy)
192+
}
193+
194+
try FileManager.default.createSymbolicLink(
195+
atPath: proxy.path,
196+
withDestinationPath: proxyTo
197+
)
198+
199+
pathChanged = true
200+
}
201+
}
202+
return pathChanged
203+
}
204+
205+
static func determineToolchainVersion(
206+
_ ctx: SwiftlyCoreContext,
207+
version: String?,
208+
config: inout Config
209+
) async throws -> ToolchainVersion {
210+
let selector: ToolchainSelector
211+
212+
if let version = version {
213+
selector = try ToolchainSelector(parsing: version)
214+
} else {
215+
if case let (_, result) = try await selectToolchain(ctx, config: &config),
216+
case let .swiftVersionFile(_, sel, error) = result {
217+
if let sel = sel {
218+
selector = sel
219+
} else if let error = error {
220+
throw error
221+
} else {
222+
throw SwiftlyError(message: "Internal error selecting toolchain to install.")
223+
}
224+
} else {
225+
throw SwiftlyError(
226+
message:
227+
"Swiftly couldn't determine the toolchain version to install. Please set a version like this and try again: `swiftly install latest`"
228+
)
229+
}
230+
}
231+
232+
return try await Self.resolve(ctx, config: config, selector: selector)
161233
}
162234

163235
public static func execute(
@@ -274,62 +346,12 @@ struct Install: SwiftlyCommand {
274346

275347
try await Swiftly.currentPlatform.install(ctx, from: tmpFile, version: version, verbose: verbose)
276348

277-
var pathChanged = false
278-
279-
// Create proxies if we have a location where we can point them
280-
if let proxyTo = try? Swiftly.currentPlatform.findSwiftlyBin(ctx) {
281-
// Ensure swiftly doesn't overwrite any existing executables without getting confirmation first.
282-
let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx)
283-
let swiftlyBinDirContents =
284-
(try? FileManager.default.contentsOfDirectory(atPath: swiftlyBinDir.path)) ?? [String]()
285-
let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version)
286-
let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(
287-
atPath: toolchainBinDir.path)
288-
289-
let existingProxies = swiftlyBinDirContents.filter { bin in
290-
do {
291-
let linkTarget = try FileManager.default.destinationOfSymbolicLink(
292-
atPath: swiftlyBinDir.appendingPathComponent(bin).path)
293-
return linkTarget == proxyTo
294-
} catch { return false }
295-
}
296-
297-
let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection(
298-
swiftlyBinDirContents)
299-
if !overwrite.isEmpty && !assumeYes {
300-
await ctx.print("The following existing executables will be overwritten:")
301-
302-
for executable in overwrite {
303-
await ctx.print(" \(swiftlyBinDir.appendingPathComponent(executable).path)")
304-
}
305-
306-
guard await ctx.promptForConfirmation(defaultBehavior: false) else {
307-
throw SwiftlyError(message: "Toolchain installation has been cancelled")
308-
}
309-
}
310-
311-
if verbose {
312-
await ctx.print("Setting up toolchain proxies...")
313-
}
314-
315-
let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union(
316-
overwrite)
317-
318-
for p in proxiesToCreate {
319-
let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx).appendingPathComponent(p)
320-
321-
if proxy.fileExists() {
322-
try FileManager.default.removeItem(at: proxy)
323-
}
324-
325-
try FileManager.default.createSymbolicLink(
326-
atPath: proxy.path,
327-
withDestinationPath: proxyTo
328-
)
329-
330-
pathChanged = true
331-
}
332-
}
349+
let pathChanged = try await Self.setupProxies(
350+
ctx,
351+
version: version,
352+
verbose: verbose,
353+
assumeYes: assumeYes
354+
)
333355

334356
config.installedToolchains.insert(version)
335357

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 let 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 await 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 let 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+
}

Tests/SwiftlyTests/UnlinkTests.swift

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)