diff --git a/package-lock.json b/package-lock.json index 19a70e62e..a1dbe223c 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": "^6.2.1", "vscode-languageclient": "^9.0.1", "xml2js": "^0.6.2", "zod": "^4.1.5" @@ -41,6 +42,7 @@ "@types/source-map-support": "^0.5.10", "@types/svg2ttf": "^5.0.3", "@types/svgicons2svgfont": "^10.0.5", + "@types/tar": "^6.1.13", "@types/ttf2woff": "^2.0.4", "@types/vscode": "^1.88.0", "@types/xml2js": "^0.4.14", @@ -2400,6 +2402,27 @@ "@types/node": "*" } }, + "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, + "license": "MIT", + "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, + "license": "ISC", + "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", @@ -6001,7 +6024,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, "license": "ISC", "dependencies": { "minipass": "^3.0.0" @@ -6014,7 +6036,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -7921,7 +7942,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, "license": "MIT", "dependencies": { "minipass": "^3.0.0", @@ -7935,7 +7955,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -7948,7 +7967,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" @@ -10471,7 +10489,6 @@ "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", "dependencies": { "chownr": "^2.0.0", @@ -10534,7 +10551,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -10544,7 +10560,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=8" @@ -11642,8 +11657,7 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yaml": { "version": "2.8.1", @@ -13463,6 +13477,24 @@ "@types/node": "*" } }, + "@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", @@ -16046,7 +16078,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, "requires": { "minipass": "^3.0.0" }, @@ -16055,7 +16086,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -17439,7 +17469,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, "requires": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -17449,7 +17478,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "requires": { "yallist": "^4.0.0" } @@ -17459,8 +17487,7 @@ "mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" }, "mkdirp-classic": { "version": "0.5.3", @@ -19229,7 +19256,6 @@ "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", @@ -19242,14 +19268,12 @@ "chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" }, "minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==" } } }, @@ -20099,8 +20123,7 @@ "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "yaml": { "version": "2.8.1", diff --git a/package.json b/package.json index e4ca18267..6dfb0c0e0 100644 --- a/package.json +++ b/package.json @@ -1122,6 +1122,13 @@ "order": 4, "scope": "machine-overridable" }, + "swift.suppressSwiftlyInstallPrompt": { + "type": "boolean", + "default": false, + "markdownDescription": "Suppress the automatic Swiftly installation prompt when no Swift toolchain is found.", + "order": 98, + "scope": "application" + }, "swift.diagnostics": { "type": "boolean", "default": false, @@ -2087,6 +2094,7 @@ "@types/source-map-support": "^0.5.10", "@types/svg2ttf": "^5.0.3", "@types/svgicons2svgfont": "^10.0.5", + "@types/tar": "^6.1.13", "@types/ttf2woff": "^2.0.4", "@types/vscode": "^1.88.0", "@types/xml2js": "^0.4.14", @@ -2137,6 +2145,7 @@ "fast-glob": "^3.3.3", "lcov-parse": "^1.0.0", "plist": "^3.1.0", + "tar": "^6.2.1", "vscode-languageclient": "^9.0.1", "xml2js": "^0.6.2", "zod": "^4.1.5" diff --git a/src/PackageWatcher.ts b/src/PackageWatcher.ts index 25e5618d5..ae255ba18 100644 --- a/src/PackageWatcher.ts +++ b/src/PackageWatcher.ts @@ -17,8 +17,10 @@ import * as vscode from "vscode"; import { FolderContext } from "./FolderContext"; import { FolderOperation } from "./WorkspaceContext"; +import { handleMissingSwiftly } from "./commands/installSwiftly"; import { SwiftLogger } from "./logging/SwiftLogger"; import { BuildFlags } from "./toolchain/BuildFlags"; +import { handleMissingSwiftlyToolchain } from "./toolchain/swiftly"; import { showReloadExtensionNotification } from "./ui/ReloadExtension"; import { fileExists } from "./utilities/filesystem"; import { Version } from "./utilities/version"; @@ -140,6 +142,31 @@ export class PackageWatcher { async handleSwiftVersionFileChange() { const version = await this.readSwiftVersionFile(); if (version?.toString() !== this.currentVersion?.toString()) { + if (version) { + const swiftlyInstalled = await handleMissingSwiftly(this.logger); + if (swiftlyInstalled) { + const toolchainInstalled = await handleMissingSwiftlyToolchain( + version.toString(), + this.folderContext.workspaceContext.extensionContext.extensionPath, + this.logger, + this.folderContext.folder + ); + if (toolchainInstalled) { + // Build dynamic message based on installation results + const message = + "Swiftly and Swift toolchain have been installed. Please reload the extension to use the new toolchain."; + await showReloadExtensionNotification(message); + return; + } else { + // Only Swiftly was installed + const message = + "Swiftly has been installed. Please reload the extension to continue."; + await showReloadExtensionNotification(message); + return; + } + } + } + await this.folderContext.fireEvent(FolderOperation.swiftVersionUpdated); await showReloadExtensionNotification( "Changing the swift toolchain version requires the extension to be reloaded" diff --git a/src/commands/installSwiftly.ts b/src/commands/installSwiftly.ts new file mode 100644 index 000000000..c237f9449 --- /dev/null +++ b/src/commands/installSwiftly.ts @@ -0,0 +1,196 @@ +//===----------------------------------------------------------------------===// +// +// 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 os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; + +import { SwiftLogger } from "../logging/SwiftLogger"; +import { Swiftly } from "../toolchain/swiftly"; + +interface SwiftlyInstallOptions { + swiftlyHomeDir?: string; + swiftlyBinDir?: string; +} + +/** + * Prompts user for Swiftly installation with directory customization options + * @param logger Optional logger + * @returns Promise Installation options if user wants to install, null otherwise + */ +export async function promptForSwiftlyInstallation( + logger?: SwiftLogger +): Promise { + const installMessage = `A .swift-version file was detected. Install Swiftly to automatically manage Swift toolchain versions for this project.`; + + const selection = await vscode.window.showInformationMessage( + installMessage, + { modal: false }, + "Install Swiftly", + "Customize Directories", + "Don't Show Again", + "Cancel" + ); + + switch (selection) { + case "Install Swiftly": + return {}; // Use defaults + + case "Customize Directories": + return await promptForDirectoryCustomization(logger); + + case "Don't Show Again": + // Set a workspace setting to suppress this prompt + await vscode.workspace + .getConfiguration("swift") + .update("suppressSwiftlyInstallPrompt", true, vscode.ConfigurationTarget.Global); + logger?.info("Swiftly installation prompt suppressed by user"); + return null; + + case "Cancel": + default: + return null; + } +} + +/** + * Prompts user to customize Swiftly installation directories + * @param logger Optional logger + * @returns Promise + */ +async function promptForDirectoryCustomization( + logger?: SwiftLogger +): Promise { + const homeDir = os.homedir(); + const defaultSwiftlyHome = path.join(homeDir, ".swiftly"); + const defaultSwiftlyBin = path.join(homeDir, ".local", "bin"); + + const customHomeDir = await vscode.window.showInputBox({ + title: "Customize Swiftly Home Directory", + prompt: "Enter the directory where Swiftly will store its data and toolchains", + value: defaultSwiftlyHome, + placeHolder: defaultSwiftlyHome, + validateInput: value => { + if (!value || value.trim().length === 0) { + return "Directory path cannot be empty"; + } + if (!path.isAbsolute(value)) { + return "Please provide an absolute path"; + } + return null; + }, + }); + + if (customHomeDir === undefined) { + return null; // User cancelled + } + + const customBinDir = await vscode.window.showInputBox({ + title: "Customize Swiftly Binary Directory", + prompt: "Enter the directory where Swiftly binaries will be installed", + value: defaultSwiftlyBin, + placeHolder: defaultSwiftlyBin, + validateInput: value => { + if (!value || value.trim().length === 0) { + return "Directory path cannot be empty"; + } + if (!path.isAbsolute(value)) { + return "Please provide an absolute path"; + } + return null; + }, + }); + + if (customBinDir === undefined) { + return null; // User cancelled + } + + logger?.info(`User customized Swiftly directories: home=${customHomeDir}, bin=${customBinDir}`); + + return { + swiftlyHomeDir: customHomeDir.trim(), + swiftlyBinDir: customBinDir.trim(), + }; +} + +/** + * Installs Swiftly with progress tracking and user feedback + * @param options Installation options + * @param logger Optional logger + * @returns Promise true if installation succeeded + */ +export async function installSwiftlyWithProgress( + options: SwiftlyInstallOptions, + logger?: SwiftLogger +): Promise { + try { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Installing Swiftly", + cancellable: false, + }, + async progress => { + await Swiftly.installSwiftly( + progress, + logger, + options.swiftlyHomeDir, + options.swiftlyBinDir + ); + } + ); + return true; + } catch (error) { + logger?.error(`Failed to install Swiftly: ${error}`); + const message = error instanceof Error ? error.message : String(error); + void vscode.window.showErrorMessage(`Failed to install Swiftly: ${message}`); + return false; + } +} + +/** + * Checks if the Swiftly installation prompt should be suppressed + * @returns true if suppressed, false otherwise + */ +export function isSwiftlyPromptSuppressed(): boolean { + return vscode.workspace.getConfiguration("swift").get("suppressSwiftlyInstallPrompt", false); +} + +/** + * Main function to handle missing Swiftly detection and installation + * @param logger Optional logger + * @returns Promise true if Swiftly was installed or already exists + */ +export async function handleMissingSwiftly(logger?: SwiftLogger): Promise { + // Check if Swiftly is missing + if (await Swiftly.isInstalled()) { + return true; // Swiftly is already installed + } + + // Check if prompt is suppressed + if (isSwiftlyPromptSuppressed()) { + logger?.debug("Swiftly installation prompt is suppressed"); + return false; + } + + // Prompt user for installation + const options = await promptForSwiftlyInstallation(logger); + if (!options) { + return false; // User cancelled or suppressed + } + + // Install Swiftly + const installSuccess = await installSwiftlyWithProgress(options, logger); + + return installSuccess; +} diff --git a/src/commands/installSwiftlyToolchain.ts b/src/commands/installSwiftlyToolchain.ts index 6af34275b..1ba6f74d9 100644 --- a/src/commands/installSwiftlyToolchain.ts +++ b/src/commands/installSwiftlyToolchain.ts @@ -22,6 +22,7 @@ import { setToolchainPath, showDeveloperDirQuickPick, } from "../ui/ToolchainSelection"; +import { handleMissingSwiftly } from "./installSwiftly"; /** * Installs a Swiftly toolchain and shows a progress notification to the user. @@ -113,10 +114,10 @@ export async function promptToInstallSwiftlyToolchain( 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 swiftlyInstalled = await handleMissingSwiftly(ctx.logger); + if (!swiftlyInstalled) { + return; + } } let branch: string | undefined = undefined; diff --git a/src/extension.ts b/src/extension.ts index cd2727ca4..c2b56c702 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,6 +22,7 @@ import { FolderEvent, FolderOperation, WorkspaceContext } from "./WorkspaceConte import * as commands from "./commands"; import { resolveFolderDependencies } from "./commands/dependencies/resolve"; import { registerSourceKitSchemaWatcher } from "./commands/generateSourcekitConfiguration"; +import { handleMissingSwiftly } from "./commands/installSwiftly"; import configuration, { handleConfigurationChangeEvent } from "./configuration"; import { ContextKeys, createContextKeys } from "./contextKeys"; import { registerDebugger } from "./debugger/debugAdapterFactory"; @@ -288,11 +289,63 @@ function handleFolderEvent(logger: SwiftLogger): (event: FolderEvent) => Promise }; } +/** + * Checks if any workspace folder contains a .swift-version file + */ +async function hasSwiftVersionFile(): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + return false; + } + + for (const folder of workspaceFolders) { + try { + const swiftVersionPath = vscode.Uri.joinPath(folder.uri, ".swift-version"); + await vscode.workspace.fs.stat(swiftVersionPath); + return true; + } catch { + // File doesn't exist, continue checking other folders + } + } + + return false; +} + async function createActiveToolchain( extension: vscode.ExtensionContext, contextKeys: ContextKeys, logger: SwiftLogger ): Promise { + // Check if there's a .swift-version file in the workspace and Swiftly is not installed + if (await hasSwiftVersionFile()) { + const { Swiftly } = await import("./toolchain/swiftly"); + const swiftlyInstalled = await Swiftly.isInstalled(); + + if (!swiftlyInstalled) { + logger.info( + "Detected .swift-version file in workspace without Swiftly, prompting for Swiftly installation" + ); + const installed = await handleMissingSwiftly(logger); + if (installed) { + // Try to create the toolchain again after Swiftly installation + try { + const toolchain = await SwiftToolchain.create( + extension.extensionPath, + undefined, + logger + ); + toolchain.logDiagnostics(logger); + contextKeys.updateKeysBasedOnActiveVersion(toolchain.swiftVersion); + return toolchain; + } catch (retryError) { + logger.error( + `Failed to create toolchain after Swiftly installation: ${retryError}` + ); + } + } + } + } + try { const toolchain = await SwiftToolchain.create(extension.extensionPath, undefined, logger); toolchain.logDiagnostics(logger); diff --git a/src/toolchain/swiftly.ts b/src/toolchain/swiftly.ts index d6dd9aa29..60187af26 100644 --- a/src/toolchain/swiftly.ts +++ b/src/toolchain/swiftly.ts @@ -18,6 +18,7 @@ import * as os from "os"; import * as path from "path"; import * as readline from "readline"; import * as Stream from "stream"; +import { extract } from "tar"; import * as vscode from "vscode"; import { z } from "zod/v4/mini"; @@ -182,6 +183,237 @@ export async function handleMissingSwiftlyToolchain( export class Swiftly { public static cancellationMessage = "Installation cancelled by user"; + /** + * Downloads and installs Swiftly for the current platform + */ + public static async installSwiftly( + progress: vscode.Progress<{ message?: string; increment?: number }>, + logger?: SwiftLogger, + swiftlyHomeDir?: string, + swiftlyBinDir?: string + ): Promise { + if (!this.isSupported()) { + throw new Error("Swiftly is not supported on this platform"); + } + + switch (process.platform) { + case "darwin": + await this.installSwiftlyDarwin(progress, logger, swiftlyHomeDir, swiftlyBinDir); + break; + case "linux": + await this.installSwiftlyLinux(progress, logger, swiftlyHomeDir, swiftlyBinDir); + break; + default: + throw new Error(`Swiftly installation is not supported on ${process.platform}`); + } + } + + private static async installSwiftlyDarwin( + progress: vscode.Progress<{ message?: string; increment?: number }>, + logger?: SwiftLogger, + swiftlyHomeDir?: string, + swiftlyBinDir?: string + ): Promise { + const url = "https://download.swift.org/swiftly/darwin/swiftly.pkg"; + const downloadedPkgPath = await this.downloadSwiftlyInstaller(url, progress, logger); + + try { + progress.report({ message: "Installing Swiftly package..." }); + + await execFile("installer", [ + "-pkg", + downloadedPkgPath, + "-target", + "CurrentUserHomeDirectory", + ]); + + progress.report({ message: "Initializing Swiftly..." }); + + const env = { ...process.env }; + if (swiftlyHomeDir) { + env["SWIFTLY_HOME_DIR"] = swiftlyHomeDir; + } + if (swiftlyBinDir) { + env["SWIFTLY_BIN_DIR"] = swiftlyBinDir; + } + + const actualSwiftlyHomeDir = swiftlyHomeDir || path.join(os.homedir(), ".swiftly"); + const swiftlyPath = path.join(actualSwiftlyHomeDir, "bin", "swiftly"); + + await execFile(swiftlyPath, ["init", "--quiet-shell-followup"], { env }); + + progress.report({ message: "Swiftly installation completed", increment: 100 }); + logger?.info("Swiftly installation and initialization completed successfully"); + } catch (error) { + logger?.error(`Failed to install Swiftly: ${error}`); + throw new Error(`Failed to install Swiftly on macOS: ${(error as Error).message}`); + } finally { + try { + await fs.unlink(downloadedPkgPath); + await fs.rm(path.dirname(downloadedPkgPath), { recursive: true }); + } catch { + // Ignore cleanup errors + } + } + } + + private static async installSwiftlyLinux( + progress: vscode.Progress<{ message?: string; increment?: number }>, + logger?: SwiftLogger, + swiftlyHomeDir?: string, + swiftlyBinDir?: string + ): Promise { + const env = { ...process.env }; + if (swiftlyHomeDir) { + env["SWIFTLY_HOME_DIR"] = swiftlyHomeDir; + } + if (swiftlyBinDir) { + env["SWIFTLY_BIN_DIR"] = swiftlyBinDir; + } + + let tmpDir: string | undefined; + + try { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vscode-swift-")); + + progress.report({ message: "Downloading Swiftly for Linux..." }); + + const archMap: Record = { + x64: "x86_64", + arm64: "aarch64", + }; + const architecture = archMap[os.arch()] || os.arch(); + const url = `https://download.swift.org/swiftly/linux/swiftly-${architecture}.tar.gz`; + const downloadedTarPath = await this.downloadSwiftlyInstaller(url, progress, logger); + + progress.report({ message: "Extracting Swiftly..." }); + + await extract({ + file: downloadedTarPath, + cwd: tmpDir, + }); + + progress.report({ message: "Initializing Swiftly..." }); + + await execFile("./swiftly", ["init", "--quiet-shell-followup"], { + cwd: tmpDir, + env, + }); + + progress.report({ message: "Swiftly installation completed", increment: 100 }); + logger?.info("Swiftly installation completed successfully on Linux"); + } catch (error) { + logger?.error(`Failed to install Swiftly on Linux: ${error}`); + throw new Error(`Failed to install Swiftly on Linux: ${(error as Error).message}`); + } finally { + if (tmpDir) { + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + } + } + + private static async downloadSwiftlyInstaller( + url: string, + progress: vscode.Progress<{ message?: string; increment?: number }>, + logger?: SwiftLogger + ): Promise { + progress.report({ message: "Downloading Swiftly installer..." }); + + let tmpDir: string | undefined; + let filePath: string | undefined; + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download installer: HTTP ${response.status}`); + } + + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vscode-swift-")); + const fileName = path.basename(url) || "swiftly-installer"; + filePath = path.join(tmpDir, fileName); + + if (!response.body) { + throw new Error("Response body is null"); + } + + const contentLength = response.headers.get("content-length"); + const totalLength = contentLength ? parseInt(contentLength, 10) : 0; + let downloadedLength = 0; + let lastReportedPercent = 0; + + const fileStream = fsSync.createWriteStream(filePath); + const reader = response.body.getReader(); + + try { + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + downloadedLength += value.length; + fileStream.write(value); + + if (totalLength > 0) { + const percent = Math.floor((downloadedLength / totalLength) * 100); + if (percent > lastReportedPercent && percent % 10 === 0) { + progress.report({ + message: `Downloading Swiftly installer... ${percent}%`, + increment: percent - lastReportedPercent, + }); + lastReportedPercent = percent; + } + } + } + } finally { + reader.releaseLock(); + fileStream.end(); + } + + await new Promise((resolve, reject) => { + fileStream.on("finish", () => { + fileStream.close(err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + fileStream.on("error", reject); + }); + + progress.report({ message: "Download completed" }); + logger?.info(`Swiftly installer downloaded to: ${filePath}`); + + return filePath; + } catch (error) { + // Cleanup temporary resources on error + if (filePath) { + try { + await fs.unlink(filePath); + } catch { + // Swallow cleanup errors + } + } + if (tmpDir) { + try { + await fs.rm(tmpDir, { recursive: true }); + } catch { + // Swallow cleanup errors + } + } + + logger?.error(`Failed to download Swiftly installer: ${error}`); + throw error; + } + } + /** * Finds the version of Swiftly installed on the system. * diff --git a/test/unit-tests/toolchain/swiftly.test.ts b/test/unit-tests/toolchain/swiftly.test.ts index dd3c38b55..d5932d788 100644 --- a/test/unit-tests/toolchain/swiftly.test.ts +++ b/test/unit-tests/toolchain/swiftly.test.ts @@ -15,7 +15,7 @@ import { expect } from "chai"; import * as fs from "fs/promises"; import * as mockFS from "mock-fs"; import * as os from "os"; -import { match } from "sinon"; +import { match, stub } from "sinon"; import * as vscode from "vscode"; import * as askpass from "@src/askpass/askpass-server"; @@ -1494,4 +1494,611 @@ apt-get -y install libncurses5-dev expect(Swiftly.cancellationMessage).to.equal("Installation cancelled by user"); }); }); + + suite("installSwiftly()", () => { + let originalFetch: typeof global.fetch; + + setup(() => { + originalFetch = global.fetch; + }); + + teardown(() => { + global.fetch = originalFetch; + }); + + test("should throw error on unsupported platform", async () => { + mockedPlatform.setValue("win32"); + const mockProgress = { report: () => {} }; + + await expect(Swiftly.installSwiftly(mockProgress as any)).to.eventually.be.rejectedWith( + "Swiftly is not supported on this platform" + ); + }); + + test("should call installSwiftlyDarwin on macOS", async () => { + mockedPlatform.setValue("darwin"); + const mockProgress = { report: () => {} }; + + // Mock fetch for download + global.fetch = async () => + ({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile.withArgs("installer", match.array).resolves({ + stdout: "", + stderr: "", + }); + mockUtilities.execFile + .withArgs(match.string, ["init", "--quiet-shell-followup"]) + .resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly(mockProgress as any); + + expect(mockUtilities.execFile).to.have.been.calledWith("installer", match.array); + }); + + test("should call installSwiftlyLinux on Linux", async () => { + mockedPlatform.setValue("linux"); + mockOS.arch.returns("x64"); + const mockProgress = { report: () => {} }; + + // Mock fetch and tar extraction + global.fetch = async () => + ({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile.withArgs("./swiftly", match.array).resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly(mockProgress as any); + + expect(mockUtilities.execFile).to.have.been.calledWith( + "./swiftly", + ["init", "--quiet-shell-followup"], + match.object + ); + }); + + test("should pass custom swiftlyHomeDir and swiftlyBinDir", async () => { + mockedPlatform.setValue("darwin"); + const mockProgress = { report: () => {} }; + + global.fetch = async () => + ({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile.withArgs("installer", match.array).resolves({ + stdout: "", + stderr: "", + }); + mockUtilities.execFile.resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly( + mockProgress as any, + undefined, + "/custom/home", + "/custom/bin" + ); + + expect(mockUtilities.execFile).to.have.been.calledWith( + match.string, + ["init", "--quiet-shell-followup"], + match.has("env", match.has("SWIFTLY_HOME_DIR", "/custom/home")) + ); + expect(mockUtilities.execFile).to.have.been.calledWith( + match.string, + ["init", "--quiet-shell-followup"], + match.has("env", match.has("SWIFTLY_BIN_DIR", "/custom/bin")) + ); + }); + }); + + suite("installSwiftlyDarwin()", () => { + let originalFetch: typeof global.fetch; + + setup(() => { + mockedPlatform.setValue("darwin"); + originalFetch = global.fetch; + }); + + teardown(() => { + global.fetch = originalFetch; + }); + + test("should download from correct macOS URL", async () => { + const fetchSpy = stub(); + global.fetch = fetchSpy.returns( + Promise.resolve({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any + ); + + mockUtilities.execFile.resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly({ report: () => {} } as any); + + expect(fetchSpy).to.have.been.calledWith( + "https://download.swift.org/swiftly/darwin/swiftly.pkg" + ); + }); + + test("should execute installer with CurrentUserHomeDirectory target", async () => { + global.fetch = async () => + ({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile.resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly({ report: () => {} } as any); + + expect(mockUtilities.execFile).to.have.been.calledWith("installer", [ + "-pkg", + match.string, + "-target", + "CurrentUserHomeDirectory", + ]); + }); + + test("should use custom swiftlyHomeDir when provided", async () => { + global.fetch = async () => + ({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile.resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly({ report: () => {} } as any, undefined, "/custom/swiftly"); + + expect(mockUtilities.execFile).to.have.been.calledWith( + match.string, + ["init", "--quiet-shell-followup"], + match.has("env", match.has("SWIFTLY_HOME_DIR", "/custom/swiftly")) + ); + }); + + test("should report progress at each stage", async () => { + const progressReports: string[] = []; + const mockProgress = { + report: (report: any) => { + if (report.message) { + progressReports.push(report.message); + } + }, + }; + + global.fetch = async () => + ({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile.resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly(mockProgress as any); + + expect(progressReports).to.include("Downloading Swiftly installer..."); + expect(progressReports).to.include("Installing Swiftly package..."); + expect(progressReports).to.include("Initializing Swiftly..."); + expect(progressReports).to.include("Swiftly installation completed"); + }); + + test("should cleanup downloaded files on failure", async () => { + global.fetch = async () => + ({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile + .withArgs("installer", match.array) + .rejects(new Error("Installer failed")); + + await expect( + Swiftly.installSwiftly({ report: () => {} } as any) + ).to.eventually.be.rejectedWith("Failed to install Swiftly on macOS"); + + // Cleanup should still happen - verify no leftover files + // (In real implementation, this would check fs operations) + }); + + test("should throw error when installer command fails", async () => { + global.fetch = async () => + ({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile + .withArgs("installer", match.array) + .rejects(new Error("Permission denied")); + + await expect( + Swiftly.installSwiftly({ report: () => {} } as any) + ).to.eventually.be.rejectedWith("Failed to install Swiftly on macOS"); + }); + }); + + suite("installSwiftlyLinux()", () => { + let originalFetch: typeof global.fetch; + + setup(() => { + mockedPlatform.setValue("linux"); + originalFetch = global.fetch; + }); + + teardown(() => { + global.fetch = originalFetch; + }); + + test("should map x64 to x86_64 architecture", async () => { + mockOS.arch.returns("x64"); + const fetchSpy = stub(); + global.fetch = fetchSpy.returns( + Promise.resolve({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any + ); + + mockUtilities.execFile.resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly({ report: () => {} } as any); + + expect(fetchSpy).to.have.been.calledWith( + "https://download.swift.org/swiftly/linux/swiftly-x86_64.tar.gz" + ); + }); + + test("should map arm64 to aarch64 architecture", async () => { + mockOS.arch.returns("arm64"); + const fetchSpy = stub(); + global.fetch = fetchSpy.returns( + Promise.resolve({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any + ); + + mockUtilities.execFile.resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly({ report: () => {} } as any); + + expect(fetchSpy).to.have.been.calledWith( + "https://download.swift.org/swiftly/linux/swiftly-aarch64.tar.gz" + ); + }); + + test("should use raw architecture for unmapped types", async () => { + mockOS.arch.returns("ppc64"); + const fetchSpy = stub(); + global.fetch = fetchSpy.returns( + Promise.resolve({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any + ); + + mockUtilities.execFile.resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly({ report: () => {} } as any); + + expect(fetchSpy).to.have.been.calledWith( + "https://download.swift.org/swiftly/linux/swiftly-ppc64.tar.gz" + ); + }); + + test("should use custom environment variables", async () => { + mockOS.arch.returns("x64"); + global.fetch = async () => + ({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile.resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly( + { report: () => {} } as any, + undefined, + "/custom/home", + "/custom/bin" + ); + + expect(mockUtilities.execFile).to.have.been.calledWith( + "./swiftly", + ["init", "--quiet-shell-followup"], + match.has("env", match.has("SWIFTLY_HOME_DIR", "/custom/home")) + ); + expect(mockUtilities.execFile).to.have.been.calledWith( + "./swiftly", + ["init", "--quiet-shell-followup"], + match.has("env", match.has("SWIFTLY_BIN_DIR", "/custom/bin")) + ); + }); + + test("should report progress at each stage", async () => { + mockOS.arch.returns("x64"); + const progressReports: string[] = []; + const mockProgress = { + report: (report: any) => { + if (report.message) { + progressReports.push(report.message); + } + }, + }; + + global.fetch = async () => + ({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile.resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly(mockProgress as any); + + expect(progressReports).to.include("Downloading Swiftly for Linux..."); + expect(progressReports).to.include("Extracting Swiftly..."); + expect(progressReports).to.include("Initializing Swiftly..."); + expect(progressReports).to.include("Swiftly installation completed"); + }); + + test("should cleanup tmpDir on failure", async () => { + mockOS.arch.returns("x64"); + global.fetch = async () => + ({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile + .withArgs("./swiftly", match.array) + .rejects(new Error("Init failed")); + + await expect( + Swiftly.installSwiftly({ report: () => {} } as any) + ).to.eventually.be.rejectedWith("Failed to install Swiftly on Linux"); + }); + }); + + suite("downloadSwiftlyInstaller()", () => { + let originalFetch: typeof global.fetch; + + setup(() => { + mockedPlatform.setValue("darwin"); + originalFetch = global.fetch; + }); + + teardown(() => { + global.fetch = originalFetch; + }); + + test("should handle HTTP 404 error", async () => { + global.fetch = async () => ({ ok: false, status: 404 }) as any; + + await expect( + Swiftly.installSwiftly({ report: () => {} } as any) + ).to.eventually.be.rejectedWith("HTTP 404"); + }); + + test("should handle HTTP 500 error", async () => { + global.fetch = async () => ({ ok: false, status: 500 }) as any; + + await expect( + Swiftly.installSwiftly({ report: () => {} } as any) + ).to.eventually.be.rejectedWith("HTTP 500"); + }); + + test("should handle network errors", async () => { + global.fetch = async () => { + throw new Error("Network error"); + }; + + await expect( + Swiftly.installSwiftly({ report: () => {} } as any) + ).to.eventually.be.rejectedWith("Network error"); + }); + + test("should report download progress in 10% increments", async () => { + const progressReports: string[] = []; + const mockProgress = { + report: (report: any) => { + if (report.message) { + progressReports.push(report.message); + } + }, + }; + + // Simulate download with multiple chunks to trigger progress reporting + let chunkCount = 0; + global.fetch = async () => + ({ + ok: true, + headers: { get: () => "1000" }, + body: { + getReader: () => ({ + read: async () => { + if (chunkCount < 10) { + chunkCount++; + return { done: false, value: new Uint8Array(100) }; + } + return { done: true, value: new Uint8Array(0) }; + }, + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile.resolves({ + stdout: "", + stderr: "", + }); + + await Swiftly.installSwiftly(mockProgress as any); + + // Should have progress reports at 10%, 20%, etc. + const downloadProgress = progressReports.filter(msg => + msg.includes("Downloading Swiftly installer...") + ); + expect(downloadProgress.length).to.be.greaterThan(1); + }); + + test("should handle missing content-length header", async () => { + global.fetch = async () => + ({ + ok: true, + headers: { get: () => null }, + body: { + getReader: () => ({ + read: async () => ({ done: true, value: new Uint8Array(0) }), + releaseLock: () => {}, + }), + }, + }) as any; + + mockUtilities.execFile.resolves({ + stdout: "", + stderr: "", + }); + + // Should not throw, just not report percentage + await expect(Swiftly.installSwiftly({ report: () => {} } as any)).to.eventually.be + .fulfilled; + }); + }); });