From 29b7043cfe532512818fa32e2ddf5bd65204d50c Mon Sep 17 00:00:00 2001 From: William Walker Date: Tue, 1 Oct 2024 14:12:28 -0500 Subject: [PATCH] feat: improve CLI output (#26) * better logging * chore: improve code coverage for add subcommand * feat: add preinstall script for installer * chore: update functional tests to reflect setup changes --- .github/workflows/release.yml | 6 +- .github/workflows/test.yml | 7 +- Cosmic.xcodeproj/project.pbxproj | 1 - .../xcshareddata/xcschemes/Cosmic.xcscheme | 6 - Cosmic/Common/Utils.swift | 90 +++--- Cosmic/Cosmic.swift | 5 +- Cosmic/Subcommands/Add.swift | 297 +++++++++++------- Cosmic/Subcommands/Setup.swift | 191 ----------- CosmicTests/CosmicTests.swift | 234 ++++++++++---- CosmicTests/CosmicTests.xctestplan | 2 +- scripts/postinstall | 18 ++ 11 files changed, 438 insertions(+), 419 deletions(-) delete mode 100644 Cosmic/Subcommands/Setup.swift create mode 100755 scripts/postinstall diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a97e83d..a5a633b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,9 @@ jobs: SCHEME: Cosmic ARCHIVE: Cosmic.xcarchive + - name: Copy pkl to archive + run: sudo cp /usr/local/bin/pkl Cosmic.xcarchive/Products/usr/local/bin/pkl + - name: Build installer package run: | pkgbuild --root "Cosmic.xcarchive/Products" \ @@ -70,6 +73,7 @@ jobs: --version "${{ github.ref }}" \ --install-location "/" \ --sign="Developer ID Installer: William Walker (QSQY64SHJ5)" \ + --scripts "scripts/" \ cosmic.pkg - name: Notarize package @@ -102,7 +106,7 @@ jobs: draft: false prerelease: false - - name: Upload binary + - name: Upload pkg installer uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e9dec24..880c4f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,15 +75,10 @@ jobs: sudo cp ./build/Cosmic.xcarchive/Products/usr/local/bin/cosmic /usr/local/bin/cosmic cosmic --help - - name: Run setup subcommand - run: | - cosmic setup --verbose - echo "$HOME/Packages" >> $GITHUB_PATH - - name: Run add subcommand run: | cosmic add k9s --verbose - name: Run installed package run: | - k9s version --short + ~/.cosmic/k9s version --short diff --git a/Cosmic.xcodeproj/project.pbxproj b/Cosmic.xcodeproj/project.pbxproj index 137886a..f63d90c 100644 --- a/Cosmic.xcodeproj/project.pbxproj +++ b/Cosmic.xcodeproj/project.pbxproj @@ -41,7 +41,6 @@ Common/Utils.swift, Cosmic.swift, Subcommands/Add.swift, - Subcommands/Setup.swift, ); target = 6BD783982C98FBFF009EEB33 /* CosmicTests */; }; diff --git a/Cosmic.xcodeproj/xcshareddata/xcschemes/Cosmic.xcscheme b/Cosmic.xcodeproj/xcshareddata/xcschemes/Cosmic.xcscheme index b09a73e..03e8061 100644 --- a/Cosmic.xcodeproj/xcshareddata/xcschemes/Cosmic.xcscheme +++ b/Cosmic.xcodeproj/xcshareddata/xcschemes/Cosmic.xcscheme @@ -60,12 +60,6 @@ ReferencedContainer = "container:Cosmic.xcodeproj"> - - - - /// - name: Name of the package being extracted. /// - Returns: URL of the extracted files' directory. /// - Throws: `ExtractionError` in case of issues during the extraction. -func unzip(from sourcePath: String, for name: String) throws -> URL { - let fileManager = FileManager.default - - // Check if the source file exists - guard fileManager.fileExists(atPath: sourcePath) else { - throw ExtractionError.fileDoesNotExist("File does not exist at \(sourcePath)") - } - - // Set up the temporary directory path for extraction - let destinationURL = fileManager.temporaryDirectory.appendingPathComponent(name) - - // Create the destination directory - do { - try fileManager.createDirectory( - at: destinationURL, withIntermediateDirectories: true, attributes: nil) - } catch { - throw ExtractionError.failedToCreateDirectory( - "Failed to create destination directory: \(error.localizedDescription)") - } - - // Configure the `unzip` process for extraction - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") - process.arguments = [sourcePath, "-d", destinationURL.path] - process.standardOutput = nil - process.standardError = nil - - // Execute the `unzip` process and handle its result - do { - try process.run() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - throw ExtractionError.extractionFailed(process.terminationStatus) - } - - return destinationURL - } catch { - throw ExtractionError.extractionProcessFailed( - "Failed to run extraction process: \(error.localizedDescription)") - } -} +//func unzip(from sourcePath: String, for name: String) throws -> URL { +// let fileManager = FileManager.default +// +// // Check if the source file exists +// guard fileManager.fileExists(atPath: sourcePath) else { +// throw ExtractionError.fileDoesNotExist("File does not exist at \(sourcePath)") +// } +// +// // Set up the temporary directory path for extraction +// let destinationURL = fileManager.temporaryDirectory.appendingPathComponent(name) +// +// // Create the destination directory +// do { +// try fileManager.createDirectory( +// at: destinationURL, withIntermediateDirectories: true, attributes: nil) +// } catch { +// throw ExtractionError.failedToCreateDirectory( +// "Failed to create destination directory: \(error.localizedDescription)") +// } +// +// // Configure the `unzip` process for extraction +// let process = Process() +// process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") +// process.arguments = [sourcePath, "-d", destinationURL.path] +// process.standardOutput = nil +// process.standardError = nil +// +// // Execute the `unzip` process and handle its result +// do { +// try process.run() +// process.waitUntilExit() +// +// guard process.terminationStatus == 0 else { +// throw ExtractionError.extractionFailed(process.terminationStatus) +// } +// +// return destinationURL +// } catch { +// throw ExtractionError.extractionProcessFailed( +// "Failed to run extraction process: \(error.localizedDescription)") +// } +//} func setExecutablePermission(for fileURL: URL) throws { let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) diff --git a/Cosmic/Cosmic.swift b/Cosmic/Cosmic.swift index beb84c9..2e4776d 100644 --- a/Cosmic/Cosmic.swift +++ b/Cosmic/Cosmic.swift @@ -14,10 +14,9 @@ import PklSwift @main struct Cosmic: AsyncParsableCommand { static var configuration = CommandConfiguration( abstract: "A package manager for macOS.", - subcommands: [Setup.self, Add.self]) + subcommands: [Add.self]) struct Options: ParsableArguments { - @Flag(name: [.long, .customShort("v")]) var verbose: Bool = false - @Flag(name: [.long, .customShort("f")]) var force: Bool = false + @Flag var verbose: Bool = false } } diff --git a/Cosmic/Subcommands/Add.swift b/Cosmic/Subcommands/Add.swift index ff7ee2b..7a47bdb 100644 --- a/Cosmic/Subcommands/Add.swift +++ b/Cosmic/Subcommands/Add.swift @@ -14,149 +14,232 @@ import PklSwift extension Cosmic { struct Add: AsyncParsableCommand { static var configuration = CommandConfiguration(abstract: "Add a package.") + @OptionGroup var options: Cosmic.Options - @Argument(help: "The name of the package to add.") var package: String - + + @Argument(help: "The name of the package to add.") var packageName: String + enum AddError: Error { - case manifestNotFound(String) - case executeProcessFailed(String) - case downloadFailed(String) - case missingExecutablePath(String) + case packageNotFound + case executeProcessFailed + case downloadFailed + case invalidPackage + case missingExecutablePath } - + + /// Command's main entry when run is executed, adding the package by process of locating, downloading, validating, unpacking, testing, and installing. mutating func run() async throws { - let package = try await Package.loadFrom( - source: .url(packageManifestPath(for: package))) - log("package fetched as \(package.name)") - + let package = try await locate(packageName: packageName) let downloadLocation = try await download(package: package) - log("package downloaded to \(downloadLocation.path)") - try validate(package: package, at: downloadLocation) - log("package is valid: \(true)") - let unpackedLocation = try unpack(package: package, at: downloadLocation) - log("package unpacked: \(unpackedLocation.path)") - - let exitCode = try execute(package: package, at: unpackedLocation) - log("\(package.name) terminated with status: \(exitCode)") - - let installResult = try await install(package: package, from: unpackedLocation) - log("\(package.name) installed: \(installResult)") + try execute(package: package, at: unpackedLocation) + try await install(package: package, from: unpackedLocation) } - - func packageManifestPath(for packageName: String) throws -> URL { - guard - let manifestURL = URL( - string: - "https://raw.githubusercontent.com/willswire/cosmic-pkgs/refs/tags/v0.0.2/\(packageName).pkl" - ) - else { - throw AddError.manifestNotFound( - "Could not find a valid package manifest for \(packageName)") + + /// Locates the package manifest and loads the package information. + /// - Parameter packageName: The name of the package to locate. + /// - Returns: The located `Package.Module`. + /// - Throws: `AddError.manifestNotFound` if the manifest URL is incorrect or cannot be found. + func locate(packageName: String) async throws -> Package.Module { + log("Locating package...") + + guard let manifestURL = URL(string: "https://raw.githubusercontent.com/willswire/cosmic-pkgs/refs/tags/v0.0.2/\(packageName).pkl") else { + preconditionFailure("Invalid package manifest repository URL.") + } + + // Check if the manifest exists by performing a HEAD request. + let (_, response) = try await URLSession.shared.data(from: manifestURL) + + // Check the response status code to ensure the manifest exists. + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw AddError.packageNotFound } - return manifestURL + + // Load the package from the URL. + let package = try await Package.loadFrom(source: .url(manifestURL)) + log(debug: "Located package \(package.name) with version: \(package.version)") + + return package } - + + + /// Downloads the package. + /// - Parameter package: The package module to download. + /// - Returns: URL where the package is downloaded. + /// - Throws: `AddError.downloadFailed` if the package cannot be downloaded. func download(package: Package.Module) async throws -> URL { + log("Downloading package...", debug: "Remote URL: \(package.url)") + guard let packageURL = URL(string: package.url) else { - throw AddError.downloadFailed("Could not download package from \(package.url)") + preconditionFailure("Invalid package manifest URL.") + } + + do { + let (location, response) = try await sharedSession.download(from: packageURL) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw AddError.downloadFailed + } + log(debug: "Downloaded \(package.name) to: \(location.path)") + return location + } catch { + throw AddError.downloadFailed } - let (location, _) = try await sharedSession.download(from: packageURL) - return location } - + + /// Validates the downloaded package by checking its hash. + /// - Parameters: + /// - package: The package module to validate. + /// - url: URL where the downloaded package is located. + /// - Throws: `AddError.downloadFailed` if the hash does not match. func validate(package: Package.Module, at url: URL) throws { let data = try Data(contentsOf: url) let calculatedHash = SHA256.hash(data: data).hexStr + log( + "Validating package...", + debug: "Calculated hash: \(calculatedHash), Expected hash: \(package.hash)") guard package.hash.caseInsensitiveCompare(calculatedHash) == .orderedSame else { - throw AddError.downloadFailed("Hash mismatch") + throw AddError.invalidPackage } + log(debug: "Validation successful for \(package.name)") } - + + /// Unpacks the downloaded package. + /// - Parameters: + /// - package: The package module to unpack. + /// - url: URL where the downloaded package is located. + /// - Returns: URL where the package is unpacked. + /// - Throws: IOException if the package could not be unpacked. func unpack(package: Package.Module, at url: URL) throws -> URL { + log("Unpacking package...", debug: "Package type: \(package.type.rawValue)") + let resultURL: URL switch package.type { case .binary: - return url + resultURL = url case .zip: - return try unzip(from: url.path(), for: package.name) + //resultURL = try unzip(from: url.path(), for: package.name) + throw AddError.invalidPackage case .archive: - return try unarchive(from: url.path(), for: package.name, strip: package.isBundle) + resultURL = try unarchive( + from: url.path(), for: package.name, strip: package.isBundle) } + log(debug: "Unpacked \(package.name) to: \(resultURL.path)") + return resultURL } - - func execute(package: Package.Module, at url: URL) throws -> Int { - var terminationStatusProduct: Int = 0 - - for executablePath in package.executablePaths { - - let fileURL = url.appendingPathComponent(executablePath) - - try setExecutablePermission(for: fileURL) - - let process = Process() - - if package.type != .binary { - process.currentDirectoryURL = url + + func execute(package: Package.Module, at url: URL) throws { + log( + debug: "Executable paths: \(package.executablePaths.joined(separator: "\n"))" + ) + + var terminationStatusProduct: Int = 0 + + for executablePath in package.executablePaths { + log(debug: "Executable path: \(executablePath)") + + let fileURL = url.appendingPathComponent(executablePath) + + log(debug: "Setting executable permissions for file: \(fileURL.path)...") + try setExecutablePermission(for: fileURL) + + let process = Process() + + if package.type != .binary { + log(debug: "Setting current directory to \(url.path)") + process.currentDirectoryURL = url + } + + process.executableURL = fileURL + process.arguments = package.testArgs + process.standardOutput = nil + process.standardError = nil + + log(debug: "Running process for file: \(fileURL.path)...") + try process.run() + process.waitUntilExit() + + log(debug: "Process exited with status: \(process.terminationStatus)") + terminationStatusProduct *= Int(process.terminationStatus) + } + + guard terminationStatusProduct == 0 else { + throw AddError.executeProcessFailed + } + + log(debug: "All executables were successfully installed") } - - process.executableURL = fileURL - process.arguments = package.testArgs - process.standardOutput = nil - process.standardError = nil - - try process.run() - process.waitUntilExit() - - terminationStatusProduct *= Int(process.terminationStatus) - } - - guard terminationStatusProduct == 0 else { - throw AddError.executeProcessFailed( - "No succesful executables were found") - } + + /// Installs the package to the home directory. + /// - Parameters: + /// - package: The package module to install. + /// - url: URL where the unpacked package is located. + /// - Returns: `true` if installation is successful, `false` otherwise. + /// - Throws: `IOException` if the package cannot be installed. + func install(package: Package.Module, from url: URL) async throws { + log("Installing package...") + let homePackagesPath = fileManager.homeDirectoryForCurrentUser + .appendingPathComponent(".cosmic") - return terminationStatusProduct - } - - func install(package: Package.Module, from url: URL) async throws -> Bool { - let fm = FileManager.default - let homePackagesPath = fm.homeDirectoryForCurrentUser.appendingPathComponent("Packages") - if !fm.fileExists(atPath: homePackagesPath.path) { - try fm.createDirectory(at: homePackagesPath, withIntermediateDirectories: true) + if !fileManager.fileExists(atPath: homePackagesPath.path) { + try fileManager.createDirectory( + at: homePackagesPath, withIntermediateDirectories: true) } - + + let destination: URL if package.isBundle { - let destination = homePackagesPath.appendingPathComponent("_" + package.name) - - try fm.moveItem(at: url, to: destination) - - for path in package.executablePaths { - guard let executable = path.split(separator: "/").last else { - throw AddError.missingExecutablePath(destination.path()) - } - try fm.createSymbolicLink(at: homePackagesPath.appendingPathComponent(String(executable)), withDestinationURL: destination.appending(path: path)) - } - + destination = homePackagesPath.appendingPathComponent("_" + package.name) + try fileManager.moveItem(at: url, to: destination) + try createSymlinks(for: package, at: destination, in: homePackagesPath) } else { - let destination = homePackagesPath.appendingPathComponent(package.name) - - guard let primaryExecutablePath = package.executablePaths.first else { - throw AddError.missingExecutablePath(destination.path()) - } - - try fm.moveItem(at: url.appendingPathComponent(primaryExecutablePath), to: destination) + destination = homePackagesPath.appendingPathComponent(package.name) + try fileManager.moveItem( + at: url.appendingPathComponent(package.executablePaths.first ?? ""), + to: destination) } - return package.executablePaths.reduce(true) { partialResult, next in - let executable = String(next.split(separator: "/").last ?? "") - return partialResult && fm.isExecutableFile(atPath: homePackagesPath.appendingPathComponent(executable).path) + let allInstalled = package.executablePaths.allSatisfy { path in + fileManager.isExecutableFile( + atPath: homePackagesPath.appendingPathComponent( + String(path.split(separator: "/").last ?? "") + ).path) } + + log(allInstalled ? "Installation complete!" : "Installation failed.") } - - func log(_ message: String) { - if options.verbose { - print(message) + + /// Creates symlinks for executable paths within the installed package. + /// - Parameters: + /// - package: The package module containing executables. + /// - destination: URL where the package is installed. + /// - homePackagesPath: Path to the home packages directory. + /// - Throws: `IOException` if symlink creation fails or executable path is missing. + func createSymlinks( + for package: Package.Module, at destination: URL, in homePackagesPath: URL + ) throws { + for path in package.executablePaths { + guard let executable = path.split(separator: "/").last else { + throw AddError.missingExecutablePath + } + log(debug: "Creating symlink for \(executable)") + try fileManager.createSymbolicLink( + at: homePackagesPath.appendingPathComponent(String(executable)), + withDestinationURL: destination.appending(path: path)) + } + } + + /// Logs messages to console based on the configured log level. + /// - Parameters: + /// - info: Information message to log. + /// - debug: Debug message to log. + func log(_ info: String? = nil, debug: String? = nil) { + if let info { + print(info) + } + + if let debug { + if options.verbose { + print(debug) + } } } } diff --git a/Cosmic/Subcommands/Setup.swift b/Cosmic/Subcommands/Setup.swift deleted file mode 100644 index 11435a0..0000000 --- a/Cosmic/Subcommands/Setup.swift +++ /dev/null @@ -1,191 +0,0 @@ -// -// Setup.swift -// Cosmic -// -// Created by Will Walker on 9/20/24. -// - -import AppKit -import ArgumentParser -import CryptoKit -import Foundation -import Network -import PklSwift -import SystemConfiguration - -extension Cosmic { - struct Setup: AsyncParsableCommand { - static var configuration = CommandConfiguration(abstract: "Setup cosmic.") - @OptionGroup var options: Cosmic.Options - - enum SetupError: Error { - case pklError(String) - case directoryCreationFailed(String) - case downloadFailed(String) - case installationFailed(String) - case profileModificationFailed(String) - case permissionSettingFailed(String) - } - - /// Main entry point for the `setup` subcommand. - mutating func run() async throws { - // Check if setup has been run before - let defaults = UserDefaults.standard - if defaults.bool(forKey: "hasRunSetup") && !options.force { - log("Setup has already been run. Are you sure you want to run it again?") - log("If so, append `--force` to the command to force setup.") - // You can ask the user for confirmation here or exit early if desired. - return - } - - // Create Packages directory if it doesn't exist - try createPackagesDirectory() - - // Download and install PKL - let pklURL = try await downloadPkl() - log("Downloaded pkl to \(pklURL)") - - try installPkl(from: pklURL) - log("Pkl installed successfully.") - - // Add Cosmic Packages to PATH - let isProfileModified = try modifyShellProfiles() - log(isProfileModified ? "Profile modified." : "Unable to modify profile!") - - // Mark setup as completed - defaults.set(true, forKey: "hasRunSetup") - log("Setup completed successfully.") - } - - /// Creates the `Packages` directory in the user's home directory if it does not exist. - func createPackagesDirectory() throws { - let fm = FileManager.default - let homePackagesPath = fm.homeDirectoryForCurrentUser.appendingPathComponent("Packages") - - if !fm.fileExists(atPath: homePackagesPath.path) { - do { - try fm.createDirectory(at: homePackagesPath, withIntermediateDirectories: true) - } catch { - throw SetupError.directoryCreationFailed( - "Failed to create directory at \(homePackagesPath.path)") - } - } - } - - /// Downloads the PKL binary from GitHub. - /// - Returns: A `URL` pointing to the downloaded PKL binary. - func downloadPkl() async throws -> URL { - let pklURLString = - "https://github.com/apple/pkl/releases/download/0.26.3/pkl-macos-aarch64" - - guard let url = URL(string: pklURLString) else { - throw SetupError.pklError("Invalid PKL download URL.") - } - - do { - let (location, _) = try await sharedSession.download(from: url) - return location - } catch { - throw SetupError.downloadFailed( - "Failed to download PKL binary from \(pklURLString)") - } - } - - /// Installs the PKL binary by moving it to the `Packages` directory and setting executable permissions. - func installPkl(from url: URL) throws { - let fm = FileManager.default - let homePackagesPath = fm.homeDirectoryForCurrentUser.appendingPathComponent("Packages") - let pklDestinationPath = homePackagesPath.appendingPathComponent("pkl") - - if !fm.fileExists(atPath: pklDestinationPath.path) { - do { - try fm.moveItem(at: url, to: pklDestinationPath) - } catch { - throw SetupError.installationFailed( - "Failed to move PKL binary to \(pklDestinationPath.path)") - } - } - - do { - try setExecutablePermission(for: pklDestinationPath) - } catch { - throw SetupError.permissionSettingFailed( - "Failed to set executable permissions for \(pklDestinationPath.path)") - } - } - - /// Adds executable permission to the PKL binary. - func setExecutablePermission(for file: URL) throws { - try FileManager.default.setAttributes( - [.posixPermissions: 0o755], ofItemAtPath: file.path) - } - - /// Modifies common shell profiles by adding the Cosmic Packages directory to the user's PATH. - /// - Returns: A `Bool` indicating whether any profiles were modified. - func modifyShellProfiles() throws -> Bool { - let fm = FileManager.default - let homeDirectory = fm.homeDirectoryForCurrentUser - let homePackagesPath = homeDirectory.appendingPathComponent("Packages").path - let pathEntry = "\n# Added by Cosmic\nexport PATH=\"$PATH:\(homePackagesPath)\"\n" - let shellProfiles = [".bashrc", ".zshrc", ".profile", ".bash_profile"] - var profileModified = false - - for profile in shellProfiles { - let profilePath = homeDirectory.appendingPathComponent(profile).path - var resolvedProfilePath = profilePath - - // Resolve symlink if the profile is a symlink - if fm.fileExists(atPath: profilePath), - let resolvedPath = try? fm.destinationOfSymbolicLink(atPath: profilePath) - { - resolvedProfilePath = resolvedPath - } - - if fm.fileExists(atPath: resolvedProfilePath) { - profileModified = true - do { - try backupProfile(resolvedProfilePath) - try appendToFile(pathEntry, at: resolvedProfilePath) - log("Added \(homePackagesPath) to \(profile)") - } catch { - throw SetupError.profileModificationFailed( - "Failed to modify profile: \(profile)") - } - } - } - - if !profileModified { - log( - "No common shell profile found. Please manually add the following to your shell profile:" - ) - log(pathEntry) - } - - return profileModified - } - - /// Appends the given string to the specified file. - func appendToFile(_ text: String, at filePath: String) throws { - if let data = text.data(using: .utf8) { - let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath)) - fileHandle.seekToEndOfFile() - fileHandle.write(data) - fileHandle.closeFile() - } - } - - /// Creates a backup of the given shell profile by copying it with a `.bak` extension. - func backupProfile(_ profilePath: String) throws { - let timestamp = Date().formatted(Date.ISO8601FormatStyle().dateSeparator(.dash)) - let backupPath = profilePath + "." + timestamp + ".bak" - try FileManager.default.copyItem(atPath: profilePath, toPath: backupPath) - } - - /// Logs a message if the verbose option is enabled. - func log(_ message: String) { - if options.verbose { - print(message) - } - } - } -} diff --git a/CosmicTests/CosmicTests.swift b/CosmicTests/CosmicTests.swift index eda8046..ed4b0ee 100644 --- a/CosmicTests/CosmicTests.swift +++ b/CosmicTests/CosmicTests.swift @@ -6,71 +6,189 @@ // import ArgumentParser +import Foundation import PklSwift import Testing @testable import cosmic -struct CosmicSetupTests { - @Test("Set up Cosmic") - func setUpCosmic() async throws { - var setupCommand = Cosmic.Setup() - setupCommand.options = try .parse(["--verbose", "--force"]) - - try setupCommand.createPackagesDirectory() - - let pklURL = try await setupCommand.downloadPkl() - #expect(pklURL.isFileURL) - - try setupCommand.installPkl(from: pklURL) - - let isProfileModified = try setupCommand.modifyShellProfiles() - // TODO: Test against output (which instructs users to self modify) - #expect(isProfileModified || true) - } -} - struct CosmicAddTests { - @Test( - "Install a package", - arguments: [ - // Non-bundled packages - "node", - "go", - "libwebp", - // Bundled packages - "age", - "apko", - "dasel", - //"gh", - //"git-lfs", - //"gitleaks", - //"goreleaser", - //"hermes", - //"k9s", - //"sops", - //"zarf", - ]) - func installPackage(packageName: String) async throws { - var addCommand = Cosmic.Add() - addCommand.options = try .parse(["--verbose"]) - - let package = try await Package.loadFrom( - source: .url(addCommand.packageManifestPath(for: packageName))) - #expect(package.name == packageName) - - let downloadLocation = try await addCommand.download(package: package) - #expect(downloadLocation.isFileURL) - - try addCommand.validate(package: package, at: downloadLocation) - - let unpackedLocation = try addCommand.unpack(package: package, at: downloadLocation) - #expect(unpackedLocation.isFileURL) + + var cmd: Cosmic.Add + + init() { + cmd = Cosmic.Add() + guard let options = try? Cosmic.Options.parse(["--verbose"]) else { + fatalError("Unable to parse arguments for the add command!") + } + cmd.options = options + } + + @Test("Add integration test", arguments: [ + "k9s", + "go" + ]) + func testRun(_ packageName: String) async { + var localCmd = Cosmic.Add() + localCmd.options = self.cmd.options + localCmd.packageName = packageName + + await #expect(throws: Never.self) { + localCmd.packageName = packageName + try await localCmd.run() + } + } + + @Test("Locate a package") + func testLocate() async { + + await #expect(throws: Never.self) { + let k9sPackage = try await cmd.locate(packageName: "k9s") + #expect(k9sPackage.name == "k9s") + } + + await #expect(throws: Cosmic.Add.AddError.packageNotFound) { + try await cmd.locate(packageName: "not-a-package") + } + } + + @Test("Download a package") + func testDownload() async { + let k9sPackage = Package.Module( + name: "k9s", + url: "https://github.com/derailed/k9s/releases/download/v0.26.0/k9s_Darwin_arm64.tar.gz", + purl: "pkg:golang/github.com/derailed/k9s@0.26.0", + version: "0.26.0", + hash: "43df569e527141dbfc53d859d7675b71c2cfc597ffa389a20f91297c6701f255", + executablePaths: ["/k9s"], + testArgs: ["version", "--short"], + type: .archive, + isBundle: false + ) + + await #expect(throws: Never.self) { + let downloadedPackage = try await cmd.download(package: k9sPackage) + #expect(FileManager.default.fileExists(atPath: downloadedPackage.path)) + } + + let brokenPackage = Package.Module( + name: "k9s", + url: "https://github.com/derailed/k9s/releases/download/v0.26.0/k9s_Darwin_arm64.rar", + purl: "pkg:golang/github.com/derailed/k9s@0.26.0", + version: "0.26.0", + hash: "43df569e527141dbfc53d859d7675b71c2cfc597ffa389a20f91297c6701f255", + executablePaths: ["/k9s"], + testArgs: ["version", "--short"], + type: .archive, + isBundle: false + ) + + await #expect(throws: Cosmic.Add.AddError.downloadFailed) { + _ = try await cmd.download(package: brokenPackage) + } + } + + @Test("Validate a package") + func testValidate() async { + let k9sPackage = Package.Module( + name: "k9s", + url: "https://github.com/derailed/k9s/releases/download/v0.26.0/k9s_Darwin_arm64.tar.gz", + purl: "pkg:golang/github.com/derailed/k9s@0.26.0", + version: "0.26.0", + hash: "43df569e527141dbfc53d859d7675b71c2cfc597ffa389a20f91297c6701f255", + executablePaths: ["/k9s"], + testArgs: ["version", "--short"], + type: .archive, + isBundle: false + ) + + let brokenPackage = Package.Module( + name: "k9s", + url: "https://github.com/derailed/k9s/releases/download/v0.26.0/k9s_Darwin_arm64.tar.gz", + purl: "pkg:golang/github.com/derailed/k9s@0.26.0", + version: "0.26.0", + hash: "43df569e57141dbfc53d859d7675b71c2cfc597ffa389a20f91297c6701f255", + executablePaths: ["/k9s"], + testArgs: ["version", "--short"], + type: .archive, + isBundle: false + ) + + guard let url = try? await cmd.download(package: k9sPackage) else { + return + } + + #expect(throws: Never.self) { + try cmd.validate(package: k9sPackage, at: url) + } + + #expect(throws: Cosmic.Add.AddError.invalidPackage) { + try cmd.validate(package: brokenPackage, at: url) + } + } + + @Test("Unpack a package") + func testUnpack() async { + let binaryPkg = Package.Module( + name: "dasel", + url: "https://github.com/TomWright/dasel/releases/download/v2.8.1/dasel_darwin_arm64", + purl: "pkg:golang/github.com/tomwright/dasel@2.8.1", + version: "2.8.1", + hash: "cf976164cf5f929abe25b6924285c27db439dbcc58bac923ee0cbc921a463307", + executablePaths: [""], + testArgs: ["help"], + type: .binary, + isBundle: false + ) + + guard let binaryPkgURL = try? await cmd.download(package: binaryPkg) else { + fatalError("Unable to download binary package") + } + + #expect(throws: Never.self) { + let unpackedBinaryPkg = try cmd.unpack(package: binaryPkg, at: binaryPkgURL) + FileManager.default.fileExists(atPath: unpackedBinaryPkg.path()) + } + + let archivePkg = Package.Module( + name: "k9s", + url: "https://github.com/derailed/k9s/releases/download/v0.26.0/k9s_Darwin_arm64.tar.gz", + purl: "pkg:golang/github.com/derailed/k9s@0.26.0", + version: "0.26.0", + hash: "43df569e527141dbfc53d859d7675b71c2cfc597ffa389a20f91297c6701f255", + executablePaths: ["/k9s"], + testArgs: ["version", "--short"], + type: .archive, + isBundle: false + ) + + guard let archivePkgURL = try? await cmd.download(package: archivePkg) else { + fatalError("Unable to download archive package") + } + + #expect(throws: Never.self) { + let unpackedArchivePkg = try cmd.unpack(package: archivePkg, at: archivePkgURL) + FileManager.default.fileExists(atPath: unpackedArchivePkg.path()) + } + + let zipPkg = Package.Module( + name: "k9s", + url: "https://github.com/derailed/k9s/releases/download/v0.26.0/k9s_Darwin_arm64.tar.gz", + purl: "pkg:golang/github.com/derailed/k9s@0.26.0", + version: "0.26.0", + hash: "43df569e527141dbfc53d859d7675b71c2cfc597ffa389a20f91297c6701f255", + executablePaths: ["/k9s"], + testArgs: ["version", "--short"], + type: .zip, + isBundle: false + ) - let exitCode = try addCommand.execute(package: package, at: unpackedLocation) - #expect(exitCode == 0) + guard let zipPkgURL = try? await cmd.download(package: zipPkg) else { + fatalError("Unable to download zip package") + } - let isInstalled = try await addCommand.install(package: package, from: unpackedLocation) - #expect(isInstalled) + #expect(throws: Cosmic.Add.AddError.invalidPackage) { + _ = try cmd.unpack(package: zipPkg, at: zipPkgURL) + } } } diff --git a/CosmicTests/CosmicTests.xctestplan b/CosmicTests/CosmicTests.xctestplan index ad190c5..45ab6aa 100644 --- a/CosmicTests/CosmicTests.xctestplan +++ b/CosmicTests/CosmicTests.xctestplan @@ -21,7 +21,7 @@ }, "testTargets" : [ { - "parallelizable" : false, + "parallelizable" : true, "target" : { "containerPath" : "container:Cosmic.xcodeproj", "identifier" : "6BD783982C98FBFF009EEB33", diff --git a/scripts/postinstall b/scripts/postinstall new file mode 100755 index 0000000..4a812f5 --- /dev/null +++ b/scripts/postinstall @@ -0,0 +1,18 @@ +#!/bin/zsh + +echo "Running postinstall script..." + +# Define variables +HOME_COSMIC_DIR="$HOME/.cosmic" +PATHS_FILE="/etc/paths.d/cosmic" + +# Append the .cosmic folder to the user's PATH if not already present +if [ ! -f "$PATHS_FILE" ]; then + echo "$HOME_COSMIC_DIR" | sudo tee "$PATHS_FILE" > /dev/null || { echo "Failed to update PATH"; exit 1; } + echo "Added $HOME_COSMIC_DIR to PATH." +else + echo "$PATHS_FILE already exists. Not modifying PATH." +fi + +echo "Postinstall script completed successfully." +exit 0