diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..e8c11be --- /dev/null +++ b/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "8e39da950f9cbe4c8126b1fabf0d3d6945ab547f7564fd7bb0921701f3269c14", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3", + "version" : "1.6.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 61788f9..fb60b0f 100644 --- a/Package.swift +++ b/Package.swift @@ -6,9 +6,21 @@ let package = Package( name: "SwiftTailwind", platforms: [.macOS(.v12)], products: [ - .plugin(name: "TailwindCSS", targets: ["TailwindCSS"]) + .plugin(name: "TailwindCSS", targets: ["TailwindCSS"]), + .executable(name: "TailwindCSSCLIArtifactBundler", targets: ["TailwindCSSCLIArtifactBundler"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), ], targets: [ + .executableTarget( + name: "TailwindCSSCLIArtifactBundler", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Crypto", package: "swift-crypto"), + ] + ), .plugin(name: "TailwindCSS", capability: .buildTool(), dependencies: ["TailwindCSSCLI"]), .binaryTarget( name: "TailwindCSSCLI", diff --git a/Scripts/buildArtifactBundle.sh b/Scripts/buildArtifactBundle.sh deleted file mode 100755 index 3893187..0000000 --- a/Scripts/buildArtifactBundle.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -# Check if TAILWINDCSS_VERSION environment variable is set -if [[ -z "${TAILWINDCSS_VERSION:-}" ]]; then - echo "Error: TAILWINDCSS_VERSION environment variable is not set" - echo "Usage: TAILWINDCSS_VERSION=v3.4.0 $0" - exit 1 -fi - -VERSION="$TAILWINDCSS_VERSION" -echo "Building artifact bundle for TailwindCSS version: $VERSION" - -# Get the directory containing this script to find the template -TEMPLATE_FILE="$PWD/Scripts/info.template.json" - -# Check if template file exists -if [[ ! -f "$TEMPLATE_FILE" ]]; then - echo "Error: Template file not found at $TEMPLATE_FILE" - exit 1 -fi - -# Create working directory -WORK_DIR="/tmp/tailwindcss.artifactbundle" -echo "Creating working directory: $WORK_DIR" -rm -rf "$WORK_DIR" -mkdir -p "$WORK_DIR" -cd "$WORK_DIR" - -# GitHub release base URL -BASE_URL="https://github.com/tailwindlabs/tailwindcss/releases/download/$VERSION" - -# Download and place binaries -download_binary() { - local binary_name="$1" - local target_path="$2" - local target_dir - target_dir=$(dirname "$target_path") - - echo "Downloading $binary_name..." - mkdir -p "$target_dir" - - if curl -L "$BASE_URL/$binary_name" > "$target_path"; then - chmod +x "$target_path" - echo "✓ Downloaded and made executable: $target_path" - else - echo "✗ Failed to download $binary_name from $BASE_URL/$binary_name" - exit 1 - fi -} - -# Download each binary to its target location -download_binary "tailwindcss-linux-x64" "tailwindcss-$VERSION-linux-x64/bin/tailwindcss" -download_binary "tailwindcss-macos-x64" "tailwindcss-$VERSION-macos-x64/bin/tailwindcss" -download_binary "tailwindcss-macos-arm64" "tailwindcss-$VERSION-macos-arm64/bin/tailwindcss" - -# Create info.json from template, replacing %VERSION% with actual version -echo "Creating info.json from template..." -sed "s/%VERSION%/$VERSION/g" "$TEMPLATE_FILE" > "info.json" -echo "✓ Created info.json with version $VERSION" - -# Create ZIP file -ZIP_FILE="/tmp/tailwindcss.artifactbundle.zip" -echo "Creating ZIP file: $ZIP_FILE" - -# Remove existing ZIP file if it exists -rm -f "$ZIP_FILE" - -# Create ZIP with the artifact bundle as the only child in root -cd /tmp -zip -r "tailwindcss.artifactbundle.zip" "tailwindcss.artifactbundle" - -echo "✓ Created ZIP file: $ZIP_FILE" - -# Compute checksum using Swift Package Manager -echo "Computing checksum..." -CHECKSUM=$(swift package compute-checksum "$ZIP_FILE") - -echo "" -echo "=== BUILD COMPLETE ===" -echo "ZIP file path: $ZIP_FILE" -echo "Checksum: $CHECKSUM" diff --git a/Scripts/info.template.json b/Scripts/info.template.json deleted file mode 100644 index ecdafef..0000000 --- a/Scripts/info.template.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "schemaVersion": "1.0", - "artifacts": { - "tailwindcss": { - "version": "%VERSION%", - "type": "executable", - "variants": [ - { - "path": "tailwindcss-%VERSION%-macos-x64/bin/tailwindcss", - "supportedTriples": ["x86_64-apple-macosx"] - }, - { - "path": "tailwindcss-%VERSION%-macos-arm64/bin/tailwindcss", - "supportedTriples": ["arm64-apple-macosx"] - }, - { - "path": "tailwindcss-%VERSION%-linux-x64/bin/tailwindcss", - "supportedTriples": ["x86_64-unknown-linux-gnu"] - } - ] - } - } -} diff --git a/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder.swift b/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder.swift new file mode 100644 index 0000000..4141e9a --- /dev/null +++ b/Sources/TailwindCSSCLIArtifactBundler/ArtifactBundleBuilder.swift @@ -0,0 +1,255 @@ +import Crypto +import Foundation + +class ArtifactBundleBuilder { + private let version: String + private let workDir: String + private let outputDir: String + private let fileManager = FileManager.default + + private let binaryConfigurations: [BinaryConfiguration] = [ + BinaryConfiguration(binaryName: "tailwindcss-linux-x64", triple: "x86_64-unknown-linux-gnu"), + BinaryConfiguration(binaryName: "tailwindcss-macos-x64", triple: "x86_64-apple-darwin"), + BinaryConfiguration(binaryName: "tailwindcss-macos-arm64", triple: "aarch64-apple-darwin"), + ] + + init(version: String, workDir: String, outputDir: String) { + self.version = version + self.workDir = workDir + self.outputDir = outputDir + } + + func buildArtifactBundles() throws { + try setupWorkingDirectory() + + var bundleInfos: [BundleInfo] = [] + + print("Creating individual bundles...") + for config in binaryConfigurations { + let bundleInfo = try createBundle(for: config) + bundleInfos.append(bundleInfo) + } + + try generateArtifactBundleIndex(bundleInfos: bundleInfos) + + print("=== BUILD COMPLETE ===") + print("All bundles created successfully:") + print("") + print("Generated artifact bundle index: \(outputDir)/tailwindcss.artifactbundleindex") + print("") + + let indexChecksum = try computeChecksum( + filePath: "\(outputDir)/tailwindcss.artifactbundleindex", usingSHA256Directly: true) + print("Index checksum: \(indexChecksum)") + print("") + + for bundleInfo in bundleInfos { + print("Bundle: \(bundleInfo.fileName)") + print(" Checksum: \(bundleInfo.checksum)") + print(" Triple: \(bundleInfo.triple)") + print("") + } + } + + private func setupWorkingDirectory() throws { + print("Creating working directory: \(workDir)") + + if fileManager.fileExists(atPath: workDir) { + try fileManager.removeItem(atPath: workDir) + } + + try fileManager.createDirectory(atPath: workDir, withIntermediateDirectories: true) + } + + private func createBundle(for config: BinaryConfiguration) throws -> BundleInfo { + let bundleDirName = "tailwindcss-\(version)-\(config.triple).artifactbundle" + let bundleDir = "\(workDir)/\(bundleDirName)" + let binaryPath = "bin/tailwindcss" + + print("Creating bundle for \(config.triple)...") + + // Create bundle directory structure + let binDir = "\(bundleDir)/bin" + try fileManager.createDirectory(atPath: binDir, withIntermediateDirectories: true) + + // Download binary + print(" Downloading \(config.binaryName)...") + let binaryURL = + "https://github.com/tailwindlabs/tailwindcss/releases/download/\(version)/\(config.binaryName)" + let binaryDestination = "\(bundleDir)/\(binaryPath)" + + try downloadFile(from: binaryURL, to: binaryDestination) + try makeExecutable(path: binaryDestination) + print(" ✓ Downloaded and made executable: \(binaryDestination)") + + // Create info.json + print(" Creating info.json...") + try createInfoJSON(bundleDir: bundleDir, binaryPath: binaryPath, triple: config.triple) + print( + " ✓ Created info.json with version \(version), path \(binaryPath), triple \(config.triple)") + + // Create ZIP file + let zipFileName = "\(bundleDirName).zip" + let zipPath = "\(outputDir)/\(zipFileName)" + print(" Creating ZIP file: \(zipPath)") + + try createZipFile(bundleDir: bundleDir, zipPath: zipPath) + + // Compute checksum + print(" Computing checksum...") + let checksum = try computeChecksum(filePath: zipPath) + + print(" ✓ Bundle created: \(zipPath)") + print(" ✓ Checksum: \(checksum)") + print("") + + return BundleInfo(fileName: zipFileName, checksum: checksum, triple: config.triple) + } + + private func downloadFile(from urlString: String, to destination: String) throws { + guard let url = URL(string: urlString) else { + throw ArtifactBundleError.invalidURL(urlString) + } + + let data = try Data(contentsOf: url) + try data.write(to: URL(fileURLWithPath: destination)) + } + + private func makeExecutable(path: String) throws { + let attributes = [FileAttributeKey.posixPermissions: 0o755] + try fileManager.setAttributes(attributes, ofItemAtPath: path) + } + + private func createInfoJSON(bundleDir: String, binaryPath: String, triple: String) throws { + let artifact = Artifact( + version: version, + type: "executable", + variants: [ + ArtifactVariant(path: binaryPath, supportedTriples: [triple]) + ] + ) + + let info = ArtifactBundleInfo( + schemaVersion: "1.0", + artifacts: ["tailwindcss": artifact] + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + let jsonData = try encoder.encode(info) + let infoPath = "\(bundleDir)/info.json" + try jsonData.write(to: URL(fileURLWithPath: infoPath)) + } + + private func createZipFile(bundleDir: String, zipPath: String) throws { + // Remove existing ZIP file if it exists + if fileManager.fileExists(atPath: zipPath) { + try fileManager.removeItem(atPath: zipPath) + } + + let bundleDirURL = URL(fileURLWithPath: bundleDir) + let workDirURL = bundleDirURL.deletingLastPathComponent() + let bundleName = bundleDirURL.lastPathComponent + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/zip") + process.arguments = ["-r", zipPath, bundleName] + process.currentDirectoryURL = workDirURL + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw ArtifactBundleError.zipCreationFailed + } + } + + /// Computes the SHA256 checksum of a file. + /// + /// If `usingSHA256Directly` is true, it uses Swift Crypto's SHA256 implementation. + /// This is to workaround https://github.com/swiftlang/swift-package-manager/issues/9219. + private func computeChecksum( + filePath: String, + usingSHA256Directly: Bool = false + ) throws -> String { + if usingSHA256Directly { + // Use swift-crypto's SHA256 implementation + let fileURL = URL(fileURLWithPath: filePath) + let data = try Data(contentsOf: fileURL) + let hash = SHA256.hash(data: data) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } else { + // Use swift package compute-checksum command + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/swift") + process.arguments = ["package", "compute-checksum", filePath] + + let pipe = Pipe() + process.standardOutput = pipe + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw ArtifactBundleError.checksumComputationFailed + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)?.trimmingCharacters( + in: .whitespacesAndNewlines) + + guard let checksum = output, !checksum.isEmpty else { + throw ArtifactBundleError.checksumComputationFailed + } + + return checksum + } + } + + private func generateArtifactBundleIndex(bundleInfos: [BundleInfo]) throws { + print("Generating tailwindcss.artifactbundleindex...") + + // Create output directory if it doesn't exist + if !fileManager.fileExists(atPath: outputDir) { + try fileManager.createDirectory(atPath: outputDir, withIntermediateDirectories: true) + } + + let bundles = bundleInfos.map { bundleInfo in + Bundle( + fileName: bundleInfo.fileName, + checksum: bundleInfo.checksum, + supportedTriples: [bundleInfo.triple] + ) + } + + let index = ArtifactBundleIndex( + schemaVersion: "1.0", + bundles: bundles + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + let jsonData = try encoder.encode(index) + let indexPath = "\(outputDir)/tailwindcss.artifactbundleindex" + try jsonData.write(to: URL(fileURLWithPath: indexPath)) + } +} + +enum ArtifactBundleError: Error, LocalizedError { + case invalidURL(String) + case zipCreationFailed + case checksumComputationFailed + + var errorDescription: String? { + switch self { + case .invalidURL(let url): + return "Invalid URL: \(url)" + case .zipCreationFailed: + return "Failed to create ZIP file" + case .checksumComputationFailed: + return "Failed to compute checksum" + } + } +} diff --git a/Sources/TailwindCSSCLIArtifactBundler/Models.swift b/Sources/TailwindCSSCLIArtifactBundler/Models.swift new file mode 100644 index 0000000..6d74643 --- /dev/null +++ b/Sources/TailwindCSSCLIArtifactBundler/Models.swift @@ -0,0 +1,53 @@ +import Foundation + +// MARK: - Artifact Bundle Info Models + +/// Represents the info.json file structure for an artifact bundle +struct ArtifactBundleInfo: Codable { + let schemaVersion: String + let artifacts: [String: Artifact] + + enum CodingKeys: String, CodingKey { + case schemaVersion, artifacts + } +} + +struct Artifact: Codable { + let version: String + let type: String + let variants: [ArtifactVariant] +} + +struct ArtifactVariant: Codable { + let path: String + let supportedTriples: [String] +} + +// MARK: - Artifact Bundle Index Models + +/// Represents the .artifactbundleindex file structure +struct ArtifactBundleIndex: Codable { + let schemaVersion: String + let bundles: [Bundle] +} + +struct Bundle: Codable { + let fileName: String + let checksum: String + let supportedTriples: [String] +} + +// MARK: - Internal Data Models + +/// Configuration for a binary platform +struct BinaryConfiguration { + let binaryName: String + let triple: String +} + +/// Information about a created bundle +struct BundleInfo { + let fileName: String + let checksum: String + let triple: String +} diff --git a/Sources/TailwindCSSCLIArtifactBundler/TailwindCSSCLIArtifactBundler.swift b/Sources/TailwindCSSCLIArtifactBundler/TailwindCSSCLIArtifactBundler.swift new file mode 100644 index 0000000..ca14ef0 --- /dev/null +++ b/Sources/TailwindCSSCLIArtifactBundler/TailwindCSSCLIArtifactBundler.swift @@ -0,0 +1,32 @@ +import ArgumentParser +import Foundation + +@main +struct TailwindCSSCLIArtifactBundler: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "TailwindCSSCLIArtifactBundler", + abstract: "Build TailwindCSS CLI artifact bundles for Swift Package Manager", + version: "1.0.0" + ) + + @Option(name: .shortAndLong, help: "TailwindCSS version to build (e.g., v4.1.14)") + var version: String + + @Option(name: .shortAndLong, help: "Working directory for temporary files") + var workDir: String = "/tmp/tailwindcss-bundles" + + @Option(name: .shortAndLong, help: "Output directory for the artifact bundle index") + var outputDir: String = "." + + func run() throws { + print("Building artifact bundles for TailwindCSS version: \(version)") + + let bundler = ArtifactBundleBuilder( + version: version, + workDir: workDir, + outputDir: outputDir + ) + + try bundler.buildArtifactBundles() + } +}