diff --git a/dots/.gitignore b/dots/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/dots/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/dots/Makefile b/dots/Makefile new file mode 100644 index 0000000..0145afe --- /dev/null +++ b/dots/Makefile @@ -0,0 +1,18 @@ +PREFIX ?= $(HOME)/.local +BINDIR = $(PREFIX)/bin +COMPLETIONDIR = $(PREFIX)/completions +LIBDIR = $(PREFIX)/lib + +build: + swiftc ./scripts/build.swift + ./build + rm ./build + +install: build + install -d "$(BINDIR)" "$(LIBDIR)" + install ./.build/release/dots "$(BINDIR)" + +uninstall: + rm "$(BINDIR)/dots" + rm "$(COMPLETIONDIR)/_dots" + diff --git a/dots/Package.swift b/dots/Package.swift new file mode 100644 index 0000000..38ce302 --- /dev/null +++ b/dots/Package.swift @@ -0,0 +1,75 @@ +// swift-tools-version: 5.7 + + +import PackageDescription + +let package = Package( + name: "dots", + platforms: [ + .macOS(.v12) + ], + products: [ + .executable(name: "dots", targets: ["dots"]), + .library(name: "CliMiddleware", targets: ["CliMiddleware"]), + .library(name: "CliMiddlewareLive", targets: ["CliMiddlewareLive"]), + .library(name: "FileClient", targets: ["FileClient"]), + .library(name: "LoggingDependency", targets: ["LoggingDependency"]), + .library(name: "ShellClient", targets: ["ShellClient"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"), + .package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "0.1.4"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + .package(url: "https://github.com/adorkable/swift-log-format-and-pipe.git", from: "0.1.0"), + ], + targets: [ + .target( + name: "CliMiddleware", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .target( + name: "CliMiddlewareLive", + dependencies: [ + "CliMiddleware", + "FileClient", + "LoggingDependency", + "ShellClient" + ] + ), + .target( + name: "FileClient", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .target( + name: "LoggingDependency", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "Logging", package: "swift-log"), + .product(name: "LoggingFormatAndPipe", package: "swift-log-format-and-pipe"), + ] + ), + .executableTarget( + name: "dots", + dependencies: [ + "CliMiddlewareLive", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .testTarget( + name: "dotsTests", + dependencies: ["dots"] + ), + .target( + name: "ShellClient", + dependencies: [ + "LoggingDependency", + .product(name: "Dependencies", package: "swift-dependencies") + ] + ), + ] +) diff --git a/dots/README.md b/dots/README.md new file mode 100644 index 0000000..7b6d177 --- /dev/null +++ b/dots/README.md @@ -0,0 +1,3 @@ +# dots + +A description of this package. diff --git a/dots/Sources/CliMiddleware/Middleware.swift b/dots/Sources/CliMiddleware/Middleware.swift new file mode 100644 index 0000000..558c070 --- /dev/null +++ b/dots/Sources/CliMiddleware/Middleware.swift @@ -0,0 +1,90 @@ +import Dependencies +import Foundation +import XCTestDynamicOverlay + +/// Implements the logic for the `dots` command line tool. +/// +/// Each command and it's sub-commands are implemented in the ``CliMiddlewareLive`` module. While this +/// represents the interface. +/// +public struct CliMiddleware { + + public var brew: (BrewContext) async throws -> Void + public var zsh: (ZshContext) async throws -> Void + + public init( + brew: @escaping (BrewContext) async throws -> Void, + zsh: @escaping (ZshContext) async throws -> Void + ) { + self.brew = brew + self.zsh = zsh + } + + public struct GlobalContext { + public let dryRun: Bool + + public init(dryRun: Bool) { + self.dryRun = dryRun + } + } + + public struct BrewContext { + public let routes: [Route] + + public init( + routes: [Route] + ) { + self.routes = routes + } + + public enum Route: String, CaseIterable { + case all + case appStore + case brews + case casks + } + } + + public struct ZshContext { + + public let context: Context + + public init( + context: Context + ) { + self.context = context + } + + public enum Context { + case install + case uninstall + } + } +} + +extension CliMiddleware.GlobalContext: TestDependencyKey { + public static let testValue: CliMiddleware.GlobalContext = .init(dryRun: true) +} + +extension CliMiddleware: TestDependencyKey { + + public static let noop = Self.init( + brew: unimplemented("\(Self.self).brew"), + zsh: unimplemented("\(Self.self).zsh") + ) + + public static let testValue = CliMiddleware.noop +} + +extension DependencyValues { + + public var cliMiddleware: CliMiddleware { + get { self[CliMiddleware.self] } + set { self[CliMiddleware.self] = newValue } + } + + public var globals: CliMiddleware.GlobalContext { + get { self[CliMiddleware.GlobalContext.self] } + set { self[CliMiddleware.GlobalContext.self] = newValue } + } +} diff --git a/dots/Sources/CliMiddlewareLive/Internals/Brew.swift b/dots/Sources/CliMiddlewareLive/Internals/Brew.swift new file mode 100644 index 0000000..2276464 --- /dev/null +++ b/dots/Sources/CliMiddlewareLive/Internals/Brew.swift @@ -0,0 +1,95 @@ +import Dependencies +import CliMiddleware +import FileClient +import Foundation +import LoggingDependency +import ShellClient + +struct Brew { + @Dependency(\.fileClient) var fileClient + @Dependency(\.globals.dryRun) var dryRun + @Dependency(\.logger) var logger + @Dependency(\.shellClient) var shellClient + + let context: CliMiddleware.BrewContext + + func run() async throws { + logger.info("Installing homebrew dependencies.") + for brewfile in try context.routes.brewfiles() { + logger.info("Installing dependencies from brewfile: \(brewfile.absoluteString)") + if !dryRun { + try shellClient.install(brewfile: brewfile) + logger.debug("Done installing dependencies from brewfile: \(brewfile.absoluteString)") + } + } + logger.info("Done installing homebrew dependencies.") + } +} + +extension ShellClient { + + func install(brewfile: URL) throws { + try foregroundShell( + "/opt/homebrew/bin/brew", + "bundle", + "--no-lock", + "--cleanup", + "--debug", + "--file", + brewfile.absoluteString + ) + } +} + +fileprivate extension FileClient { + var brewFileDirectory: URL { + dotfilesDirectory() + .appendingPathComponent("macOS") + .appendingPathComponent(".config") + .appendingPathComponent("macOS") + } +} + +fileprivate extension CliMiddleware.BrewContext.Route { + + static func allBrews() throws -> [URL] { + let brews: [Self] = [.appStore, .brews, .casks] + return try brews.map { try $0.brewfile() } + } + + func brewfile() throws -> URL { + @Dependency(\.fileClient) var fileClient + switch self { + case .all: + // should never happen. + throw BrewfileError() + case .appStore: + return fileClient.brewFileDirectory.appendingPathComponent("AppStore.Brewfile") + case .brews: + return fileClient.brewFileDirectory.appendingPathComponent("Brewfile") + case .casks: + return fileClient.brewFileDirectory.appendingPathComponent("Casks.Brewfile") + } + } +} + +fileprivate extension Array where Element == CliMiddleware.BrewContext.Route { + + func brewfiles() throws -> [URL] { + + if self.count == 1 && self.first == .all { + return try CliMiddleware.BrewContext.Route.allBrews() + } + + var urls = [URL]() + for route in self { + if route != .all { + let url = try route.brewfile() + urls.append(url) + } + } + return urls + } +} + +struct BrewfileError: Error { } diff --git a/dots/Sources/CliMiddlewareLive/Internals/Zsh.swift b/dots/Sources/CliMiddlewareLive/Internals/Zsh.swift new file mode 100644 index 0000000..d608e2b --- /dev/null +++ b/dots/Sources/CliMiddlewareLive/Internals/Zsh.swift @@ -0,0 +1,87 @@ +import CliMiddleware +import Dependencies +import FileClient +import Foundation +import LoggingDependency + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct Zsh { + @Dependency(\.globals.dryRun) var dryRun + @Dependency(\.fileClient) var fileClient + @Dependency(\.logger) var logger + + let context: CliMiddleware.ZshContext + + func install() async throws { + let configString = fileClient.zshConfigDestination.absoluteString + .replacingOccurrences(of: "file://", with: "") + + let destination = fileClient.zshEnvDestination + + let destinationString = destination.absoluteString + .replacingOccurrences(of: "file://", with: "") + + logger.info("Linking zsh configuration to: \(configString)") + logger.info("Linking .zshenv file to: \(destinationString)") + + if !dryRun { + try await linkZshConfig() + try await fileClient.createSymlink( + source: fileClient.zshEnvSource, + destination: destination + ) + } + logger.info("Done installing zsh configuration files.") + } + + func uninstall() async throws { + logger.info("Uninstalling zsh configuration from: \(fileClient.zshConfigDestination.absoluteString)") + if !dryRun { + logger.debug("Moving configuration to the trash.") + try await fileClient.moveToTrash(fileClient.zshConfigDestination) + logger.debug("Moving .zshenv to the trash.") + try await fileClient.moveToTrash(fileClient.zshEnvDestination) + } + logger.info("Done uninstalling zsh configuration, you will need to reload your shell.") + } + + func run() async throws { + switch context.context { + case .install: + try await self.install() + case .uninstall: + try await self.uninstall() + } + } + + func linkZshConfig() async throws { + try await fileClient.createDirectory(at: fileClient.configDirectory()) + try await fileClient.createSymlink( + source: fileClient.zshDirectory, + destination: fileClient.zshConfigDestination + ) + } +} + +fileprivate extension FileClient { + var zshDirectory: URL { + dotfilesDirectory() + .appendingPathComponent("zsh") + .appendingPathComponent("config") + } + + var zshConfigDestination: URL { + configDirectory().appendingPathComponent("zsh") + } + + var zshEnvDestination: URL { + homeDirectory().appendingPathComponent(".zshenv") + } + + var zshEnvSource: URL { + zshDirectory.appendingPathComponent(".zshenv") + } +} diff --git a/dots/Sources/CliMiddlewareLive/LiveKey.swift b/dots/Sources/CliMiddlewareLive/LiveKey.swift new file mode 100644 index 0000000..6f92d2b --- /dev/null +++ b/dots/Sources/CliMiddlewareLive/LiveKey.swift @@ -0,0 +1,19 @@ +import Dependencies +@_exported import CliMiddleware +@_exported import FileClient +@_exported import LoggingDependency +@_exported import ShellClient +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension CliMiddleware: DependencyKey { + public static var liveValue: CliMiddleware { + .init( + brew: { try await Brew(context: $0).run() }, + zsh: { try await Zsh(context: $0).run() } + ) + } +} diff --git a/dots/Sources/FileClient/Client.swift b/dots/Sources/FileClient/Client.swift new file mode 100644 index 0000000..b4fac39 --- /dev/null +++ b/dots/Sources/FileClient/Client.swift @@ -0,0 +1,70 @@ +import Dependencies +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import XCTestDynamicOverlay + +/// Represents interactions with the file system. +/// +public struct FileClient { + + public var configDirectory: () -> URL + public var createDirectory: (URL, Bool) async throws -> Void + public var createSymlink: (URL, URL) async throws -> Void + public var dotfilesDirectory: () -> URL + public var homeDirectory: () -> URL + public var exists: (URL) async throws -> Bool + public var readFile: (URL) async throws -> Data + public var moveToTrash: (URL) async throws -> Void + public var writeFile: (Data, URL) async throws -> Void + + public func createDirectory( + at url: URL, + withIntermediates: Bool = true + ) async throws { + let exists = try await self.exists(url) + if !exists { + try await createDirectory(url, withIntermediates) + } + } + + public func createSymlink( + source: URL, + destination: URL + ) async throws { + try await self.createSymlink(source, destination) + } + + public func read(file: URL) async throws -> Data { + try await self.readFile(file) + } + + public func write(data: Data, to file: URL) async throws { + try await writeFile(data, file) + } +} + +extension FileClient: TestDependencyKey { + public static let noop = Self.init( + configDirectory: unimplemented(placeholder: URL(string: "/")!), + createDirectory: unimplemented(), + createSymlink: unimplemented(), + dotfilesDirectory: unimplemented(placeholder: URL(string: "/")!), + homeDirectory: unimplemented(), + exists: unimplemented(placeholder: false), + readFile: unimplemented(placeholder: Data()), + moveToTrash: unimplemented(), + writeFile: unimplemented() + ) + + public static let testValue: FileClient = .noop + +} + +extension DependencyValues { + public var fileClient: FileClient { + get { self[FileClient.self] } + set { self[FileClient.self] = newValue } + } +} diff --git a/dots/Sources/FileClient/LiveKey.swift b/dots/Sources/FileClient/LiveKey.swift new file mode 100644 index 0000000..6e114a0 --- /dev/null +++ b/dots/Sources/FileClient/LiveKey.swift @@ -0,0 +1,78 @@ +import Dependencies +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension FileClient: DependencyKey { + + public static var liveValue: FileClient { + .live(environment: ProcessInfo.processInfo.environment) + } + + public static func live(environment: [String: String] = [:]) -> Self { + let environment = Environment(environment: environment) + return .init( + configDirectory: { + guard let xdgConfigHome = environment.xdgConfigHome, + let configUrl = URL(string: xdgConfigHome) + else { + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config") + } + return configUrl + }, + createDirectory: { url, withIntermediates in + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: withIntermediates + ) + }, + createSymlink: { source, destination in + try FileManager.default.createSymbolicLink( + at: source, + withDestinationURL: destination + ) + }, + dotfilesDirectory: { + guard let dotfiles = environment.dotfilesDirectory, + let dotfilesUrl = URL(string: dotfiles) + else { + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".dotfiles") + } + return dotfilesUrl + }, + homeDirectory: { + FileManager.default.homeDirectoryForCurrentUser + }, + exists: { path in + FileManager.default.fileExists(atPath: path.absoluteString) + }, + readFile: { path in + try Data(contentsOf: path) + }, + moveToTrash: { path in + try FileManager.default.trashItem(at: path, resultingItemURL: nil) + }, + writeFile: { data, path in + try data.write(to: path) + } + ) + } +} + +fileprivate struct Environment { + let xdgConfigHome: String? + let dotfilesDirectory: String? + + enum CodingKeys: String, CodingKey { + case xdgConfigHome = "XDG_CONFIG_HOME" + case dotfilesDirectory = "DOTFILES" + } + + init(environment: [String: String]) { + self.xdgConfigHome = environment[CodingKeys.xdgConfigHome.rawValue] + self.dotfilesDirectory = environment[CodingKeys.dotfilesDirectory.rawValue] + } +} diff --git a/dots/Sources/LoggingDependency/Live.swift b/dots/Sources/LoggingDependency/Live.swift new file mode 100644 index 0000000..0fefb7e --- /dev/null +++ b/dots/Sources/LoggingDependency/Live.swift @@ -0,0 +1,32 @@ +import Dependencies +import Foundation +@_exported import Logging +import LoggingFormatAndPipe + +extension Logger: DependencyKey { + + fileprivate static func factory(label: String) -> Self { + Logger(label: "dots") { _ in + LoggingFormatAndPipe.Handler( + formatter: BasicFormatter([.message]), + pipe: LoggerTextOutputStreamPipe.standardOutput + ) + } + } + + public static var liveValue: Logger { + factory(label: "dots") + } + + public static var testValue: Logger { + factory(label: "dots-test") + } + +} + +extension DependencyValues { + public var logger: Logger { + get { self[Logger.self] } + set { self[Logger.self] = newValue } + } +} diff --git a/dots/Sources/ShellClient/Client.swift b/dots/Sources/ShellClient/Client.swift new file mode 100644 index 0000000..8595537 --- /dev/null +++ b/dots/Sources/ShellClient/Client.swift @@ -0,0 +1,37 @@ +import Dependencies +import Foundation +import XCTestDynamicOverlay +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct ShellClient { + public var foregroundShell: ([String]) throws -> Void + public var backgroundShell: ([String]) throws -> String + + public func foregroundShell(_ arguments: String...) throws { + try self.foregroundShell(arguments) + } + + @discardableResult + public func backgroundShell(_ arguments: String...) throws -> String { + try self.backgroundShell(arguments) + } +} + +extension ShellClient: TestDependencyKey { + + public static let noop = Self.init( + foregroundShell: unimplemented(), + backgroundShell: unimplemented(placeholder: "") + ) + + public static let testValue: ShellClient = .noop +} + +extension DependencyValues { + public var shellClient: ShellClient { + get { self[ShellClient.self] } + set { self[ShellClient.self] = newValue } + } +} diff --git a/dots/Sources/ShellClient/LiveKey.swift b/dots/Sources/ShellClient/LiveKey.swift new file mode 100644 index 0000000..542eafb --- /dev/null +++ b/dots/Sources/ShellClient/LiveKey.swift @@ -0,0 +1,59 @@ +import Dependencies +import Foundation +import LoggingDependency +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension ShellClient: DependencyKey { + + public static var liveValue: ShellClient { + @Dependency(\.logger) var logger + + return .init( + foregroundShell: { arguments in + logger.debug("Running in foreground shell.") + logger.debug("$ \(arguments.joined(separator: " "))") + + let task = Process() + task.launchPath = "/usr/bin/env" + task.arguments = arguments + task.launch() + task.waitUntilExit() + + guard task.terminationStatus == 0 else { + throw ShellError(terminationStatus: task.terminationStatus) + } + }, + backgroundShell: { arguments in + logger.debug("Running background shell.") + logger.debug("$ \(arguments.joined(separator: " "))") + + let task = Process() + task.launchPath = "/usr/bin/env" + task.arguments = arguments + // grab stdout + let output = Pipe() + task.standardOutput = output + // ignore stderr + let error = Pipe() + task.standardError = error + task.launch() + task.waitUntilExit() + + guard task.terminationStatus == 0 else { + throw ShellError(terminationStatus: task.terminationStatus) + } + + return String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + + } + ) + } + +} + +struct ShellError: Swift.Error { + var terminationStatus: Int32 +} diff --git a/dots/Sources/dots/CliContext.swift b/dots/Sources/dots/CliContext.swift new file mode 100644 index 0000000..20ef8f4 --- /dev/null +++ b/dots/Sources/dots/CliContext.swift @@ -0,0 +1,24 @@ +import ArgumentParser +import Dependencies +import Foundation + +struct CliContext { + let globals: GlobalOptions + let _run: () async throws -> Void + + init(globals: GlobalOptions, run: @escaping () async throws -> Void) { + self.globals = globals + self._run = run + } + + func run() async throws { + try await withDependencies { + if globals.verbose { + $0.logger.logLevel = .debug + } + $0.globals = .live(globals) + } operation: { + try await _run() + } + } +} diff --git a/dots/Sources/dots/Commands/Brew.swift b/dots/Sources/dots/Commands/Brew.swift new file mode 100644 index 0000000..8aa7c94 --- /dev/null +++ b/dots/Sources/dots/Commands/Brew.swift @@ -0,0 +1,44 @@ +import ArgumentParser +import CliMiddleware +import Dependencies +import Foundation +import LoggingDependency + +extension Dots { + + struct Brew: AsyncParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Manage homebrew dependency installation.", + subcommands: [ + Install.self + ], + defaultSubcommand: Install.self + ) + + struct Install: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + abstract: "Install brew dependencies from the brewfiles." + ) + + @OptionGroup var globals: GlobalOptions + + @Flag(help: "The homebrew dependencies to install from their brewfiles.") + var routes: [CliMiddleware.BrewContext.Route] = [.all] + + func run() async throws { + try await CliContext(globals: globals) { + @Dependency(\.cliMiddleware.brew) var brew + @Dependency(\.logger) var logger: Logger + + logger.debug("Routes: \(routes)") + try await brew(.init(routes: routes)) + logger.info("Done.") + } + .run() + } + } + } +} + +extension CliMiddleware.BrewContext.Route: EnumerableFlag { } diff --git a/dots/Sources/dots/Commands/Zsh.swift b/dots/Sources/dots/Commands/Zsh.swift new file mode 100644 index 0000000..b523904 --- /dev/null +++ b/dots/Sources/dots/Commands/Zsh.swift @@ -0,0 +1,57 @@ +import ArgumentParser +import CliMiddleware +import Dependencies +import Foundation +import LoggingDependency + +extension Dots { + struct Zsh: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Manage zsh configuration.", + subcommands: [ + Install.self, + Uninstall.self + ], + defaultSubcommand: Install.self + ) + + + struct Install: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Install zsh configuration files." + ) + + @OptionGroup var globals: GlobalOptions + + func run() async throws { + try await CliContext(globals: globals) { + @Dependency(\.cliMiddleware.zsh) var zsh + @Dependency(\.logger) var logger: Logger + + try await zsh(.init(context: .install)) + logger.info("Done.") + } + .run() + } + } + + struct Uninstall: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Uninstall zsh configuration files." + ) + + @OptionGroup var globals: GlobalOptions + + func run() async throws { + try await CliContext(globals: globals) { + @Dependency(\.cliMiddleware.zsh) var zsh + @Dependency(\.logger) var logger: Logger + + try await zsh(.init(context: .uninstall)) + logger.info("Done.") + } + .run() + } + } + } +} diff --git a/dots/Sources/dots/GlobalOptions.swift b/dots/Sources/dots/GlobalOptions.swift new file mode 100644 index 0000000..e23985a --- /dev/null +++ b/dots/Sources/dots/GlobalOptions.swift @@ -0,0 +1,24 @@ +import ArgumentParser +import CliMiddleware +import Dependencies +import Foundation + +struct GlobalOptions: ParsableArguments { + @Flag( + name: .long, + help: "Perform an action as a dry-run, not removing or installing anything." + ) + var dryRun: Bool = false + + @Flag( + name: .long, + help: "Increase logging output level." + ) + var verbose: Bool = false +} + +extension CliMiddleware.GlobalContext { + static func live(_ globalOptions: GlobalOptions) -> Self { + .init(dryRun: globalOptions.dryRun) + } +} diff --git a/dots/Sources/dots/Version.swift b/dots/Sources/dots/Version.swift new file mode 100644 index 0000000..815a5a6 --- /dev/null +++ b/dots/Sources/dots/Version.swift @@ -0,0 +1,2 @@ +// Do not change this value, it get's set by the build script +let VERSION: String? = nil diff --git a/dots/Sources/dots/dots.swift b/dots/Sources/dots/dots.swift new file mode 100644 index 0000000..df1989d --- /dev/null +++ b/dots/Sources/dots/dots.swift @@ -0,0 +1,13 @@ +import ArgumentParser + +@main +struct Dots: AsyncParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Commands for installing / uninstalling dotfile configuration.", + version: VERSION ?? "0.0.0", + subcommands: [ + Brew.self, + Zsh.self + ] + ) +} diff --git a/dots/Tests/dotsTests/dotsTests.swift b/dots/Tests/dotsTests/dotsTests.swift new file mode 100644 index 0000000..1b26747 --- /dev/null +++ b/dots/Tests/dotsTests/dotsTests.swift @@ -0,0 +1,8 @@ +import XCTest +@testable import dots + +final class dotsTests: XCTestCase { + func testExample() throws { + XCTAssert(true) + } +} diff --git a/dots/scripts/build.swift b/dots/scripts/build.swift new file mode 100755 index 0000000..231e4af --- /dev/null +++ b/dots/scripts/build.swift @@ -0,0 +1,81 @@ +#!/usr/bin/env swift +import Foundation + +try build() + +func build() throws { + try withVersion(in: "Sources/dots/Version.swift", as: currentVersion()) { + try foregroundShell( + "swift", "build", + "--disable-sandbox", + "--configuration", "release", + "-Xswiftc", "-cross-module-optimization" + ) + } +} + +func withVersion(in file: String, as version: String, _ closure: () throws -> ()) throws { + let fileURL = URL(fileURLWithPath: file) + let originalFileContents = try String(contentsOf: fileURL, encoding: .utf8) + // set version + try originalFileContents + .replacingOccurrences(of: "nil", with: "\"\(version)\"") + .write(to: fileURL, atomically: true, encoding: .utf8) + defer { + // undo set version + try! originalFileContents + .write(to: fileURL, atomically: true, encoding: .utf8) + } + // run closure + try closure() +} + +func currentVersion() throws -> String { + do { + let tag = try backgroundShell("git", "describe", "--tags", "--exact-match") + return tag + } catch { + let branch = try backgroundShell("git", "symbolic-ref", "-q", "--short", "HEAD") + let commit = try backgroundShell("git", "rev-parse", "--short", "HEAD") + return "\(branch) (\(commit))" + } +} + +func foregroundShell(_ args: String...) throws { + print("$", args.joined(separator: " ")) + let task = Process() + task.launchPath = "/usr/bin/env" + task.arguments = args + task.launch() + task.waitUntilExit() + + guard task.terminationStatus == 0 else { + throw ShellError(terminationStatus: task.terminationStatus) + } +} + +@discardableResult +func backgroundShell(_ args: String...) throws -> String { + let task = Process() + task.launchPath = "/usr/bin/env" + task.arguments = args + // grab stdout + let output = Pipe() + task.standardOutput = output + // ignore stderr + let error = Pipe() + task.standardError = error + task.launch() + task.waitUntilExit() + + guard task.terminationStatus == 0 else { + throw ShellError(terminationStatus: task.terminationStatus) + } + + return String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) +} + +struct ShellError: Swift.Error { + var terminationStatus: Int32 +} diff --git a/scripts/scripts/dots b/scripts/scripts/dots deleted file mode 100755 index 903912d..0000000 --- a/scripts/scripts/dots +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh - -# Open dotfiles. - -set -e - -test -d "${DOTFILES}" || \ - (echo "Dotfiles path not a directory or doesn't exist" && exit 1) - -cd "${DOTFILES}" && vi . diff --git a/scripts/setup b/scripts/setup index a1ab390..8c5a584 100755 --- a/scripts/setup +++ b/scripts/setup @@ -96,7 +96,7 @@ main() { _parse_options "$@" test "$remove" -eq 0 && _remove_scripts && exit "$?" test "$uninstall" -eq 0 && _remove_scripts && exit "$?" - _install && exit "$?" + _install } main "$@"