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 1add3561e..a97bfc44c 100644 --- a/package.json +++ b/package.json @@ -2002,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", @@ -2048,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/toolchain/swiftly.ts b/src/toolchain/swiftly.ts index b796ea65f..0208c5b11 100644 --- a/src/toolchain/swiftly.ts +++ b/src/toolchain/swiftly.ts @@ -25,6 +25,7 @@ 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( @@ -305,8 +306,8 @@ export class Swiftly { } if (!(await Swiftly.supportsJsonOutput(logger))) { - logger?.warn("Swiftly version does not support JSON output for list-available"); - return []; + logger?.info("Using legacy text parsing for older Swiftly version"); + return await this.listAvailableLegacy(logger, branch); } try { @@ -322,6 +323,83 @@ export class Swiftly { } } + /** + * Legacy method to parse plain text output from older Swiftly versions + * + * @param logger Optional logger for error reporting + * @param branch Optional branch to filter available toolchains + * @returns Array of available toolchains parsed from text output + */ + private static async listAvailableLegacy( + logger?: SwiftLogger, + branch?: string + ): Promise { + try { + const args = ["list-available"]; + if (branch) { + args.push(branch); + } + const { stdout } = await execFile("swiftly", args); + + // Get list of installed toolchains to mark them as installed + const installedToolchains = new Set(await this.listAvailableToolchains(logger)); + + // Parse the text output + const toolchains: AvailableToolchain[] = []; + const lines = stdout.split("\n"); + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip headers and empty lines + if ( + !trimmedLine || + trimmedLine.startsWith("Available") || + trimmedLine.startsWith("---") + ) { + continue; + } + + // Parse Swift version line (e.g., "Swift 6.1.2 (installed) (in use) (default)") + const match = trimmedLine.match(/^Swift\s+(\d+\.\d+(?:\.\d+)?)/); + if (match) { + const versionString = match[1]; + const fullLine = trimmedLine; + + // Check if this toolchain is installed, in use, or default + const installed = + installedToolchains.has(versionString) || fullLine.includes("(installed)"); + const inUse = fullLine.includes("(in use)"); + const isDefault = fullLine.includes("(default)"); + + // Parse version components + const versionParts = versionString.split(".").map(Number); + const major = versionParts[0] || 0; + const minor = versionParts[1] || 0; + const patch = versionParts[2] || 0; + + toolchains.push({ + inUse, + installed, + isDefault, + version: { + type: "stable", + major, + minor, + patch, + name: versionString, + }, + }); + } + } + + return toolchains; + } catch (error) { + logger?.error(`Failed to retrieve available toolchains using legacy parsing: ${error}`); + return []; + } + } + /** * Installs a toolchain via swiftly with optional progress tracking * @@ -343,10 +421,15 @@ export class 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) { + if (progressCallback && supportsProgressFile) { progressPipePath = path.join(tmpDir, `progress-${version}.pipe`); await execFile("mkfifo", [progressPipePath]); @@ -385,11 +468,13 @@ export class Swiftly { postInstallFilePath, ]; - if (progressPipePath) { + // 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) { @@ -401,19 +486,44 @@ export class Swiftly { 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 { - if (progressPipePath) { + // 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(progressPipePath); - } catch { - // Ignore errors if the pipe file doesn't exist + 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.unlink(postInstallFilePath); - } catch { - // Ignore errors if the post-install file doesn't exist - } + 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(); } } @@ -651,4 +761,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?.warn("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 0e58725d6..ad4aa9066 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(); } } @@ -216,7 +228,7 @@ async function getQuickPickItems( version: path.basename(toolchainPath), onDidSelect: async () => { try { - await Swiftly.use(toolchainPath); + await Swiftly.use(path.basename(toolchainPath)); void showReloadExtensionNotification( "Changing the Swift path requires Visual Studio Code be reloaded." ); @@ -267,9 +279,11 @@ 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(); + }, }); } 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/swiftly.test.ts b/test/unit-tests/toolchain/swiftly.test.ts index 4e912c6d6..d62d7951c 100644 --- a/test/unit-tests/toolchain/swiftly.test.ts +++ b/test/unit-tests/toolchain/swiftly.test.ts @@ -175,11 +175,21 @@ suite("Swiftly Unit Tests", () => { mockedPlatform.setValue("darwin"); const progressCallback = () => {}; - mockUtilities.execFile.withArgs("mkfifo").resolves({ stdout: "", stderr: "" }); - mockUtilities.execFile.withArgs("swiftly", match.array).resolves({ - stdout: "", + // 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({}); @@ -193,9 +203,36 @@ suite("Swiftly Unit Tests", () => { 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 = () => {}; + + // 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); + + // 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 handle installation error properly", async () => { mockedPlatform.setValue("darwin"); const installError = new Error("Installation failed"); @@ -233,16 +270,56 @@ suite("Swiftly Unit Tests", () => { expect(result).to.deep.equal([]); }); - test("should return empty array when Swiftly version doesn't support JSON output", async () => { + test("should use legacy parsing when Swiftly version doesn't support JSON output", async () => { mockedPlatform.setValue("darwin"); mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ stdout: "1.0.0\n", stderr: "", }); + // Mock legacy list-available command + mockUtilities.execFile.withArgs("swiftly", ["list-available"]).resolves({ + stdout: `Available release toolchains +---------------------------- +Swift 6.1.2 (installed) (in use) (default) +Swift 6.1.1 +Swift 6.0.0`, + stderr: "", + }); + + // Mock listAvailableToolchains for installed check + mockUtilities.execFile.withArgs("swiftly", ["list"]).resolves({ + stdout: "6.1.2\n", + stderr: "", + }); + const result = await Swiftly.listAvailable(); - expect(result).to.deep.equal([]); + expect(result).to.have.lengthOf(3); + expect(result[0]).to.deep.include({ + installed: true, + inUse: true, + isDefault: true, + version: { + type: "stable", + major: 6, + minor: 1, + patch: 2, + name: "6.1.2", + }, + }); + expect(result[1]).to.deep.include({ + installed: false, + inUse: false, + isDefault: false, + version: { + type: "stable", + major: 6, + minor: 1, + patch: 1, + name: "6.1.1", + }, + }); }); test("should return available toolchains with installation status", async () => { @@ -362,7 +439,9 @@ suite("Swiftly Unit Tests", () => { test("should call installToolchain with correct parameters", async () => { mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); - mockUtilities.execFile.withArgs("mkfifo").resolves({ stdout: "", stderr: "" }); + mockUtilities.execFile + .withArgs("mkfifo", match.array) + .resolves({ stdout: "", stderr: "" }); await Swiftly.installToolchain("6.0.0"); @@ -380,7 +459,9 @@ suite("Swiftly Unit Tests", () => { test("should handle swiftly installation errors", async () => { const installError = new Error("Swiftly installation failed"); mockUtilities.execFile.withArgs("swiftly").rejects(installError); - mockUtilities.execFile.withArgs("mkfifo").resolves({ stdout: "", stderr: "" }); + mockUtilities.execFile + .withArgs("mkfifo", match.array) + .resolves({ stdout: "", stderr: "" }); await expect(Swiftly.installToolchain("6.0.0")).to.eventually.be.rejectedWith( "Swiftly installation failed" @@ -388,8 +469,13 @@ suite("Swiftly Unit Tests", () => { }); 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").rejects(mkfifoError); + mockUtilities.execFile.withArgs("mkfifo", match.array).rejects(mkfifoError); const progressCallback = () => {}; @@ -409,8 +495,15 @@ suite("Swiftly Unit Tests", () => { }); 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").resolves({ stdout: "", stderr: "" }); + mockUtilities.execFile + .withArgs("mkfifo", match.array) + .resolves({ stdout: "", stderr: "" }); const progressCallback = () => {};