This commit is contained in:
2023-03-05 14:37:02 -05:00
parent 5b8b912844
commit 8524efb765
23 changed files with 926 additions and 11 deletions

9
dots/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

18
dots/Makefile Normal file
View File

@@ -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"

75
dots/Package.swift Normal file
View File

@@ -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")
]
),
]
)

3
dots/README.md Normal file
View File

@@ -0,0 +1,3 @@
# dots
A description of this package.

View File

@@ -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 }
}
}

View File

@@ -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 { }

View File

@@ -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")
}
}

View File

@@ -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() }
)
}
}

View File

@@ -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 }
}
}

View File

@@ -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]
}
}

View File

@@ -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 }
}
}

View File

@@ -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 }
}
}

View File

@@ -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
}

View File

@@ -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()
}
}
}

View File

@@ -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 { }

View File

@@ -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()
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,2 @@
// Do not change this value, it get's set by the build script
let VERSION: String? = nil

View File

@@ -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
]
)
}

View File

@@ -0,0 +1,8 @@
import XCTest
@testable import dots
final class dotsTests: XCTestCase {
func testExample() throws {
XCTAssert(true)
}
}

81
dots/scripts/build.swift Executable file
View File

@@ -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
}

View File

@@ -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 .

View File

@@ -96,7 +96,7 @@ main() {
_parse_options "$@" _parse_options "$@"
test "$remove" -eq 0 && _remove_scripts && exit "$?" test "$remove" -eq 0 && _remove_scripts && exit "$?"
test "$uninstall" -eq 0 && _remove_scripts && exit "$?" test "$uninstall" -eq 0 && _remove_scripts && exit "$?"
_install && exit "$?" _install
} }
main "$@" main "$@"