34 Commits

Author SHA1 Message Date
66e286f267 fix: Fixes merge conflicts
Some checks failed
CI / Run Tests (push) Failing after 41s
2024-12-18 08:22:18 -05:00
cb25dba7de feat: Updating just file
All checks were successful
CI / Run Tests (push) Successful in 2m52s
2024-12-18 08:15:26 -05:00
9b99b35436 feat: Uses curl to download toml config to allow for pre-built binaries.
Some checks failed
CI / Run Tests (push) Failing after 2m44s
2024-12-17 19:03:16 -05:00
0c6e9e1228 feat: Bumps version
All checks were successful
CI / Run Tests (push) Successful in 2m19s
2024-12-17 16:02:56 -05:00
faa28749bc feat: Merges dev
All checks were successful
CI / Run Tests (push) Successful in 2m43s
2024-12-17 15:55:36 -05:00
fb246df01a fix: Fixes failing tests since we create backups now during configuration generation.
All checks were successful
CI / Run Tests (push) Successful in 2m43s
CI / Run Tests (pull_request) Successful in 2m22s
2024-12-17 15:44:51 -05:00
0b153d7990 fix: Removes unused release workflow
Some checks failed
CI / Run Tests (push) Failing after 2m52s
CI / Run Tests (pull_request) Failing after 2m15s
2024-12-17 15:32:15 -05:00
558054464c feat: Create backups of configuration when the force option is not used.
Some checks failed
CI / Run Tests (push) Failing after 2m16s
Create and publish a Docker image / build-and-push-image (push) Has been cancelled
2024-12-17 14:38:52 -05:00
54c07886ad feat: Adding documentation comments.
All checks were successful
CI / Run Tests (push) Successful in 2m28s
2024-12-17 13:32:05 -05:00
f8e89ed0fa feat: Adding documentation comments.
All checks were successful
CI / Run Tests (push) Successful in 2m17s
2024-12-17 11:51:03 -05:00
f596975bbc feat: Fix test-docker command to not use TTY for CI tests.
All checks were successful
CI / Run Tests (push) Successful in 2m28s
2024-12-17 10:25:24 -05:00
805100fa43 feat: Adds ci
Some checks failed
CI / Run Tests (push) Failing after 3m47s
2024-12-17 10:17:46 -05:00
f7f168b7fd feat: Begins adding docker containers 2024-12-17 10:15:07 -05:00
99459a0a71 feat: Adds dump-configuration command. 2024-12-16 20:44:30 -05:00
f89efc8c5e feat: Adds file-client tests. 2024-12-16 19:00:42 -05:00
85b285347b feat: Removes cli-client 2024-12-16 17:14:25 -05:00
8c402f3f5f feat: Adds command documentation about piping to other commands. 2024-12-16 12:41:49 -05:00
1429c51821 feat: Adds output values to some of the commands to allow them to be piped into other commands 2024-12-16 12:28:38 -05:00
1302b15ee2 feat: Integrates vault-client into hpa-executable. 2024-12-16 10:57:14 -05:00
da810d0a45 feat: Integrates playbook client into hpa-executable. 2024-12-16 10:29:58 -05:00
35d9422f07 feat: Updates playbook client initialization. 2024-12-15 21:16:11 -05:00
601869d457 feat: Adds vault client 2024-12-15 17:27:28 -05:00
6d0108da0c feat: Adds createProject and createJson tests for playbook-client. 2024-12-15 11:59:50 -05:00
bc0b740f95 feat: Moves playbook run into playbook client, need to move tests. 2024-12-14 23:14:01 -05:00
303cdef84b feat: Preparing to move items out of cli-client module into playbook-client 2024-12-14 10:06:30 -05:00
b5afc77428 feat: Beginning to refactor cli-client 2024-12-13 23:00:40 -05:00
3f56dda568 feat: Adds generate commands that call to pandoc to generate pdf, latex, and html files from a project. 2024-12-13 15:33:20 -05:00
d1b3379815 feat: Adds playbook client 2024-12-13 11:27:43 -05:00
b557a60fa3 feat: Adds build-with-version plugin. 2024-12-13 08:19:36 -05:00
5f4ef3b5b5 feat: Moves playbook into resources of cli-client. 2024-12-12 19:39:52 -05:00
ba1e61d99e feat: Updates 'extraArgs' naming to 'extraOptions' as it's more inline with the usage of them 2024-12-12 18:21:13 -05:00
bd56660683 feat: fixes improper json when creating a project using a repository 2024-12-12 18:01:32 -05:00
a480e942bc feat: fixes install dependencies to use texlive 2024-12-12 17:01:56 -05:00
2b265a4ca5 feat: Adds install-dependencies command. 2024-12-12 13:39:11 -05:00
65 changed files with 3413 additions and 1163 deletions

20
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,20 @@
---
name: CI
on:
push:
branches: ["main", "dev"]
pull_request:
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: https://git.housh.dev/actions/setup-just@v1
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
- name: Run Test
run: just test-docker

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@ DerivedData/
.nvim/*
.swiftpm/*
./hpa.toml
./Version.*
/*.json

View File

@@ -2,6 +2,7 @@ disabled_rules:
- closing_brace
- fuction_body_length
- opening_brace
- nesting
included:
- Sources

View File

@@ -18,6 +18,13 @@
"identifier" : "CliClientTests",
"name" : "CliClientTests"
}
},
{
"target" : {
"containerPath" : "container:",
"identifier" : "ConfigurationClientTests",
"name" : "ConfigurationClientTests"
}
}
],
"version" : 1

View File

@@ -1,5 +1,5 @@
{
"originHash" : "57500b96c3cc5835f23b47b8fdf0a0f69711487d67b69c5ab659837d02308a93",
"originHash" : "bc31b11e5e7d488e0a9c1bf91cb572d29f782bfd8e43f44157036f8f3d282893",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -37,6 +37,15 @@
"version" : "0.2.0"
}
},
{
"identity" : "swift-cli-version",
"kind" : "remoteSourceControl",
"location" : "https://github.com/m-housh/swift-cli-version.git",
"state" : {
"revision" : "1885a90f622c91ea9bf7a9b3df82831dece8eb7d",
"version" : "0.1.1"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
@@ -55,6 +64,15 @@
"version" : "1.3.0"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump.git",
"state" : {
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
"version" : "1.3.3"
}
},
{
"identity" : "swift-dependencies",
"kind" : "remoteSourceControl",

View File

@@ -7,48 +7,39 @@ let package = Package(
platforms: [.macOS(.v14)],
products: [
.executable(name: "hpa", targets: ["hpa"]),
.library(name: "CliClient", targets: ["CliClient"]),
.library(name: "CodersClient", targets: ["CodersClient"]),
.library(name: "CommandClient", targets: ["CommandClient"]),
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"]),
.library(name: "FileClient", targets: ["FileClient"])
.library(name: "FileClient", targets: ["FileClient"]),
.library(name: "PlaybookClient", targets: ["PlaybookClient"]),
.library(name: "PandocClient", targets: ["PandocClient"]),
.library(name: "VaultClient", targets: ["VaultClient"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
.package(url: "https://github.com/pointfreeco/swift-custom-dump.git", from: "1.3.3"),
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.5.2"),
.package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.1.0"),
.package(url: "https://git.housh.dev/michael/swift-cli-doc.git", from: "0.2.0"),
.package(url: "https://github.com/m-housh/swift-cli-version.git", from: "0.1.0"),
.package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.5.0")
],
targets: [
.executableTarget(
name: "hpa",
dependencies: [
"CliClient",
"ConfigurationClient",
"FileClient",
"PandocClient",
"PlaybookClient",
"VaultClient",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "CliDoc", package: "swift-cli-doc"),
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.target(
name: "CliClient",
dependencies: [
"CodersClient",
"ConfigurationClient",
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.testTarget(
name: "CliClientTests",
dependencies: [
"CliClient",
"TestSupport"
]
),
.target(
name: "CodersClient",
dependencies: [
@@ -57,9 +48,20 @@ let package = Package(
.product(name: "TOMLKit", package: "TOMLKit")
]
),
.target(
name: "CommandClient",
dependencies: [
"Constants",
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.target(name: "Constants"),
.target(
name: "TestSupport",
dependencies: [
"CommandClient",
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
]
@@ -68,6 +70,7 @@ let package = Package(
name: "ConfigurationClient",
dependencies: [
"CodersClient",
"CommandClient",
"FileClient",
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
@@ -90,6 +93,58 @@ let package = Package(
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies")
]
),
.testTarget(
name: "FileClientTests",
dependencies: ["FileClient", "TestSupport"]
),
.target(
name: "PandocClient",
dependencies: [
"CommandClient",
"ConfigurationClient",
"PlaybookClient",
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies")
]
),
.testTarget(
name: "PandocClientTests",
dependencies: ["PandocClient", "TestSupport"]
),
.target(
name: "PlaybookClient",
dependencies: [
"CommandClient",
"CodersClient",
"ConfigurationClient",
"FileClient",
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.testTarget(
name: "PlaybookClientTests",
dependencies: [
"PlaybookClient",
"TestSupport"
]
),
.target(
name: "VaultClient",
dependencies: [
"CommandClient",
"ConfigurationClient",
"FileClient",
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.testTarget(
name: "VaultClientTests",
dependencies: ["VaultClient", "TestSupport"]
)
]
)

View File

@@ -1,175 +0,0 @@
import ConfigurationClient
import Dependencies
import DependenciesMacros
import FileClient
import Foundation
import ShellClient
public extension CliClient {
func runCommand(
quiet: Bool,
shell: ShellCommand.Shell,
_ args: [String]
) async throws {
try await runCommand(.init(arguments: args, quiet: quiet, shell: shell))
}
func runCommand(
quiet: Bool,
shell: ShellCommand.Shell,
_ args: String...
) async throws {
try await runCommand(quiet: quiet, shell: shell, args)
}
func runPlaybookCommand(
_ options: PlaybookOptions,
logging loggingOptions: LoggingOptions
) async throws {
try await withLogger(loggingOptions) {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.logger) var logger
let configuration = try await configurationClient.ensuredConfiguration(options.configuration)
logger.trace("Configuration: \(configuration)")
let playbookDirectory = try configuration.ensuredPlaybookDirectory(options.playbookDirectory)
let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)"
logger.trace("Playbook path: \(playbookPath)")
let inventoryPath = ensuredInventoryPath(
options.inventoryFilePath,
configuration: configuration,
playbookDirectory: playbookDirectory
)
logger.trace("Inventory path: \(inventoryPath)")
var arguments = [
Constants.playbookCommand, playbookPath,
"--inventory", inventoryPath
] + options.arguments
if let defaultArgs = configuration.args {
arguments.append(contentsOf: defaultArgs)
}
if configuration.useVaultArgs, let vaultArgs = configuration.vault.args {
arguments.append(contentsOf: vaultArgs)
}
logger.trace("Running playbook command with arguments: \(arguments)")
try await runCommand(
quiet: options.quiet,
shell: options.shell.orDefault,
arguments
)
}
}
func runVaultCommand(
_ options: VaultOptions,
logging loggingOptions: LoggingOptions
) async throws {
try await withLogger(loggingOptions) {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
let configuration = try await configurationClient.ensuredConfiguration(options.configuration)
logger.trace("Configuration: \(configuration)")
let vaultFilePath = try await fileClient.ensuredVaultFilePath(options.vaultFilePath)
logger.trace("Vault file: \(vaultFilePath)")
var arguments = [
Constants.vaultCommand
] + options.arguments
if let defaultArgs = configuration.vault.args {
arguments.append(contentsOf: defaultArgs)
}
if arguments.contains("encrypt"),
!arguments.contains("--encrypt-vault-id"),
let id = configuration.vault.encryptId
{
arguments.append(contentsOf: ["--encrypt-vault-id", id])
}
arguments.append(vaultFilePath)
logger.trace("Running vault command with arguments: \(arguments)")
try await runCommand(
quiet: options.quiet,
shell: options.shell.orDefault,
arguments
)
}
}
}
@_spi(Internal)
public extension ConfigurationClient {
func ensuredConfiguration(_ optionalConfig: Configuration?) async throws -> Configuration {
guard let config = optionalConfig else {
return try await findAndLoad()
}
return config
}
}
@_spi(Internal)
public extension Configuration {
func ensuredPlaybookDirectory(_ optionalDirectory: String?) throws -> String {
guard let directory = optionalDirectory else {
guard let directory = playbook?.directory else {
throw CliClientError.playbookDirectoryNotFound
}
return directory
}
return directory
}
}
@_spi(Internal)
public extension Optional where Wrapped == String {
var orDefault: ShellCommand.Shell {
guard let shell = self else { return .zsh(useDashC: true) }
return .custom(path: shell, useDashC: true)
}
}
@_spi(Internal)
public func ensuredInventoryPath(
_ optionalInventoryPath: String?,
configuration: Configuration,
playbookDirectory: String
) -> String {
guard let path = optionalInventoryPath else {
guard let path = configuration.playbook?.inventory else {
return "\(playbookDirectory)/\(Constants.inventoryFileName)"
}
return path
}
return path
}
@_spi(Internal)
public extension FileClient {
func ensuredVaultFilePath(_ optionalPath: String?) async throws -> String {
guard let path = optionalPath else {
guard let url = try await findVaultFileInCurrentDirectory() else {
throw CliClientError.vaultFileNotFound
}
return url.cleanFilePath
}
return path
}
}
extension ShellCommand.Shell: @retroactive @unchecked Sendable {}

View File

@@ -1,7 +0,0 @@
enum Constants {
static let executableName = "hpa"
static let playbookCommand = "ansible-playbook"
static let playbookFileName = "main.yml"
static let inventoryFileName = "inventory.ini"
static let vaultCommand = "ansible-vault"
}

View File

@@ -1,80 +0,0 @@
import ConfigurationClient
import Dependencies
import Foundation
// NOTE: We're not using the `Coders` client because we generally do not
// want the output to be `prettyPrinted` or anything, unless we're running
// tests, so we use a supplied json encoder.
func createJSONData(
_ options: CliClient.GenerateJsonOptions,
logging loggingOptions: CliClient.LoggingOptions,
encoder: JSONEncoder = .init()
) async throws -> Data {
try await CliClient.withLogger(loggingOptions) {
@Dependency(\.logger) var logger
@Dependency(\.configurationClient) var configurationClient
let configuration = try await configurationClient.findAndLoad()
let templateDir = options.templateDirectory ?? configuration.template.directory
let templateRepo = options.templateRepo ?? configuration.template.url
let version = options.version ?? configuration.template.version
logger.debug("""
(\(options.useLocalTemplateDirectory), \(String(describing: templateDir)), \(String(describing: templateRepo)))
""")
switch (options.useLocalTemplateDirectory, templateDir, templateRepo) {
case (true, .none, _):
// User supplied they wanted to use a local template directory, but we could not find
// the path set from command line or in configuration.
throw CliClientError.templateDirectoryNotFound
case let (false, _, .some(repo)):
// User did not supply they wanted to use a local template directory, and we found a repo url that was
// either set by the command line or found in the configuration.
logger.debug("Using repo.")
return try encoder.encode(TemplateRepo(repo: repo, version: version))
case let (true, .some(templateDir), _):
// User supplied they wanted to use a local template directory, and we found the template directory
// either set by the command line or in the configuration.
logger.debug("Using template directory.")
return try encoder.encode(TemplateDirJson(path: templateDir))
case let (false, .some(templateDir), _):
// User supplied they did not wanted to use a local template directory, and we found the template directory
// either set by the command line or in the configuration, and no repo was found / handled previously.
logger.debug("Using template directory.")
return try encoder.encode(TemplateDirJson(path: templateDir))
case (_, .none, .none):
// We could not find a repo or template directory.
throw CliClientError.templateDirectoryOrRepoNotSpecified
}
}
}
private struct TemplateDirJson: Encodable {
let template: Template
init(path: String) {
self.template = .init(path: path)
}
struct Template: Encodable {
let path: String
}
}
private struct TemplateRepo: Encodable {
let template: Template
init(repo: String, version: String?) {
self.template = .init(repo: repo, version: version ?? "main")
}
struct Template: Encodable {
let repo: String
let version: String
}
}

View File

@@ -1,185 +0,0 @@
import ConfigurationClient
import Dependencies
import DependenciesMacros
import Foundation
import ShellClient
public extension DependencyValues {
var cliClient: CliClient {
get { self[CliClient.self] }
set { self[CliClient.self] = newValue }
}
}
@DependencyClient
public struct CliClient: Sendable {
public var runCommand: @Sendable (RunCommandOptions) async throws -> Void
public var generateJSON: @Sendable (GenerateJsonOptions, LoggingOptions, JSONEncoder) async throws -> String
public func generateJSON(
_ options: GenerateJsonOptions,
logging loggingOptions: LoggingOptions,
encoder jsonEncoder: JSONEncoder = .init()
) async throws -> String {
try await generateJSON(options, loggingOptions, jsonEncoder)
}
}
public extension CliClient {
struct GenerateJsonOptions: Equatable, Sendable {
let templateDirectory: String?
let templateRepo: String?
let version: String?
let useLocalTemplateDirectory: Bool
public init(
templateDirectory: String?,
templateRepo: String?,
version: String?,
useLocalTemplateDirectory: Bool
) {
self.templateDirectory = templateDirectory
self.templateRepo = templateRepo
self.version = version
self.useLocalTemplateDirectory = useLocalTemplateDirectory
}
}
struct LoggingOptions: Equatable, Sendable {
let commandName: String
let logLevel: Logger.Level
public init(commandName: String, logLevel: Logger.Level) {
self.commandName = commandName
self.logLevel = logLevel
}
}
struct PlaybookOptions: Sendable, Equatable {
let arguments: [String]
let configuration: Configuration?
let inventoryFilePath: String?
let playbookDirectory: String?
let quiet: Bool
let shell: String?
public init(
arguments: [String],
configuration: Configuration? = nil,
inventoryFilePath: String? = nil,
playbookDirectory: String? = nil,
quiet: Bool,
shell: String? = nil
) {
self.arguments = arguments
self.configuration = configuration
self.inventoryFilePath = inventoryFilePath
self.playbookDirectory = playbookDirectory
self.quiet = quiet
self.shell = shell
}
}
struct RunCommandOptions: Sendable, Equatable {
public let arguments: [String]
public let quiet: Bool
public let shell: ShellCommand.Shell
public init(
arguments: [String],
quiet: Bool,
shell: ShellCommand.Shell
) {
self.arguments = arguments
self.quiet = quiet
self.shell = shell
}
}
struct VaultOptions: Equatable, Sendable {
let arguments: [String]
let configuration: Configuration?
let quiet: Bool
let shell: String?
let vaultFilePath: String?
public init(
arguments: [String],
configuration: Configuration? = nil,
quiet: Bool,
shell: String?,
vaultFilePath: String? = nil
) {
self.arguments = arguments
self.configuration = configuration
self.quiet = quiet
self.shell = shell
self.vaultFilePath = vaultFilePath
}
}
}
extension CliClient: DependencyKey {
public static func live(
env: [String: String]
) -> Self {
@Dependency(\.logger) var logger
return .init { options in
@Dependency(\.asyncShellClient) var shellClient
if !options.quiet {
try await shellClient.foreground(.init(
shell: options.shell,
environment: ProcessInfo.processInfo.environment,
in: nil,
options.arguments
))
} else {
try await shellClient.background(.init(
shell: options.shell,
environment: ProcessInfo.processInfo.environment,
in: nil,
options.arguments
))
}
} generateJSON: { options, loggingOptions, encoder in
let data = try await createJSONData(options, logging: loggingOptions, encoder: encoder)
guard let string = String(data: data, encoding: .utf8) else {
throw CliClientError.encodingError
}
return string
}
}
public static var liveValue: CliClient {
.live(env: ProcessInfo.processInfo.environment)
}
public static let testValue: CliClient = Self()
public static func capturing(_ client: CapturingClient) -> Self {
.init { options in
await client.set(options)
} generateJSON: {
try await Self().generateJSON($0, $1, $2)
}
}
public actor CapturingClient: Sendable {
public private(set) var quiet: Bool?
public private(set) var shell: ShellCommand.Shell?
public private(set) var arguments: [String]?
public init() {}
public func set(
_ options: RunCommandOptions
) {
quiet = options.quiet
shell = options.shell
arguments = options.arguments
}
}
}

View File

@@ -1,28 +0,0 @@
import Dependencies
import Logging
import ShellClient
public extension CliClient {
@discardableResult
func withLogger<T>(
_ options: LoggingOptions,
operation: @Sendable @escaping () async throws -> T
) async rethrows -> T {
try await Self.withLogger(options, operation: operation)
}
@discardableResult
static func withLogger<T>(
_ options: LoggingOptions,
operation: @Sendable @escaping () async throws -> T
) async rethrows -> T {
try await withDependencies {
$0.logger = .init(label: "\(Constants.executableName)")
$0.logger.logLevel = options.logLevel
$0.logger[metadataKey: "command"] = "\(options.commandName.blue)"
} operation: {
try await operation()
}
}
}

View File

@@ -4,6 +4,8 @@ import Foundation
import TOMLKit
public extension DependencyValues {
/// Holds onto decoders and encoders for json and toml files.
var coders: Coders {
get { self[Coders.self] }
set { self[Coders.self] = newValue }

View File

@@ -0,0 +1,233 @@
import Constants
import Dependencies
import DependenciesMacros
import Foundation
import ShellClient
public extension DependencyValues {
/// Runs shell commands.
var commandClient: CommandClient {
get { self[CommandClient.self] }
set { self[CommandClient.self] = newValue }
}
}
@DependencyClient
public struct CommandClient: Sendable {
/// Runs a shell command.
public var runCommand: @Sendable (RunCommandOptions) async throws -> Void
/// Runs a shell command, sets up logging, and returns an output value.
///
/// This is useful when you need to give some output value to the caller,
/// generally for the ability of that output to be piped into other commands.
///
@discardableResult
public func run<T>(
logging logginOptions: LoggingOptions,
quiet: Bool = false,
shell: String? = nil,
in workingDirectory: String? = nil,
makeArguments: @Sendable @escaping () async throws -> ([String], T)
) async throws -> T {
try await logginOptions.withLogger {
let (arguments, returnValue) = try await makeArguments()
try await self.run(
quiet: quiet,
shell: shell,
arguments
)
return returnValue
}
}
/// Runs a shell command and sets up logging.
public func run(
logging logginOptions: LoggingOptions,
quiet: Bool = false,
shell: String? = nil,
in workingDirectory: String? = nil,
makeArguments: @Sendable @escaping () async throws -> [String]
) async throws {
try await run(
logging: logginOptions,
quiet: quiet,
shell: shell,
in: workingDirectory,
makeArguments: { try await (makeArguments(), ()) }
)
}
/// Runs a shell command.
public func run(
quiet: Bool = false,
shell: String? = nil,
in workingDirectory: String? = nil,
_ arguments: [String]
) async throws {
try await runCommand(.init(
arguments: arguments,
quiet: quiet,
shell: shell,
workingDirectory: workingDirectory
))
}
/// Runs a shell command.
public func run(
quiet: Bool = false,
shell: String? = nil,
in workingDirectory: String? = nil,
_ arguments: String...
) async throws {
try await run(
quiet: quiet,
shell: shell,
in: workingDirectory,
arguments
)
}
public struct RunCommandOptions: Sendable, Equatable {
public let arguments: [String]
public let quiet: Bool
public let shell: String?
public let workingDirectory: String?
public init(
arguments: [String],
quiet: Bool,
shell: String? = nil,
workingDirectory: String? = nil
) {
self.arguments = arguments
self.quiet = quiet
self.shell = shell
self.workingDirectory = workingDirectory
}
}
}
/// Represents logger setup options used when running commands.
///
/// This set's up metadata tags and keys appropriately for the command being run,
/// which can aid in debugging.
///
public struct LoggingOptions: Equatable, Sendable {
/// The command name / key that the logger is running for.
public let commandName: String
/// The log level.
public let logLevel: Logger.Level
/// Create the logging options.
///
/// - Parameters:
/// - commandName: The command name / key that the logger is running for.
/// - logLevel: The log level.
public init(commandName: String, logLevel: Logger.Level) {
self.commandName = commandName
self.logLevel = logLevel
}
/// Perform an operation with a setup logger.
///
/// - Parameters:
/// - operation: The operation to perform with the setup logger.
@discardableResult
public func withLogger<T>(
operation: @Sendable @escaping () async throws -> T
) async rethrows -> T {
try await withDependencies {
$0.logger = .init(label: "\(Constants.executableName)")
$0.logger.logLevel = logLevel
$0.logger[metadataKey: "command"] = "\(commandName.blue)"
} operation: {
try await operation()
}
}
}
extension CommandClient: DependencyKey {
public static let testValue: CommandClient = Self()
public static func live(
environment: [String: String]
) -> CommandClient {
.init { options in
@Dependency(\.asyncShellClient) var shellClient
if !options.quiet {
try await shellClient.foreground(.init(
shell: .init(options.shell),
environment: environment,
in: options.workingDirectory,
options.arguments
))
} else {
try await shellClient.background(.init(
shell: .init(options.shell),
environment: environment,
in: options.workingDirectory,
options.arguments
))
}
}
}
public static var liveValue: CommandClient {
.live(environment: ProcessInfo.processInfo.environment)
}
}
@_spi(Internal)
public extension CommandClient {
/// Create a command client that can capture the arguments / options.
///
/// This is used for testing.
static func capturing(_ client: CapturingClient) -> Self {
.init { options in
await client.set(options)
}
}
/// Captures the arguments / options passed into the command client's run commands.
///
@dynamicMemberLookup
actor CapturingClient: Sendable {
public private(set) var options: RunCommandOptions?
public init() {}
public func set(
_ options: RunCommandOptions
) {
self.options = options
}
public subscript<T>(dynamicMember keyPath: KeyPath<RunCommandOptions, T>) -> T? {
options?[keyPath: keyPath]
}
}
}
extension ShellCommand.Shell: @retroactive @unchecked Sendable {}
extension ShellCommand.Shell {
init(_ path: String?) {
if let path {
self = .custom(path: path, useDashC: true)
} else {
#if os(macOS)
self = .zsh(useDashC: true)
#else
// Generally we're in a docker container when this occurs, which does not have `zsh` installed.
self = .sh(useDashC: true)
#endif
}
}
}

View File

@@ -1,22 +1,42 @@
import Foundation
/// Represents configurable settings for the command line tool.
// NOTE: When adding items, then the 'hpa.toml' resource file needs to be updated.
/// Represents configurable settings for the application.
public struct Configuration: Codable, Equatable, Sendable {
/// Default arguments / options that can get passed into
/// ansible-playbook commands.
public let args: [String]?
/// Whether to use the vault arguments as defaults that get passed into
/// the ansible-playbook commands.
public let useVaultArgs: Bool
/// Configuration for when generating files from templated directories.
public let generate: Generate?
/// Configuration of the ansible-playbook, these are more for developing the
/// playbook locally and generally not needed by the user.
public let playbook: Playbook?
/// Template configuration options.
public let template: Template
/// Ansible-vault configuration options.
public let vault: Vault
public init(
args: [String]? = nil,
useVaultArgs: Bool = true,
generate: Generate? = nil,
playbook: Playbook? = nil,
template: Template = .init(),
vault: Vault = .init()
) {
self.args = args
self.useVaultArgs = useVaultArgs
self.generate = generate
self.playbook = playbook
self.template = template
self.vault = vault
@@ -32,24 +52,118 @@ public struct Configuration: Codable, Equatable, Sendable {
)
}
/// Configuration options for running `pandoc` commands that generate files from
/// a templated directory.
///
/// ## NOTE: Most of these can also be set by the template repository variables.
///
///
public struct Generate: Codable, Equatable, Sendable {
/// Specifiy the name of the build directory, generally `.build`.
public let buildDirectory: String?
/// Specifiy the files used in the `pandoc` command to generate a final output file.
///
/// - SeeAlso: `pandoc --help`
public let files: [String]?
/// Specifiy the files used in the `pandoc` command to include in the header.
///
/// - SeeAlso: `pandoc --help`
public let includeInHeader: [String]?
/// The default name of the output file, this does not require the file extension as we will
/// add it based on the command that is called. Generally this is 'Report'.
///
public let outputFileName: String?
/// The default pdf engine to use when generating pdf files using `pandoc`. Generally
/// this is 'xelatex'.
public let pdfEngine: String?
public init(
buildDirectory: String? = nil,
files: [String]? = nil,
includeInHeader: [String]? = nil,
outputFileName: String? = nil,
pdfEngine: String? = nil
) {
self.buildDirectory = buildDirectory
self.files = files
self.includeInHeader = includeInHeader
self.outputFileName = outputFileName
self.pdfEngine = pdfEngine
}
/// Represents the default configuration for generating files using `pandoc`.
public static let `default` = Self.mock
/// Represents mock configuration for generating files using `pandoc`.
public static var mock: Self {
.init(
buildDirectory: ".build",
files: ["Report.md", "Definitions.md"],
includeInHeader: ["head.tex", "footer.tex"],
outputFileName: "Report",
pdfEngine: "xelatex"
)
}
}
/// Configuration options for the ansible-hpa-playbook. The playbook is
/// the primary driver of templating files, generating repository templates, and building
/// projects.
///
/// ## NOTE: These are generally only used for local development of the playbook.
///
///
public struct Playbook: Codable, Equatable, Sendable {
/// The directory location of the ansible playbook.
public let directory: String?
/// The inventory file name / location.
public let inventory: String?
/// The playbook version, branch, or tag.
public let version: String?
public init(
directory: String? = nil,
inventory: String? = nil
inventory: String? = nil,
version: String? = nil
) {
self.directory = directory
self.inventory = inventory
self.version = version
}
public static var mock: Self { .init() }
}
/// Configuration settings for the user's template repository or directory.
///
/// A template is what is used to create projects for a user. Generally they setup
/// their own template by customizing the default template to their needs. This template
/// can then be stored as a git repository or on the local file system.
///
/// The template will hold variables and files that are used to generate the final output
/// files using `pandoc`. Generally the template will hold files that may only need setup once,
/// such as header files, footer files, and definitions.
///
/// The project directory contains dynamic variables / files that need edited in order
/// to generate the final output. This allows the project directory to remain smaller so the user
/// can more easily focus on the tasks required to generate the final output files.
public struct Template: Codable, Equatable, Sendable {
/// A url of the template when it is a remote git repository.
public let url: String?
/// The version, branch, or tag of the remote repository.
public let version: String?
/// The local directory that contains the template on the local file system.
public let directory: String?
public init(
@@ -71,8 +185,23 @@ public struct Configuration: Codable, Equatable, Sendable {
}
}
/// Configuration for `ansible-vault` commands. Ansible vault is used to encrypt and
/// decrypt sensitive data. This allows for variables, such as customer name and address
/// to be stored along with the project in an encrypted file so that it is safer to store them.
///
/// These may also be used in general `ansible-playbook` commands if the `useVaultArgs` is set
/// to `true` in a users configuration.
///
public struct Vault: Codable, Equatable, Sendable {
/// A list of arguments / options that get passed to the `ansible-vault` command.
///
/// - SeeAlso: `ansible-vault --help`
public let args: [String]?
/// An id that is used during encrypting `ansible-vault` files.
///
/// - SeeAlso: `ansible-vault encrypt --help`
public let encryptId: String?
public init(

View File

@@ -1,4 +1,5 @@
import CodersClient
import CommandClient
import Dependencies
import DependenciesMacros
import FileClient
@@ -6,37 +7,119 @@ import Foundation
import ShellClient
public extension DependencyValues {
/// Interacts with the user's configuration.
var configurationClient: ConfigurationClient {
get { self[ConfigurationClient.self] }
set { self[ConfigurationClient.self] = newValue }
}
}
/// Represents actions that can be taken on user's configuration files.
///
///
@DependencyClient
public struct ConfigurationClient: Sendable {
public var find: @Sendable () async throws -> File
var generate: @Sendable (File, Bool) async throws -> Void
public var load: @Sendable (File?) async throws -> Configuration
var write: @Sendable (File, Configuration, Bool) async throws -> Void
/// Find the user's configuration, searches in the current directory and default
/// locations where configuration can be stored. An error is thrown if no configuration
/// is found.
public var find: @Sendable () async throws -> File
/// Generate a configuration file for the user.
public var generate: @Sendable (GenerateOptions) async throws -> String
/// Load a configuration file from the given file location. If the file is
/// not provided then we return an empty configuraion item.
public var load: @Sendable (File?) async throws -> Configuration
/// Write the configuration to the given file, optionally forcing an overwrite of
/// the file.
///
/// ## NOTE: This uses the `fileClient.write` under the hood, so if you need to control
/// what happens during tests, then you can customize the behavior of the fileClient.
///
public var write: @Sendable (WriteOptions) async throws -> Void
/// Find the user's configuration and load it.
public func findAndLoad() async throws -> Configuration {
let file = try? await find()
return try await load(file)
}
public func generate(
at file: File,
force: Bool = false
) async throws {
try await generate(file, force)
}
/// Write the configuration to the given file, optionally forcing an overwrite of
/// the file. If a file already exists and force is not supplied then we will create
/// a backup of the existing file.
///
/// ## NOTE: This uses the `fileClient.write` under the hood, so if you need to control
/// what happens during tests, then you can customize the behavior of the fileClient.
///
/// - Parameters:
/// - configuration: The configuration to save.
/// - file: The file location and type to save.
/// - force: Force overwritting if a file already exists.
public func write(
_ configuration: Configuration,
to file: File,
force: Bool = false
) async throws {
try await write(file, configuration, force)
try await write(.init(configuration, to: file, force: force))
}
/// Represents the options to generate a configuration file for a user.
public struct GenerateOptions: Equatable, Sendable {
/// Force generation, overwritting existing file if it exists.
public let force: Bool
/// Generate a `json` file instead of the default `toml` file.
public let json: Bool
/// The path to generate a file in.
public let path: Path?
public init(
force: Bool = false,
json: Bool = false,
path: Path? = nil
) {
self.force = force
self.json = json
self.path = path
}
/// Represents the path option for generating a configuration file for a user.
///
/// This can either be a full file path or a directory. If a directory is supplied
/// then we will use the default file name of 'config' and add the extension dependning
/// on if the caller wants a `json` or `toml` file.
public enum Path: Equatable, Sendable {
case file(File)
case directory(String)
}
}
/// Represents the options required to write a configuration file.
public struct WriteOptions: Equatable, Sendable {
/// The configuration to wrtie to the file path.
public let configuration: Configuration
/// The file path to write the configuration to.
public let file: File
/// Force overwritting an existing file, if it exists.
public let force: Bool
public init(
_ configuration: Configuration,
to file: File,
force: Bool
) {
self.configuration = configuration
self.file = file
self.force = force
}
}
}
@@ -45,20 +128,27 @@ extension ConfigurationClient: DependencyKey {
public static func live(environment: [String: String]) -> Self {
let liveClient = LiveConfigurationClient(environment: environment)
return .init {
try await liveClient.find()
} generate: { file, force in
try await liveClient.generate(at: file, force: force)
} load: { file in
try await liveClient.load(file: file)
} write: { file, configuration, force in
try await liveClient.write(configuration, to: file, force: force)
}
return .init(
find: { try await liveClient.find() },
generate: { try await liveClient.generate($0) },
load: { try await liveClient.load(file: $0) },
write: { try await liveClient.write($0) }
)
}
public static var liveValue: Self {
.live(environment: ProcessInfo.processInfo.environment)
}
@_spi(Internal)
public static func mock(_ configuration: Configuration = .mock) -> Self {
.init(
find: { throw MockFindError() },
generate: Self().generate,
load: { _ in configuration },
write: Self().write
)
}
}
struct LiveConfigurationClient {
@@ -66,6 +156,7 @@ struct LiveConfigurationClient {
private let environment: [String: String]
@Dependency(\.coders) var coders
@Dependency(\.commandClient) var commandClient
@Dependency(\.fileClient) var fileManager
@Dependency(\.logger) var logger
@@ -116,10 +207,24 @@ struct LiveConfigurationClient {
throw ConfigurationError.configurationNotFound
}
func generate(at file: File, force: Bool) async throws {
func generate(_ options: ConfigurationClient.GenerateOptions) async throws -> String {
@Dependency(\.logger) var logger
logger.debug("Begin generating configuration: \(file.path), force: \(force)")
let file: File
if let path = options.path {
switch path {
case let .file(requestedFile):
file = requestedFile
case let .directory(directory):
file = .init("\(directory)/\(HPAKey.defaultFileNameWithoutExtension).\(options.json ? "json" : "toml")")!
}
} else {
let configDir = "\(environment.xdgConfigHome)/\(HPAKey.configDirName)"
file = .init("\(configDir)/\(HPAKey.defaultFileName)")!
}
logger.debug("Begin generating configuration: \(file.path), force: \(options.force)")
let expandedPath = file.path.replacingOccurrences(
of: "~",
@@ -128,10 +233,10 @@ struct LiveConfigurationClient {
let fileUrl = URL(filePath: expandedPath)
if !force {
guard !fileManager.fileExists(fileUrl) else {
throw ConfigurationError.fileExists(path: file.path)
}
let exists = fileManager.fileExists(fileUrl)
if !options.force, exists {
try await createBackup(file.url)
}
let fileDirectory = fileUrl.deletingLastPathComponent()
@@ -143,21 +248,19 @@ struct LiveConfigurationClient {
}
if case .toml = file {
// In the case of toml, we copy the internal resource that includes
// usage comments in the file.
guard let resourceFile = Bundle.module.url(
forResource: HPAKey.resourceFileName,
withExtension: HPAKey.resourceFileExtension
) else {
throw ConfigurationError.resourceNotFound
}
try await fileManager.copy(resourceFile, fileUrl)
// Copy the file using curl, because when installed as a pre-built binary we
// don't have access to bundled resources.
try await commandClient.run(
quiet: true,
["curl", HPAKey.tomlConfigUrl, "--output", fileUrl.path]
)
} else {
// Json does not allow comments, so we write the mock configuration
// to the file path.
try await write(.mock, to: File(fileUrl)!, force: force)
try await write(.init(.mock, to: File(fileUrl)!, force: options.force))
}
return fileUrl.cleanFilePath
}
func load(file: File?) async throws -> Configuration {
@@ -176,14 +279,16 @@ struct LiveConfigurationClient {
}
func write(
_ configuration: Configuration,
to file: File,
force: Bool
_ options: ConfigurationClient.WriteOptions
) async throws {
if !force {
guard !fileManager.fileExists(file.url) else {
throw ConfigurationError.fileExists(path: file.path)
}
let configuration = options.configuration
let file = options.file
let force = options.force
let exists = fileManager.fileExists(file.url)
if !force, exists {
try await createBackup(file.url)
}
let data: Data
@@ -199,6 +304,13 @@ struct LiveConfigurationClient {
try await fileManager.write(data, file.url)
}
private func createBackup(_ url: URL) async throws {
let backupUrl = url.appendingPathExtension("back")
logger.warning("File exists, creating a backup of the existing file at: \(backupUrl.cleanFilePath)")
try await fileManager.copy(url, backupUrl)
try await fileManager.delete(url)
}
private func findInDirectory(_ directory: URL) async -> File? {
for file in validFileNames {
let url = directory.appending(path: file)
@@ -229,3 +341,5 @@ enum ConfigurationError: Error {
case decodingError
case fileExists(path: String)
}
struct MockFindError: Error {}

View File

@@ -1,3 +1,5 @@
/// Represents keys in the environment that can be used to locate a user's
/// configuration file.
@_spi(Internal)
public enum EnvironmentKey {
static let xdgConfigHome = "XDG_CONFIG_HOME"
@@ -5,12 +7,15 @@ public enum EnvironmentKey {
static let hpaConfigFile = "HPA_CONFIG_FILE"
}
/// Represents keys that are used internally for directory names, file names, etc.
@_spi(Internal)
public enum HPAKey {
public static let configDirName = "hpa"
public static let resourceFileName = "hpa"
public static let resourceFileExtension = "toml"
public static let defaultFileName = "config.toml"
public static let defaultFileNameWithoutExtension = "config"
public static let tomlConfigUrl = "https://git.housh.dev/michael/swift-hpa/raw/branch/main/Sources/ConfigurationClient/Resources/hpa.toml"
}
extension [String: String] {

View File

@@ -1,11 +1,22 @@
import FileClient
import Foundation
/// Represents a file location and type on disk for a configuration file.
///
/// Currently the supported formats are `json` or `toml`. Toml is the default /
/// preferred format because it allows comments in the file and is easy to understand.
///
public enum File: Equatable, Sendable {
case json(URL)
case toml(URL)
/// Attempts to create a file with the given url.
///
/// ## NOTE: There are no checks on if a file / path actually exists or not.
///
/// If the file does not have a suffix of `json` or `toml` then
/// we will return `nil`.
public init?(_ url: URL) {
if url.cleanFilePath.hasSuffix("json") {
self = .json(url)
@@ -16,18 +27,17 @@ public enum File: Equatable, Sendable {
}
}
/// Attempts to create a file with the given path.
///
/// ## NOTE: There are no checks on if a file / path actually exists or not.
///
/// If the file does not have a suffix of `json` or `toml` then
/// we will return `nil`.
public init?(_ path: String) {
self.init(URL(filePath: path))
}
public static func json(_ path: String) -> Self {
.json(URL(filePath: path))
}
public static func toml(_ path: String) -> Self {
.toml(URL(filePath: path))
}
/// Get the url of the file.
public var url: URL {
switch self {
case let .json(url): return url
@@ -35,10 +45,14 @@ public enum File: Equatable, Sendable {
}
}
/// Get the path string of the file.
public var path: String {
url.cleanFilePath
}
/// Represents the default file path for a user's configuration.
///
/// Which is `~/.config/hpa/config.toml`
public static var `default`: Self {
let fileUrl = FileManager.default
.homeDirectoryForCurrentUser
@@ -49,11 +63,3 @@ public enum File: Equatable, Sendable {
return .toml(fileUrl)
}
}
@_spi(Internal)
public extension URL {
var cleanFilePath: String {
absoluteString.replacing("file://", with: "")
}
}

View File

@@ -1,13 +1,42 @@
# NOTE:
# Configuration settings for the `hpa` command line tool.
#
# Delete settings that are not applicable to your use case.
# You can delete settings that are not applicable to your use case.
# Default arguments / options that get passed into `ansible-playbook` commands.
# WARNING: Do not put arguments / options that contain spaces in the same string,
# they should be separate strings, for example do not do something like
# ['--tags debug'], instead use ['--tags', 'debug'].
#
args = []
# Set to true if you want to pass the vault args to `ansible-playbook` commands.
useVaultArgs = true
# NOTE:
# Configuration for running the generate command(s). This allows custimizations
# to the files that get used to generate the final output (generally a pdf).
# See `pandoc --help`. Below are the defaults that get used, which only need
# adjusted if your template does not follow the default template design or if
# you add extra files to your template that need to be included in the final
# output. Also be aware that any of the files specified in the `files` or
# `includeInHeader` options, need to be inside the `buildDirectory` when generating
# the final output file.
# [generate]
# this relative to the project directory.
# buildDirectory = '.build'
# pdfEngine = 'xelatex'
# includeInHeader = [
# 'head.tex',
# 'footer.tex'
# ]
# files = [
# 'Report.md',
# 'Definitions.md'
# ]
# outputFileName = 'Report'
# NOTE:
# These are more for local development of the ansible playbook and should not be needed
# in most cases. Uncomment the lines if you want to customize the playbook and use it
# instead of the provided / default playbook.
@@ -15,7 +44,9 @@ useVaultArgs = true
#[playbook]
#directory = '/path/to/local/playbook-directory'
#inventory = '/path/to/local/inventory.ini'
#version = 'main'
# NOTE:
# These are to declare where your template files are either on your local system or
# a remote git repository.
[template]
@@ -30,6 +61,7 @@ url = 'https://git.example.com/consult-template.git'
# branch.
version = '1.0.0'
# NOTE:
# Holds settings for `ansible-vault` commands.
[vault]
# Arguments to pass to commands that use `ansible-vault`, such as encrypting or decrypting

View File

@@ -0,0 +1,3 @@
public enum Constants {
public static let executableName = "hpa"
}

View File

@@ -3,22 +3,53 @@ import DependenciesMacros
import Foundation
public extension DependencyValues {
/// Represents interactions with the file system.
///
var fileClient: FileClient {
get { self[FileClient.self] }
set { self[FileClient.self] = newValue }
}
}
/// Represents interactions with the file system.
///
///
@DependencyClient
public struct FileClient: Sendable {
/// Copy an item from one location to another.
public var copy: @Sendable (URL, URL) async throws -> Void
/// Create a directory at the given location.
public var createDirectory: @Sendable (URL) async throws -> Void
/// Delete the item at the given location.
public var delete: @Sendable (URL) async throws -> Void
/// Check if a file exists at the given location.
public var fileExists: @Sendable (URL) -> Bool = { _ in true }
public var findVaultFileInCurrentDirectory: @Sendable () async throws -> URL?
/// Find an ansible-vault file in the given location, checking up to 1 level deep
/// in subfolders.
public var findVaultFile: @Sendable (URL) async throws -> URL?
/// Return the user's home directory.
public var homeDirectory: @Sendable () -> URL = { URL(filePath: "~/") }
/// Check if an item is a directory or not.
public var isDirectory: @Sendable (URL) async throws -> Bool
/// Load a file from the given location.
public var load: @Sendable (URL) async throws -> Data
/// Write data to a file at the given location.
public var write: @Sendable (Data, URL) async throws -> Void
/// Find an ansible-vault file in the current directory, checking up to 1 level
/// deep in subfolders.
public func findVaultFileInCurrentDirectory() async throws -> URL? {
try await findVaultFile(URL(filePath: "./"))
}
}
extension FileClient: DependencyKey {
@@ -26,23 +57,17 @@ extension FileClient: DependencyKey {
public static var liveValue: Self {
let manager = LiveFileClient()
return .init {
try await manager.copy($0, to: $1)
} createDirectory: {
try await manager.creatDirectory($0)
} fileExists: { url in
manager.fileExists(at: url)
} findVaultFileInCurrentDirectory: {
try await manager.findVaultFileInCurrentDirectory()
} homeDirectory: {
manager.homeDirectory()
} isDirectory: {
manager.isDirectory($0)
} load: { url in
try await manager.load(from: url)
} write: { data, url in
try await manager.write(data, to: url)
}
return .init(
copy: { try await manager.copy($0, to: $1) },
createDirectory: { try await manager.creatDirectory($0) },
delete: { try await manager.delete($0) },
fileExists: { manager.fileExists(at: $0) },
findVaultFile: { try await manager.findVaultFile(in: $0) },
homeDirectory: { manager.homeDirectory() },
isDirectory: { manager.isDirectory($0) },
load: { try await manager.load(from: $0) },
write: { try await manager.write($0, to: $1) }
)
}
}
@@ -58,30 +83,35 @@ struct LiveFileClient: Sendable {
try manager.createDirectory(at: url, withIntermediateDirectories: true)
}
func delete(_ url: URL) async throws {
try manager.removeItem(at: url)
}
func fileExists(at url: URL) -> Bool {
manager.fileExists(atPath: url.cleanFilePath)
}
func findVaultFileInCurrentDirectory() async throws -> URL? {
let urls = try manager.contentsOfDirectory(
at: URL(filePath: "./"),
includingPropertiesForKeys: nil
)
func findVaultFile(in url: URL) async throws -> URL? {
guard isDirectory(url) else { return nil }
let urls = try manager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
// Check the current directory
if let vault = urls.firstVaultFile { return vault }
guard let vault = urls.firstVaultFile else {
// check subfolders, 1 layer deep.
let subfolders = urls.filter { isDirectory($0) }
for folder in subfolders {
let vault = try manager.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: nil
)
.firstVaultFile
let subfolders = urls.filter { isDirectory($0) }
for folder in subfolders {
let files = try manager.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: nil
)
if let vault = files.firstVaultFile { return vault }
if let vault { return vault }
}
// Didn't find a file.
return nil
}
return nil
return vault
}
func homeDirectory() -> URL {
@@ -114,6 +144,8 @@ private extension Array where Element == URL {
public extension URL {
var cleanFilePath: String {
absoluteString.replacing("file://", with: "")
absoluteString
.replacing("file://", with: "")
.replacing("/private", with: "")
}
}

View File

@@ -0,0 +1,9 @@
extension PandocClient {
/// Represents constant string values needed internally.
enum Constants {
static let pandocCommand = "pandoc"
static let defaultOutputFileName = "Report"
static let defaultPdfEngine = "xelatex"
}
}

View File

@@ -0,0 +1,245 @@
import CommandClient
import ConfigurationClient
import Dependencies
import Foundation
import PlaybookClient
extension PandocClient.RunOptions {
func run(
_ fileType: PandocClient.FileType,
_ environment: [String: String]
) async throws -> String {
@Dependency(\.commandClient) var commandClient
@Dependency(\.logger) var logger
@Dependency(\.playbookClient) var playbookClient
return try await commandClient.run(logging: loggingOptions, quiet: quiet, shell: shell) {
let ensuredOptions = try await self.ensuredOptions(fileType)
let projectDirectory = self.projectDirectory ?? environment["PWD"]
guard let projectDirectory else {
throw ProjectDirectoryNotSpecified()
}
if shouldBuildProject {
logger.debug("Building project...")
try await playbookClient.run.buildProject(.init(
projectDirectory: projectDirectory,
shared: .init(
extraOptions: nil,
inventoryFilePath: nil,
loggingOptions: loggingOptions,
quiet: quiet,
shell: shell
)
))
}
let outputDirectory = self.outputDirectory ?? projectDirectory
let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredExtensionFileName)"
let arguments = ensuredOptions.makeArguments(
outputPath: outputPath,
projectDirectory: projectDirectory
)
logger.debug("Pandoc arguments: \(arguments)")
return (arguments, outputPath)
}
}
func ensuredOptions(
_ fileType: PandocClient.FileType
) async throws -> EnsuredPandocOptions {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.logger) var logger
let configuration = try await configurationClient.findAndLoad()
logger.debug("Configuration: \(configuration)")
return try await ensurePandocOptions(
configuration: configuration,
fileType: fileType,
options: self
)
}
}
extension PandocClient.FileType {
var fileExtension: String {
switch self {
case .html: return "html"
case .latex: return "tex"
case .pdf: return "pdf"
}
}
}
@_spi(Internal)
public struct EnsuredPandocOptions: Equatable, Sendable {
public let buildDirectory: String
public let extraOptions: [String]?
public let files: [String]
public let includeInHeader: [String]
public let outputFileName: String
public let outputFileType: PandocClient.FileType
public let pdfEngine: String?
public var ensuredExtensionFileName: String {
let extensionString = ".\(outputFileType.fileExtension)"
if !outputFileName.hasSuffix(extensionString) {
return outputFileName + extensionString
}
return outputFileName
}
func makeArguments(
outputPath: String,
projectDirectory: String
) -> [String] {
var arguments = [PandocClient.Constants.pandocCommand]
arguments += includeInHeader.map {
"--include-in-header=\(projectDirectory)/\(buildDirectory)/\($0)"
}
if let pdfEngine {
arguments.append("--pdf-engine=\(pdfEngine)")
}
arguments.append("--output=\(outputPath)")
if let extraOptions {
arguments.append(contentsOf: extraOptions)
}
arguments += files.map {
"\(projectDirectory)/\(buildDirectory)/\($0)"
}
return arguments
}
}
@_spi(Internal)
public func ensurePandocOptions(
configuration: Configuration,
fileType: PandocClient.FileType,
options: PandocClient.RunOptions
) async throws -> EnsuredPandocOptions {
let defaults = Configuration.Generate.default
return .init(
buildDirectory: options.parseBuildDirectory(configuration.generate, defaults),
extraOptions: options.extraOptions,
files: options.parseFiles(configuration.generate, defaults),
includeInHeader: options.parseIncludeInHeader(configuration.generate, defaults),
outputFileName: options.parseOutputFileName(configuration.generate, defaults),
outputFileType: fileType,
pdfEngine: fileType.parsePdfEngine(configuration.generate, defaults)
)
}
@_spi(Internal)
public extension PandocClient.FileType {
func parsePdfEngine(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate
) -> String? {
switch self {
case .html, .latex:
return nil
case let .pdf(engine: engine):
if let engine {
return engine
} else if let engine = configuration?.pdfEngine {
return engine
} else if let engine = defaults.pdfEngine {
return engine
} else {
return PandocClient.Constants.defaultPdfEngine
}
}
}
}
@_spi(Internal)
public extension PandocClient.RunOptions {
func parseFiles(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate
) -> [String] {
@Dependency(\.logger) var logger
if let files = files {
return files
} else if let files = configuration?.files {
return files
} else if let files = defaults.files {
return files
} else {
logger.warning("Files not specified, this could lead to errors.")
return []
}
}
func parseIncludeInHeader(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate
) -> [String] {
@Dependency(\.logger) var logger
if let files = includeInHeader {
return files
} else if let files = configuration?.includeInHeader {
return files
} else if let files = defaults.includeInHeader {
return files
} else {
logger.warning("Include in header files not specified, this could lead to errors.")
return []
}
}
func parseOutputFileName(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate
) -> String {
@Dependency(\.logger) var logger
if let output = outputFileName {
return output
} else if let output = configuration?.outputFileName {
return output
} else if let output = defaults.outputFileName {
return output
} else {
logger.warning("Output file name not specified, this could lead to errors.")
return PandocClient.Constants.defaultOutputFileName
}
}
func parseBuildDirectory(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate
) -> String {
@Dependency(\.logger) var logger
if let output = buildDirectory {
return output
} else if let output = configuration?.buildDirectory {
return output
} else if let output = defaults.buildDirectory {
return output
} else {
logger.warning("Output file name not specified, this could lead to errors.")
return ".build"
}
}
}
struct ProjectDirectoryNotSpecified: Error {}

View File

@@ -0,0 +1,141 @@
import CommandClient
import ConfigurationClient
import Dependencies
import DependenciesMacros
import Foundation
public extension DependencyValues {
/// Represents interactions with the `pandoc` command line application.
///
/// The `pandoc` command line application is used to generate the final output
/// documents from a home performance assessment project.
///
var pandocClient: PandocClient {
get { self[PandocClient.self] }
set { self[PandocClient.self] = newValue }
}
}
@DependencyClient
public struct PandocClient: Sendable {
/// Run a pandoc command.
public var run: Run
/// Represents the pandoc commands that we can run.
@DependencyClient
public struct Run: Sendable {
/// Generate a latex file from the given options, returning the path the
/// generated file was written to.
public var generateLatex: @Sendable (RunOptions) async throws -> String
/// Generate an html file from the given options, returning the path the
/// generated file was written to.
public var generateHtml: @Sendable (RunOptions) async throws -> String
/// Generate a pdf file from the given options, returning the path the
/// generated file was written to.
public var generatePdf: @Sendable (RunOptions, String?) async throws -> String
/// Generate a pdf file from the given options, returning the path the
/// generated file was written to.
///
/// - Parameters:
/// - options: The shared run options.
/// - pdfEngine: The pdf-engine to use to generate the pdf file.
public func generatePdf(
_ options: RunOptions,
pdfEngine: String? = nil
) async throws -> String {
try await generatePdf(options, pdfEngine)
}
}
/// Represents the shared options used to run a `pandoc` command.
///
///
public struct RunOptions: Equatable, Sendable {
let buildDirectory: String?
let extraOptions: [String]?
let files: [String]?
let loggingOptions: LoggingOptions
let includeInHeader: [String]?
let outputDirectory: String?
let outputFileName: String?
let projectDirectory: String?
let quiet: Bool
let shell: String?
let shouldBuildProject: Bool
/// Create the shared run options.
///
/// - Parameters:
/// - buildDirectory: Specify the build directory of the project.
/// - extraOptions: Extra arguments / options passed to the `pandoc` command.
/// - file: Files used to build the final output.
/// - loggingOptions: The logging options used when running the command.
/// - includeInHeader: Files to include in the header of the final output document.
/// - outputDirectory: Optional output directory for the output file, defaults to current working directory.
/// - projectDirectory: Optional project directory, defaults to current working directory.
/// - outputFileName: Override the output file name.
/// - quiet: Don't log output from the command.
/// - shell: Optional shell to use when calling the pandoc command.
/// - shouldBuildProject: Build the project prior to generating the output file.
public init(
buildDirectory: String? = nil,
extraOptions: [String]? = nil,
files: [String]? = nil,
loggingOptions: LoggingOptions,
includeInHeader: [String]? = nil,
outputDirectory: String? = nil,
projectDirectory: String? = nil,
outputFileName: String? = nil,
quiet: Bool = false,
shell: String? = nil,
shouldBuild shouldBuildProject: Bool = true
) {
self.buildDirectory = buildDirectory
self.extraOptions = extraOptions
self.files = files
self.loggingOptions = loggingOptions
self.includeInHeader = includeInHeader
self.outputDirectory = outputDirectory
self.outputFileName = outputFileName
self.projectDirectory = projectDirectory
self.quiet = quiet
self.shell = shell
self.shouldBuildProject = shouldBuildProject
}
}
/// Represents the file types that we can generate output files for.
@_spi(Internal)
public enum FileType: Equatable, Sendable {
case html
case latex
case pdf(engine: String?)
}
}
extension PandocClient: DependencyKey {
public static let testValue: PandocClient = Self(run: Run())
public static func live(environment: [String: String]) -> PandocClient {
.init(
run: Run(
generateLatex: { try await $0.run(.latex, environment) },
generateHtml: { try await $0.run(.html, environment) },
generatePdf: { try await $0.run(.pdf(engine: $1), environment) }
)
)
}
public static var liveValue: PandocClient {
.live(environment: ProcessInfo.processInfo.environment)
}
}

View File

@@ -0,0 +1,14 @@
// TODO: Use an actual version tag for playbook repo.
// TODO: Use an externally public url for the playbook repo.
public extension PlaybookClient {
@_spi(Internal)
enum Constants {
public static let defaultInstallationPath = "~/.local/share/hpa/playbook"
public static let inventoryFileName = "inventory.ini"
public static let playbookCommand = "ansible-playbook"
public static let playbookFileName = "main.yml"
public static let playbookRepoUrl = "https://git.housh.dev/michael/ansible-hpa-playbook.git"
public static let playbookRepoVersion = "main"
}
}

View File

@@ -0,0 +1,79 @@
import CommandClient
import ConfigurationClient
import Dependencies
import FileClient
import Foundation
import ShellClient
extension PlaybookClient.Repository {
static func findDirectory(
configuration: Configuration? = nil
) async throws -> String {
@Dependency(\.configurationClient) var configurationClient
var configuration: Configuration! = configuration
if configuration == nil {
configuration = try await configurationClient.findAndLoad()
}
return configuration.playbook?.directory
?? PlaybookClient.Constants.defaultInstallationPath
}
static func installPlaybook(
configuration: Configuration?
) async throws {
@Dependency(\.commandClient) var commandClient
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
var configuration: Configuration! = configuration
if configuration == nil {
configuration = try await configurationClient.findAndLoad()
}
let (path, version) = parsePlaybookPathAndVerion(
configuration?.playbook
)
let parentDirectory = URL(filePath: path)
.deletingLastPathComponent()
let parentExists = try await fileClient.isDirectory(parentDirectory)
if !parentExists {
try await fileClient.createDirectory(parentDirectory)
}
let playbookExists = try await fileClient.isDirectory(URL(filePath: path))
if !playbookExists {
logger.debug("Cloning playbook to: \(path)")
try await commandClient.run(
"git", "clone", "--branch", version,
PlaybookClient.Constants.playbookRepoUrl, path
)
} else {
logger.debug("Playbook exists, ensuring it's up to date.")
try await commandClient.run(
in: path,
"git", "pull", "--tags"
)
try await commandClient.run(
in: path,
"git", "checkout", version
)
}
}
static func parsePlaybookPathAndVerion(
_ configuration: Configuration.Playbook?
) -> (path: String, version: String) {
return (
path: configuration?.directory ?? PlaybookClient.Constants.defaultInstallationPath,
version: configuration?.version ?? PlaybookClient.Constants.playbookRepoVersion
)
}
}

View File

@@ -0,0 +1,245 @@
import CommandClient
import ConfigurationClient
import Dependencies
import Foundation
extension PlaybookClient.RunPlaybook.BuildOptions {
func run() async throws {
try await shared.run { arguments, _ in
let projectDirectory = projectDirectory
?? ProcessInfo.processInfo.environment["PWD"]
guard let projectDirectory else {
throw PlaybookClientError.projectDirectoryNotFound
}
arguments.append(contentsOf: [
"--tags", "build-project",
"--extra-vars", "project_dir=\(projectDirectory)"
])
if let extraOptions = shared.extraOptions {
arguments.append(contentsOf: extraOptions)
}
}
}
}
extension PlaybookClient.RunPlaybook.CreateOptions {
func run(encoder jsonEncoder: JSONEncoder?) async throws {
try await shared.run { arguments, configuration in
let jsonData = try createJSONData(
configuration: configuration,
encoder: jsonEncoder
)
guard let json = String(data: jsonData, encoding: .utf8) else {
throw PlaybookClientError.encodingError
}
arguments.append(contentsOf: [
"--tags", "setup-project",
"--extra-vars", "project_dir=\(projectDirectory)",
"--extra-vars", "'\(json)'"
])
if let extraOptions = shared.extraOptions {
arguments.append(contentsOf: extraOptions)
}
}
}
}
extension PlaybookClient.RunPlaybook.GenerateTemplateOptions {
func run() async throws -> String {
try await shared.run { arguments, _ in
arguments.append(contentsOf: [
"--tags", "repo-template",
"--extra-vars", "output_dir=\(templateDirectory)"
])
if let templateVarsDirectory {
arguments.append(contentsOf: ["--extra-vars", "repo_vars_dir=\(templateVarsDirectory)"])
}
if !useVault {
arguments.append(contentsOf: ["--extra-vars", "use_vault=false"])
}
return templateDirectory
}
}
}
extension PlaybookClient.RunPlaybook.SharedRunOptions {
@discardableResult
func run<T>(
_ apply: @Sendable @escaping (inout [String], Configuration) throws -> T
) async throws -> T {
@Dependency(\.commandClient) var commandClient
return try await commandClient.run(
logging: loggingOptions,
quiet: quiet,
shell: shell
) {
@Dependency(\.logger) var logger
@Dependency(\.configurationClient) var configurationClient
let configuration = try await configurationClient.findAndLoad()
var arguments = try await PlaybookClient.RunPlaybook.makeCommonArguments(
configuration: configuration,
inventoryFilePath: inventoryFilePath
)
let output = try apply(&arguments, configuration)
return (arguments, output)
}
}
}
@_spi(Internal)
public extension PlaybookClient.RunPlaybook {
static func ensuredInventoryPath(
_ optionalInventoryPath: String?,
configuration: Configuration,
playbookDirectory: String
) -> String {
guard let path = optionalInventoryPath else {
guard let path = configuration.playbook?.inventory else {
return "\(playbookDirectory)/\(PlaybookClient.Constants.inventoryFileName)"
}
return path
}
return path
}
static func makeCommonArguments(
configuration: Configuration,
inventoryFilePath: String?
) async throws -> [String] {
@Dependency(\.logger) var logger
let playbookDirectory = try await PlaybookClient.Repository.findDirectory(configuration: configuration)
let playbookPath = "\(playbookDirectory)/\(PlaybookClient.Constants.playbookFileName)"
logger.trace("Playbook path: \(playbookPath)")
let inventoryPath = ensuredInventoryPath(
inventoryFilePath,
configuration: configuration,
playbookDirectory: playbookDirectory
)
logger.trace("Inventory path: \(inventoryPath)")
var arguments = [
PlaybookClient.Constants.playbookCommand, playbookPath,
"--inventory", inventoryPath
]
if let defaultArgs = configuration.args {
arguments.append(contentsOf: defaultArgs)
}
if configuration.useVaultArgs, let vaultArgs = configuration.vault.args {
arguments.append(contentsOf: vaultArgs)
}
logger.trace("Common arguments: \(inventoryPath)")
return arguments
}
}
//
// NOTE: We're not using the `Coders` client because we generally do not
// want the output to be `prettyPrinted` or anything, unless we're running
// tests, so we use a supplied json encoder.
//
@_spi(Internal)
public extension PlaybookClient.RunPlaybook.CreateOptions {
func createJSONData(
configuration: Configuration,
encoder: JSONEncoder?
) throws -> Data {
@Dependency(\.logger) var logger
let encoder = encoder ?? jsonEncoder
let templateDir = template?.directory ?? configuration.template.directory
let templateRepo = template?.url ?? configuration.template.url
let version = template?.version ?? configuration.template.version
logger.debug("""
(\(useLocalTemplateDirectory), \(String(describing: templateDir)), \(String(describing: templateRepo)))
""")
switch (useLocalTemplateDirectory, templateDir, templateRepo) {
case (true, .none, _):
// User supplied they wanted to use a local template directory, but we could not find
// the path set from command line or in configuration.
throw PlaybookClientError.templateDirectoryNotFound
case let (false, _, .some(repo)):
// User did not supply they wanted to use a local template directory, and we found a repo url that was
// either set by the command line or found in the configuration.
logger.debug("Using repo.")
return try encoder.encode(TemplateRepo(repo: repo, version: version))
case let (true, .some(templateDir), _):
// User supplied they wanted to use a local template directory, and we found the template directory
// either set by the command line or in the configuration.
logger.debug("Using template directory.")
return try encoder.encode(TemplateDirJson(path: templateDir))
case let (false, .some(templateDir), _):
// User supplied they did not wanted to use a local template directory, and we found the template directory
// either set by the command line or in the configuration, and no repo was found / handled previously.
logger.debug("Using template directory.")
return try encoder.encode(TemplateDirJson(path: templateDir))
case (_, .none, .none):
// We could not find a repo or template directory.
throw PlaybookClientError.templateDirectoryOrRepoNotSpecified
}
}
}
private struct TemplateDirJson: Encodable {
let template: Template
init(path: String) {
self.template = .init(path: path)
}
struct Template: Encodable {
let path: String
}
}
private struct TemplateRepo: Encodable {
let template: Template
init(repo: String, version: String?) {
self.template = .init(repo: .init(url: repo, version: version ?? "main"))
}
struct Template: Encodable {
let repo: Repo
}
struct Repo: Encodable {
let url: String
let version: String
}
}
private let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes, .sortedKeys]
return encoder
}()

View File

@@ -0,0 +1,262 @@
import CommandClient
import ConfigurationClient
import Dependencies
import DependenciesMacros
import FileClient
import Foundation
import ShellClient
// TODO: Add update checks and pull for the playbook.
public extension DependencyValues {
/// Manages interactions with the playbook repository as well as `ansible-playbook`
/// command line application.
///
///
var playbookClient: PlaybookClient {
get { self[PlaybookClient.self] }
set { self[PlaybookClient.self] = newValue }
}
}
@DependencyClient
public struct PlaybookClient: Sendable {
/// Manages interactions with the playbook repository.
public var repository: Repository
/// Run the `ansible-playbook` command line application.
public var run: RunPlaybook
}
public extension PlaybookClient {
/// Manages interactions with the `ansible-hpa-playbook` repository, which is
/// used to build and generate home performance assessment projects.
@DependencyClient
struct Repository: Sendable {
/// Install the repository based on the given configuration. If configuration is
/// not supplied then the default location for the playbook repository is
/// `~/.local/share/hpa/playbook`
public var install: @Sendable (Configuration?) async throws -> Void
/// Get the current directory of the playbook repository based on the given
/// configuration.
public var directory: @Sendable (Configuration?) async throws -> String
/// Install the playbook in the default location.
public func install() async throws {
try await install(nil)
}
/// Get the current directory path of the playbook repository.
public func directory() async throws -> String {
try await directory(nil)
}
}
}
public extension PlaybookClient {
/// Runs the `ansible-playbook` command line application with the `ansible-hpa-playbook`.
///
/// This is used to build and create home performance projects. It can also generate a
/// template for a user to customize to their use case.
@DependencyClient
struct RunPlaybook: Sendable {
/// Build a home performance assesment project with the given options.
public var buildProject: @Sendable (BuildOptions) async throws -> Void
/// Create a new home performance assesment project with the given options.
public var createProject: @Sendable (CreateOptions, JSONEncoder?) async throws -> Void
/// Generate a user's template from the default home performance template.
public var generateTemplate: @Sendable (GenerateTemplateOptions) async throws -> String
/// Create a new home performance assesment project with the given options.
///
/// - Parameters:
/// - options: The options used to create the project.
public func createProject(_ options: CreateOptions) async throws {
try await createProject(options, nil)
}
/// Represents options that are shared for all the `ansible-playbook` commands.
public struct SharedRunOptions: Equatable, Sendable {
/// Extra arguments / options passed to the `ansible-playbook` command.
let extraOptions: [String]?
/// Specify the inventory file path.
let inventoryFilePath: String?
/// The logging options used when running the command.
let loggingOptions: LoggingOptions
/// Disables log output of the command.
let quiet: Bool
/// Optional shell to use when running the command.
let shell: String?
/// Create the shared options.
///
/// - Parameters:
/// - extraOptions: The extra arguments / options to pass to the command.
/// - inventoryFilePath: Specify the inventory file path.
/// - loggingOptions: The logging options used when running the command.
/// - quiet: Disable log output from the command.
/// - shell: Specify a shell used when running the command.
public init(
extraOptions: [String]? = nil,
inventoryFilePath: String? = nil,
loggingOptions: LoggingOptions,
quiet: Bool = false,
shell: String? = nil
) {
self.extraOptions = extraOptions
self.inventoryFilePath = inventoryFilePath
self.loggingOptions = loggingOptions
self.quiet = quiet
self.shell = shell
}
}
/// Options require when calling the build project command.
@dynamicMemberLookup
public struct BuildOptions: Equatable, Sendable {
/// An optional project directory, if not supplied then we will use the current working directory.
let projectDirectory: String?
/// The shared run options.
let shared: SharedRunOptions
/// Create new build options.
///
/// - Parameters:
/// - projectDirectory: The optional project directory to build, if not supplied then we'll use the current working directory.
/// - shared: The shared run options.
public init(
projectDirectory: String? = nil,
shared: SharedRunOptions
) {
self.projectDirectory = projectDirectory
self.shared = shared
}
public subscript<T>(dynamicMember keyPath: KeyPath<SharedRunOptions, T>) -> T {
shared[keyPath: keyPath]
}
}
/// Options required when creating a new home performance assessment project.
@dynamicMemberLookup
public struct CreateOptions: Equatable, Sendable {
/// The directory to generate the new project in.
let projectDirectory: String
/// Shared run options.
let shared: SharedRunOptions
/// Custom template configuration to use.
let template: Configuration.Template?
/// Specify whether we should only use a local template directory to create the project from.
let useLocalTemplateDirectory: Bool
/// Create new create options.
///
/// - Parameters:
/// - projectDirectory: The directory to generate the project in.
/// - shared: The shared run options.
/// - template: Custom template configuration used when generating the project.
/// - useLocalTemplateDirectory: Whether to use a local template directory, not a template repository.
public init(
projectDirectory: String,
shared: SharedRunOptions,
template: Configuration.Template? = nil,
useLocalTemplateDirectory: Bool
) {
self.projectDirectory = projectDirectory
self.shared = shared
self.template = template
self.useLocalTemplateDirectory = useLocalTemplateDirectory
}
public subscript<T>(dynamicMember keyPath: KeyPath<SharedRunOptions, T>) -> T {
shared[keyPath: keyPath]
}
}
/// Options required when generating a new template repository / directory.
@dynamicMemberLookup
public struct GenerateTemplateOptions: Equatable, Sendable {
/// The shared run options.
let shared: SharedRunOptions
/// The path to generate the template in.
let templateDirectory: String
/// Specify the name of the directory for template variables.
let templateVarsDirectory: String?
/// Specify whether to use `ansible-vault` encrypted variables.
let useVault: Bool
/// Create new generate template options.
///
/// - Parameters:
/// - shared: The shared run options
/// - templateDirectory: The path to generate the template in.
/// - templateVarsDirectory: Specify the name of the directory for template variables.
/// - useVault: Specify wheter to use `ansible-vault` encrypted variables.
public init(
shared: SharedRunOptions,
templateDirectory: String,
templateVarsDirectory: String? = nil,
useVault: Bool = true
) {
self.shared = shared
self.templateDirectory = templateDirectory
self.templateVarsDirectory = templateVarsDirectory
self.useVault = useVault
}
public subscript<T>(dynamicMember keyPath: KeyPath<SharedRunOptions, T>) -> T {
shared[keyPath: keyPath]
}
}
}
}
extension PlaybookClient.Repository: DependencyKey {
public static var liveValue: Self {
.init {
try await installPlaybook(configuration: $0)
} directory: {
try await findDirectory(configuration: $0)
}
}
}
extension PlaybookClient.RunPlaybook: DependencyKey {
public static var liveValue: PlaybookClient.RunPlaybook {
.init(
buildProject: { try await $0.run() },
createProject: { try await $0.run(encoder: $1) },
generateTemplate: { try await $0.run() }
)
}
}
extension PlaybookClient: DependencyKey {
public static let testValue: PlaybookClient = Self(
repository: Repository(),
run: RunPlaybook()
)
public static var liveValue: PlaybookClient {
.init(
repository: .liveValue,
run: .liveValue
)
}
}

View File

@@ -1,9 +1,8 @@
import Foundation
public enum CliClientError: Error {
enum PlaybookClientError: Error {
case encodingError
case playbookDirectoryNotFound
case projectDirectoryNotFound
case templateDirectoryNotFound
case templateDirectoryOrRepoNotSpecified
case vaultFileNotFound
}

View File

@@ -1,9 +1,42 @@
@_spi(Internal) @_exported import CommandClient
@_exported import Dependencies
import Foundation
import Logging
@_exported import ShellClient
public protocol TestCase {}
public extension TestCase {
static var loggingOptions: LoggingOptions {
let levelString = ProcessInfo.processInfo.environment["LOG_LEVEL"] ?? "debug"
let logLevel = Logger.Level(rawValue: levelString) ?? .debug
return .init(commandName: "\(Self.self)", logLevel: logLevel)
}
func withCapturingCommandClient(
_ key: String,
dependencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
run: @Sendable @escaping () async throws -> Void,
assert: @Sendable @escaping (CommandClient.RunCommandOptions) -> Void
) async throws {
let captured = CommandClient.CapturingClient()
try await withDependencies {
$0.commandClient = .capturing(captured)
setupDependencies(&$0)
} operation: {
try await Self.loggingOptions.withLogger {
try await run()
guard let options = await captured.options else {
throw TestSupportError.optionsNotSet
}
assert(options)
}
}
}
func withTestLogger(
key: String,
logLevel: Logger.Level = .debug,
@@ -128,3 +161,7 @@ public func withTestLogger(
try await operation()
}
}
enum TestSupportError: Error {
case optionsNotSet
}

View File

@@ -0,0 +1,15 @@
import Foundation
// swiftlint:disable force_try
public func withTemporaryDirectory(
_ operation: @Sendable (URL) async throws -> Void
) async rethrows {
let temporaryDirectory = FileManager.default
.temporaryDirectory
.appending(path: UUID().uuidString)
try! FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: false)
try await operation(temporaryDirectory)
try! FileManager.default.removeItem(at: temporaryDirectory)
}
// swiftlint:enable force_try

View File

@@ -0,0 +1,177 @@
import CommandClient
import ConfigurationClient
import Dependencies
import DependenciesMacros
import FileClient
public extension DependencyValues {
/// Manages interactions with `ansible-vault` command line application.
var vaultClient: VaultClient {
get { self[VaultClient.self] }
set { self[VaultClient.self] = newValue }
}
}
@DependencyClient
public struct VaultClient: Sendable {
/// Run an `ansible-vault` command.
public var run: Run
@DependencyClient
public struct Run: Sendable {
/// Decrypt an `ansible-vault` file.
public var decrypt: @Sendable (RunOptions) async throws -> String
/// Encrypt an `ansible-vault` file.
public var encrypt: @Sendable (RunOptions) async throws -> String
}
public struct RunOptions: Equatable, Sendable {
/// Extra arguments / options passed to the `ansible-vault` command.
let extraOptions: [String]?
/// Logging options to use while running the command.
let loggingOptions: LoggingOptions
/// The optional output file path.
let outputFilePath: String?
/// Disable logging output.
let quiet: Bool
/// Optional shell used to call the command.
let shell: String?
/// The path to the vault file, if not supplied we will search the current directory for it.
let vaultFilePath: String?
/// Create new run options.
///
/// - Parameters:
/// - extraOptions: Extra arguments / options passed to the command.
/// - loggingOptions: The logging options used while running the command.
/// - outputFilePath: The optional output file path.
/// - quiet: Disable logging output of the command.
/// - shell: The shell used to call the command.
/// - vaultFilePath: The vault file, if not supplied we will search in the current working directory.
public init(
extraOptions: [String]? = nil,
loggingOptions: LoggingOptions,
outputFilePath: String? = nil,
quiet: Bool = false,
shell: String? = nil,
vaultFilePath: String? = nil
) {
self.extraOptions = extraOptions
self.loggingOptions = loggingOptions
self.outputFilePath = outputFilePath
self.quiet = quiet
self.shell = shell
self.vaultFilePath = vaultFilePath
}
}
@_spi(Internal)
public enum Route: String, Equatable, Sendable {
case decrypt
case encrypt
public var verb: String { rawValue }
}
}
extension VaultClient: DependencyKey {
public static let testValue: VaultClient = Self(run: Run())
public static var liveValue: VaultClient {
.init(
run: .init(
decrypt: { try await $0.run(route: .decrypt) },
encrypt: { try await $0.run(route: .encrypt) }
)
)
}
}
@_spi(Internal)
public extension VaultClient {
enum Constants {
public static let vaultCommand = "ansible-vault"
}
}
extension VaultClient.RunOptions {
// Sets up the default arguments and runs the `ansible-vault` command,
// returning the output file path, which is either supplied by the caller
// or found via the `fileClient.findVaultFileInCurrentDirectory`.
//
// This allows the output to be piped into other commands.
//
//
@discardableResult
func run(route: VaultClient.Route) async throws -> String {
@Dependency(\.commandClient) var commandClient
return try await commandClient.run(
logging: loggingOptions,
quiet: quiet,
shell: shell
) {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
var output: String?
let configuration = try await configurationClient.findAndLoad()
logger.trace("Configuration: \(configuration)")
var vaultFilePath: String? = vaultFilePath
if vaultFilePath == nil {
vaultFilePath = try await fileClient
.findVaultFileInCurrentDirectory()?
.cleanFilePath
}
guard let vaultFilePath else {
throw VaultClientError.vaultFileNotFound
}
output = vaultFilePath
logger.trace("Vault file: \(vaultFilePath)")
var arguments = [
VaultClient.Constants.vaultCommand,
route.verb
]
if let outputFilePath {
arguments.append(contentsOf: ["--output", outputFilePath])
output = outputFilePath
}
if let extraOptions {
arguments.append(contentsOf: extraOptions)
}
if let vaultArgs = configuration.vault.args {
arguments.append(contentsOf: vaultArgs)
}
if arguments.contains("encrypt"),
!arguments.contains("--encrypt-vault-id"),
let id = configuration.vault.encryptId
{
arguments.append(contentsOf: ["--encrypt-vault-id", id])
}
arguments.append(vaultFilePath)
logger.trace("Arguments: \(arguments)")
return (arguments, output ?? "")
}
}
}
enum VaultClientError: Error {
case vaultFileNotFound
}

View File

@@ -9,8 +9,13 @@ struct Application: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: Constants.appName,
abstract: createAbstract("A utility for working with ansible hpa playbook."),
version: VERSION ?? "0.0.0",
subcommands: [
BuildCommand.self, CreateCommand.self, VaultCommand.self, UtilsCommand.self
BuildCommand.self,
CreateCommand.self,
GenerateCommand.self,
VaultCommand.self,
UtilsCommand.self
]
)

View File

@@ -1,6 +1,7 @@
import ArgumentParser
import CliClient
import Dependencies
import Foundation
import PlaybookClient
struct BuildCommand: AsyncParsableCommand {
@@ -9,46 +10,38 @@ struct BuildCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration.playbook(
commandName: commandName,
abstract: "Build a home performance assesment project.",
examples: (label: "Build Project", example: "\(commandName) /path/to/project")
examples: (
label: "Build project when in the project directory.",
example: "\(commandName)"
),
(
label: "Build project from outside the project directory.",
example: "\(commandName) --project-directory /path/to/project"
)
)
@OptionGroup var globals: GlobalOptions
@Argument(
@Option(
help: "Path to the project directory.",
completion: .directory
)
var projectDir: String
var projectDirectory: String?
@Argument(
help: "Extra arguments passed to the playbook."
help: "Extra arguments / options passed to the playbook."
)
var extraArgs: [String] = []
var extraOptions: [String] = []
mutating func run() async throws {
try await _run()
@Dependency(\.playbookClient) var playbookClient
// try await runPlaybook(
// commandName: Self.commandName,
// globals: globals,
// extraArgs: extraArgs,
// "--tags", "build-project",
// "--extra-vars", "project_dir=\(projectDir)"
// )
}
private func _run() async throws {
@Dependency(\.cliClient) var cliClient
try await cliClient.runPlaybookCommand(
globals.playbookOptions(
arguments: [
"--tags", "build-project",
"--extra-vars", "project_dir=\(projectDir)"
],
configuration: nil
),
logging: globals.loggingOptions(commandName: Self.commandName)
)
try await playbookClient.run.buildProject(.init(
projectDirectory: projectDirectory,
shared: globals.sharedPlaybookRunOptions(
commandName: Self.commandName,
extraOptions: extraOptions
)
))
}
}

View File

@@ -1,9 +1,9 @@
import ArgumentParser
import CliClient
import ConfigurationClient
import Dependencies
import Foundation
import Logging
import PlaybookClient
struct CreateCommand: AsyncParsableCommand {
@@ -54,53 +54,19 @@ struct CreateCommand: AsyncParsableCommand {
@Argument(
help: "Extra arguments passed to the playbook."
)
var extraArgs: [String] = []
var extraOptions: [String] = []
mutating func run() async throws {
try await _run()
}
private func _run() async throws {
@Dependency(\.coders) var coders
@Dependency(\.cliClient) var cliClient
@Dependency(\.configurationClient) var configurationClient
let loggingOptions = globals.loggingOptions(commandName: Self.commandName)
try await cliClient.withLogger(loggingOptions) {
@Dependency(\.logger) var logger
let json = try await cliClient.generateJSON(
generateJsonOptions,
logging: loggingOptions
)
logger.debug("JSON string: \(json)")
let arguments = [
"--tags", "setup-project",
"--extra-vars", "project_dir=\(self.projectDir)",
"--extra-vars", "'\(json)'"
] + extraArgs
try await cliClient.runPlaybookCommand(
globals.playbookOptions(
arguments: arguments,
configuration: nil
),
logging: loggingOptions
)
}
}
}
private extension CreateCommand {
var generateJsonOptions: CliClient.GenerateJsonOptions {
.init(
templateDirectory: templateDir,
templateRepo: repo,
version: branch,
@Dependency(\.playbookClient) var playbookClient
try await playbookClient.run.createProject(.init(
projectDirectory: projectDir,
shared: globals.sharedPlaybookRunOptions(
commandName: Self.commandName,
extraOptions: extraOptions
),
template: .init(directory: templateDir),
useLocalTemplateDirectory: localTemplateDir
)
))
print(projectDir)
}
}

View File

@@ -0,0 +1,14 @@
import ArgumentParser
struct GenerateCommand: AsyncParsableCommand {
static let commandName = "generate"
static let configuration = CommandConfiguration(
commandName: commandName,
subcommands: [
GeneratePdfCommand.self,
GenerateLatexCommand.self,
GenerateHtmlCommand.self
]
)
}

View File

@@ -0,0 +1,24 @@
import ArgumentParser
import Dependencies
import PandocClient
// TODO: Need to add a step to build prior to generating file.
struct GenerateHtmlCommand: AsyncParsableCommand {
static let commandName = "html"
static let configuration = CommandConfiguration(
commandName: commandName
)
@OptionGroup var globals: GenerateOptions
mutating func run() async throws {
@Dependency(\.pandocClient) var pandocClient
let output = try await pandocClient.run.generateHtml(
globals.pandocRunOptions(commandName: Self.commandName)
)
print(output)
}
}

View File

@@ -0,0 +1,22 @@
import ArgumentParser
import Dependencies
// TODO: Need to add a step to build prior to generating file.
struct GenerateLatexCommand: AsyncParsableCommand {
static let commandName = "latex"
static let configuration = CommandConfiguration(
commandName: commandName
)
@OptionGroup var globals: GenerateOptions
mutating func run() async throws {
@Dependency(\.pandocClient) var pandocClient
let output = try await pandocClient.run.generateLatex(
globals.pandocRunOptions(commandName: Self.commandName)
)
print(output)
}
}

View File

@@ -0,0 +1,95 @@
import ArgumentParser
import CommandClient
import PandocClient
@dynamicMemberLookup
struct GenerateOptions: ParsableArguments {
@OptionGroup var basic: BasicGlobalOptions
@Option(
name: .shortAndLong,
help: "Custom build directory path.",
completion: .directory
)
var buildDirectory: String?
@Option(
name: [.short, .customLong("file")],
help: "Files used to generate the output, can be specified multiple times.",
completion: .file()
)
var files: [String] = []
@Option(
name: [.customShort("H"), .long],
help: "Files to include in the header, can be specified multiple times.",
completion: .file()
)
var includeInHeader: [String] = []
@Flag(
help: "Do not build the project prior to generating the output."
)
var noBuild: Bool = false
@Option(
name: .shortAndLong,
help: "The project directory.",
completion: .directory
)
var projectDirectory: String?
@Option(
name: .shortAndLong,
help: "The output directory",
completion: .directory
)
var outputDirectory: String?
@Option(
name: [.customShort("n"), .customLong("name")],
help: "Name of the output file."
)
var outputFileName: String?
// NOTE: This must be last, both here and in the commands, so if the commands have options of their
// own, they must be declared ahead of using the global options.
@Argument(
help: "Extra arguments / options to pass to the underlying pandoc command."
)
var extraOptions: [String] = []
subscript<T>(dynamicMember keyPath: WritableKeyPath<BasicGlobalOptions, T>) -> T {
get { basic[keyPath: keyPath] }
set { basic[keyPath: keyPath] = newValue }
}
subscript<T>(dynamicMember keyPath: KeyPath<BasicGlobalOptions, T>) -> T {
basic[keyPath: keyPath]
}
}
extension GenerateOptions {
func loggingOptions(commandName: String) -> LoggingOptions {
basic.loggingOptions(commandName: commandName)
}
func pandocRunOptions(commandName: String) -> PandocClient.RunOptions {
.init(
buildDirectory: buildDirectory,
extraOptions: extraOptions.count > 0 ? extraOptions : nil,
files: files.count > 0 ? files : nil,
loggingOptions: .init(commandName: commandName, logLevel: .init(globals: basic, quietOnlyPlaybook: false)),
includeInHeader: includeInHeader.count > 0 ? includeInHeader : nil,
outputDirectory: outputDirectory,
projectDirectory: projectDirectory,
outputFileName: outputFileName,
quiet: basic.quiet,
shell: basic.shell,
shouldBuild: !noBuild
)
}
}

View File

@@ -0,0 +1,32 @@
import ArgumentParser
import Dependencies
import PandocClient
// TODO: Need to add a step to build prior to generating file.
struct GeneratePdfCommand: AsyncParsableCommand {
static let commandName = "pdf"
static let configuration = CommandConfiguration(
commandName: commandName
)
@Option(
name: [.customShort("e"), .customLong("engine")],
help: "The pdf engine to use."
)
var pdfEngine: String?
@OptionGroup var globals: GenerateOptions
mutating func run() async throws {
@Dependency(\.pandocClient) var pandocClient
let output = try await pandocClient.run.generatePdf(
globals.pandocRunOptions(commandName: Self.commandName),
pdfEngine: pdfEngine
)
print(output)
}
}

View File

@@ -25,7 +25,13 @@ func createAbstract(_ string: String) -> String {
extension Usage where Content == AnyTextNode {
static func `default`(commandName: String, parentCommand: String?) -> Self {
static func `default`(
commandName: String,
parentCommand: String?,
usesArguments: Bool = true,
usesOptions: Bool = true,
usesExtraArguments: Bool = true
) -> Self {
.init {
HStack {
HStack {
@@ -37,10 +43,16 @@ extension Usage where Content == AnyTextNode {
}
.color(.blue).bold()
"[OPTIONS]".color(.green)
"[ARGUMENTS]".color(.cyan)
"--"
"[EXTRA-OPTIONS]".color(.magenta)
if usesOptions {
"[OPTIONS]".color(.green)
}
if usesArguments {
"[ARGUMENTS]".color(.cyan)
}
if usesExtraArguments {
"--"
"[EXTRA-OPTIONS]".color(.magenta)
}
}
.eraseToAnyTextNode()

View File

@@ -3,6 +3,9 @@ import Rainbow
// Constant string values.
enum Constants {
static let appName = "hpa"
static let brewPackages = [
"ansible", "imagemagick", "pandoc", "texLive"
]
static let playbookFileName = "main.yml"
static let inventoryFileName = "inventory.ini"
static let importantExtraArgsNote = """

View File

@@ -26,13 +26,32 @@ extension Array where Element == Example {
))
return output
}
func addingPipeToOtherCommand(command: String) -> Self {
var output = self
output.append((
label: "Piping output to other command.",
example: "\(command) --quiet | xargs open"
))
return output
}
}
extension ExampleSection where Header == String, Label == String {
static func `default`(examples: [Example], usesPassingExtraOptions: Bool = true) -> some TextNode {
static func `default`(
examples: [Example],
usesPassingExtraOptions: Bool = true,
usesPipeToOtherCommand: Bool = true
) -> some TextNode {
var examples = examples
if usesPassingExtraOptions, let first = examples.first {
examples = examples.addingPassingOptions(command: first.example)
if let first = examples.first {
if usesPassingExtraOptions {
examples = examples.addingPassingOptions(command: first.example)
}
if usesPipeToOtherCommand {
examples = examples.addingPipeToOtherCommand(command: first.example)
}
}
return Self(examples: examples) {
"EXAMPLES:"

View File

@@ -1,5 +1,7 @@
import ArgumentParser
import CliClient
import CommandClient
import ConfigurationClient
import PlaybookClient
struct BasicGlobalOptions: ParsableArguments {
@Flag(
@@ -27,10 +29,10 @@ struct BasicGlobalOptions: ParsableArguments {
struct GlobalOptions: ParsableArguments {
@Option(
name: .shortAndLong,
name: .long,
help: "Optional path to the ansible hpa playbook directory."
)
var playbookDir: String?
var playbookDirectory: String?
@Option(
name: .shortAndLong,
@@ -58,7 +60,7 @@ struct GlobalOptions: ParsableArguments {
}
extension GlobalOptions {
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
func loggingOptions(commandName: String) -> LoggingOptions {
.init(
commandName: commandName,
logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook)
@@ -67,10 +69,28 @@ extension GlobalOptions {
}
extension BasicGlobalOptions {
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
func loggingOptions(commandName: String) -> LoggingOptions {
.init(
commandName: commandName,
logLevel: .init(globals: self, quietOnlyPlaybook: false)
)
}
}
extension GlobalOptions {
func sharedPlaybookRunOptions(
commandName: String,
extraOptions: [String]?
) -> PlaybookClient.RunPlaybook.SharedRunOptions {
return .init(
extraOptions: extraOptions,
inventoryFilePath: inventoryPath,
loggingOptions: .init(
commandName: commandName,
logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook)
),
quiet: basic.quiet,
shell: basic.shell
)
}
}

View File

@@ -1,19 +0,0 @@
import CliClient
import ConfigurationClient
extension GlobalOptions {
func playbookOptions(
arguments: [String],
configuration: Configuration?
) -> CliClient.PlaybookOptions {
.init(
arguments: arguments,
configuration: configuration,
inventoryFilePath: inventoryPath,
playbookDirectory: playbookDir,
quiet: quietOnlyPlaybook ? true : basic.quiet,
shell: basic.shell
)
}
}

View File

@@ -1,18 +0,0 @@
import CliClient
import ConfigurationClient
extension VaultOptions {
func vaultOptions(
arguments: [String],
configuration: Configuration?
) -> CliClient.VaultOptions {
.init(
arguments: arguments,
configuration: configuration,
quiet: globals.quiet,
shell: globals.shell,
vaultFilePath: file
)
}
}

View File

@@ -0,0 +1,27 @@
import ArgumentParser
import CliDoc
import ConfigurationClient
import CustomDump
import Dependencies
struct DumpConfigCommand: AsyncParsableCommand {
static let commandName = "dump-config"
static let configuration = CommandConfiguration(
commandName: commandName,
abstract: createAbstract("Show the current configuration."),
usage: .default(commandName: commandName, parentCommand: "utils", usesArguments: false, usesExtraArguments: false),
discussion: Discussion {
"Useful to debug your configuration settings / make sure they load properly."
}
)
func run() async throws {
@Dependency(\.configurationClient) var configurationClient
let configuration = try await configurationClient.findAndLoad()
customDump(configuration)
}
}

View File

@@ -1,6 +1,6 @@
import ArgumentParser
import CliClient
import CliDoc
import CommandClient
import ConfigurationClient
import Dependencies
@@ -16,14 +16,14 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
Note {
"""
If a directory is not supplied then a configuration file will be created
at \("'~/.config/hpa-playbook/config'".yellow).
at \("'~/.config/hpa/config.toml'".yellow).
"""
}
VStack {
"EXAMPLE:".yellow.bold
"Create a directory and generate the configuration".green
ShellCommand("mkdir -p ~/.config/hpa-playbook")
ShellCommand("hpa generate-config --path ~/.config/hpa-playbook")
ShellCommand("mkdir -p ~/.config/hpa")
ShellCommand("hpa generate-config --path ~/.config/hpa")
}
}
.separator(.newLine(count: 2))
@@ -39,7 +39,7 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
@Flag(
name: .shortAndLong,
help: "Generate a json file, instead of default env style"
help: "Generate a json file, instead of the default toml style"
)
var json: Bool = false
@@ -56,26 +56,18 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
}
private func _run() async throws {
@Dependency(\.cliClient) var cliClient
@Dependency(\.configurationClient) var configurationClient
try await cliClient.withLogger(globals.loggingOptions(commandName: Self.commandName)) {
try await globals.loggingOptions(commandName: Self.commandName).withLogger {
@Dependency(\.logger) var logger
let actualPath: File
let output = try await configurationClient.generate(.init(
force: force,
json: json,
path: path != nil ? .directory(path!) : nil
))
if let path, let file = File("\(path)/config.\(json ? "json" : "toml")") {
actualPath = file
} else {
actualPath = .default
}
logger.debug("Generating config at path: \(actualPath.path)")
try await configurationClient.generate(
at: actualPath,
force: force
)
print(output)
}
}
}

View File

@@ -1,6 +1,6 @@
import ArgumentParser
import CliClient
import Dependencies
import PlaybookClient
struct GenerateProjectTemplateCommand: AsyncParsableCommand {
@@ -34,29 +34,23 @@ struct GenerateProjectTemplateCommand: AsyncParsableCommand {
var path: String
@Argument(
help: "Extra arguments passed to the playbook."
help: "Extra arguments / options passed to the playbook."
)
var extraArgs: [String] = []
var extraOptions: [String] = []
mutating func run() async throws {
@Dependency(\.cliClient) var cliClient
@Dependency(\.playbookClient) var playbookClient
var arguments = [
"--tags", "repo-template",
"--extra-vars", "output_dir=\(path)"
]
let output = try await playbookClient.run.generateTemplate(.init(
shared: globals.sharedPlaybookRunOptions(
commandName: Self.commandName,
extraOptions: extraOptions
),
templateDirectory: path,
templateVarsDirectory: templateVars,
useVault: !noVault
))
if let varsDir = templateVars {
arguments.append(contentsOf: ["--extra-vars", "repo_vars_dir=\(varsDir)"])
}
if noVault {
arguments.append(contentsOf: ["--extra-vars", "use_vault=false"])
}
try await cliClient.runPlaybookCommand(
globals.playbookOptions(arguments: arguments, configuration: nil),
logging: globals.loggingOptions(commandName: Self.commandName)
)
print(output)
}
}

View File

@@ -0,0 +1,52 @@
import ArgumentParser
import CliDoc
import CommandClient
import Dependencies
struct InstallDependenciesCommand: AsyncParsableCommand {
static let commandName: String = "install-dependencies"
static let configuration = CommandConfiguration(
commandName: commandName,
abstract: createAbstract("Ensure required dependencies are installed"),
usage: .default(commandName: commandName, parentCommand: "utils", usesArguments: false),
discussion: Discussion {
VStack {
Note {
"Homebrew is required to install dependencies."
}
HStack {
"See Also:".yellow.bold.underline
"https://brew.sh"
}
ImportantNote.passingExtraArgs
}
.separator(.newLine(count: 2))
}
)
@OptionGroup var globals: BasicGlobalOptions
@Argument(
help: "Extra arguments / options to pass to the homebrew command."
)
var extraOptions: [String] = []
mutating func run() async throws {
@Dependency(\.commandClient) var commandClient
@Dependency(\.playbookClient) var playbookClient
let arguments = [
"brew", "install"
] + Constants.brewPackages
+ extraOptions
try await commandClient.run(
quiet: globals.quiet,
shell: globals.shell,
arguments
)
try await playbookClient.repository.install()
}
}

View File

@@ -10,8 +10,10 @@ struct UtilsCommand: AsyncParsableCommand {
These are commands that are generally only run on occasion / less frequently used.
""",
subcommands: [
DumpConfigCommand.self,
GenerateProjectTemplateCommand.self,
GenerateConfigurationCommand.self
GenerateConfigurationCommand.self,
InstallDependenciesCommand.self
]
)

View File

@@ -1,6 +1,6 @@
import ArgumentParser
import CliClient
import Dependencies
import VaultClient
struct DecryptCommand: AsyncParsableCommand {
@@ -11,32 +11,22 @@ struct DecryptCommand: AsyncParsableCommand {
abstract: createAbstract("Decrypt a vault file.")
)
@OptionGroup var options: VaultOptions
@Option(
name: .shortAndLong,
help: "Output file."
)
var output: String?
// FIX:
@OptionGroup var options: VaultOptions
mutating func run() async throws {
@Dependency(\.cliClient) var cliClient
@Dependency(\.vaultClient) var vaultClient
var args = ["decrypt"]
if let output {
args.append(contentsOf: ["--output", output])
}
let output = try await vaultClient.run.decrypt(options.runOptions(
commandName: Self.commandName,
outputFilePath: output
))
try await cliClient.runVaultCommand(
options.vaultOptions(arguments: args, configuration: nil),
logging: options.loggingOptions(commandName: Self.commandName)
)
// try await runVault(
// commandName: Self.commandName,
// options: options,
// args
// )
print(output)
}
}

View File

@@ -0,0 +1 @@

View File

@@ -1,6 +1,6 @@
import ArgumentParser
import CliClient
import Dependencies
import VaultClient
struct EncryptCommand: AsyncParsableCommand {
@@ -11,31 +11,22 @@ struct EncryptCommand: AsyncParsableCommand {
abstract: createAbstract("Encrypt a vault file.")
)
@OptionGroup var options: VaultOptions
@Option(
name: .shortAndLong,
help: "Output file."
)
var output: String?
// FIX:
@OptionGroup var options: VaultOptions
mutating func run() async throws {
@Dependency(\.cliClient) var cliClient
@Dependency(\.vaultClient) var vaultClient
var args = ["encrypt"]
if let output {
args.append(contentsOf: ["--output", output])
}
try await cliClient.runVaultCommand(
options.vaultOptions(arguments: args, configuration: nil),
logging: options.loggingOptions(commandName: Self.commandName)
)
let output = try await vaultClient.run.encrypt(options.runOptions(
commandName: Self.commandName,
outputFilePath: output
))
// try await runVault(
// commandName: Self.commandName,
// options: options,
// args
// )
print(output)
}
}

View File

@@ -24,7 +24,7 @@ struct VaultCommand: AsyncParsableCommand {
.separator(.newLine(count: 2))
},
subcommands: [
EncryptCommand.self, DecryptCommand.self
DecryptCommand.self, EncryptCommand.self
]
)
}

View File

@@ -1,5 +1,6 @@
import ArgumentParser
import CliClient
import CommandClient
import VaultClient
// Holds the common options for vault commands, as they all share the
// same structure.
@@ -18,7 +19,7 @@ struct VaultOptions: ParsableArguments {
@Argument(
help: "Extra arguments to pass to the vault command."
)
var extraArgs: [String] = []
var extraOptions: [String] = []
subscript<T>(dynamicMember keyPath: WritableKeyPath<BasicGlobalOptions, T>) -> T {
get { globals[keyPath: keyPath] }
@@ -32,7 +33,21 @@ struct VaultOptions: ParsableArguments {
}
extension VaultOptions {
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
globals.loggingOptions(commandName: commandName)
func runOptions(
commandName: String,
outputFilePath: String? = nil
) -> VaultClient.RunOptions {
.init(
extraOptions: extraOptions,
loggingOptions: .init(
commandName: commandName,
logLevel: .init(globals: globals, quietOnlyPlaybook: false)
),
outputFilePath: outputFilePath,
quiet: globals.quiet,
shell: globals.shell,
vaultFilePath: file
)
}
}

View File

@@ -0,0 +1,2 @@
// Do not set this variable, it is set during the build process.
let VERSION: String? = "0.1.1"

View File

@@ -1,327 +0,0 @@
@_spi(Internal) import CliClient
import ConfigurationClient
import Dependencies
import FileClient
import Foundation
import ShellClient
import Testing
import TestSupport
@Suite("CliClientTests")
struct CliClientTests: TestCase {
static let loggingOptions: CliClient.LoggingOptions = {
let levelString = ProcessInfo.processInfo.environment["LOGGING_LEVEL"] ?? "debug"
let logLevel = Logger.Level(rawValue: levelString) ?? .debug
return .init(commandName: "CliClientTests", logLevel: logLevel)
}()
@Test
func capturingClient() async throws {
let captured = CliClient.CapturingClient()
let client = CliClient.capturing(captured)
try await client.runCommand(quiet: false, shell: .zsh(), "foo", "bar")
let quiet = await captured.quiet!
#expect(quiet == false)
let shell = await captured.shell
#expect(shell == .zsh())
let arguments = await captured.arguments!
#expect(arguments == ["foo", "bar"])
}
@Test(arguments: ["encrypt", "decrypt"])
func runVault(argument: String) async throws {
let captured = CliClient.CapturingClient()
try await withMockConfiguration(captured, key: "runVault") {
$0.fileClient.findVaultFileInCurrentDirectory = { URL(filePath: "vault.yml") }
} operation: {
@Dependency(\.cliClient) var cliClient
let configuration = Configuration.mock
try await cliClient.runVaultCommand(
.init(arguments: [argument], quiet: false, shell: nil),
logging: Self.loggingOptions
)
let shell = await captured.shell
#expect(shell == .zsh(useDashC: true))
let vaultPath = URL(filePath: #file)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appending(path: "vault.yml")
var encryptArgs: [String] = []
if argument == "encrypt", let id = configuration.vault.encryptId {
encryptArgs = ["--encrypt-vault-id", id]
}
let expectedArguments = [
"ansible-vault", argument
] + configuration.vault.args!
+ encryptArgs
+ [vaultPath.cleanFilePath]
let arguments = await captured.arguments
#expect(arguments == expectedArguments)
}
}
@Test(arguments: [
Configuration(
args: ["--tags", "debug"],
useVaultArgs: true,
playbook: .init(directory: "playbook", inventory: nil),
vault: .mock
)
])
func runPlaybook(configuration: Configuration) async throws {
let captured = CliClient.CapturingClient()
try await withMockConfiguration(captured, configuration: configuration, key: "runPlaybook") {
@Dependency(\.cliClient) var cliClient
try await cliClient.runPlaybookCommand(
.init(
arguments: [],
quiet: false,
shell: nil
),
logging: Self.loggingOptions
)
let expectedArguments = [
"ansible-playbook", "playbook/main.yml",
"--inventory", "playbook/inventory.ini",
"--tags", "debug",
"--vault-id=myId@$SCRIPTS/vault-gopass-client"
]
let arguments = await captured.arguments
#expect(arguments == expectedArguments)
}
}
@Test
func ensuredPlaybookDirectory() throws {
let configuration = Configuration.mock
let playbookDir = try configuration.ensuredPlaybookDirectory("playbook")
#expect(playbookDir == "playbook")
do {
_ = try configuration.ensuredPlaybookDirectory(nil)
#expect(Bool(false))
} catch {
#expect(Bool(true))
}
}
@Test
func shellOrDefault() {
var shell: String? = "/bin/bash"
#expect(shell.orDefault == .custom(path: "/bin/bash", useDashC: true))
shell = nil
#expect(shell.orDefault == .zsh(useDashC: true))
}
@Test
func testEnsuredInventoryPath() {
let configuration = Configuration(playbook: .init(inventory: "inventory.ini"))
let playbookDir = "playbook"
let inventoryPath = "inventory.ini"
var output = ensuredInventoryPath(
inventoryPath,
configuration: configuration,
playbookDirectory: playbookDir
)
#expect(output == "inventory.ini")
output = ensuredInventoryPath(
nil,
configuration: configuration,
playbookDirectory: playbookDir
)
#expect(output == "inventory.ini")
}
@Test
func vaultFilePath() async throws {
var fileClient = FileClient.testValue
fileClient.findVaultFileInCurrentDirectory = { URL(string: "vault.yml") }
var output = try await fileClient.ensuredVaultFilePath("vault.yml")
#expect(output == "vault.yml")
output = try await fileClient.ensuredVaultFilePath(nil)
fileClient.findVaultFileInCurrentDirectory = { nil }
do {
_ = try await fileClient.ensuredVaultFilePath(nil)
#expect(Bool(false))
} catch {
#expect(Bool(true))
}
}
@Test(
arguments: [
GenerateJsonTestOption(
options: .init(
templateDirectory: nil,
templateRepo: nil,
version: nil,
useLocalTemplateDirectory: true
),
configuration: nil,
expectation: .failing
),
GenerateJsonTestOption(
options: .init(
templateDirectory: nil,
templateRepo: nil,
version: nil,
useLocalTemplateDirectory: false
),
configuration: nil,
expectation: .failing
),
GenerateJsonTestOption(
options: .init(
templateDirectory: "template",
templateRepo: nil,
version: nil,
useLocalTemplateDirectory: true
),
configuration: nil,
expectation: .success("""
{
"template" : {
"path" : "template"
}
}
""")
),
GenerateJsonTestOption(
options: .init(
templateDirectory: nil,
templateRepo: nil,
version: nil,
useLocalTemplateDirectory: false
),
configuration: .init(template: .init(directory: "template")),
expectation: .success("""
{
"template" : {
"path" : "template"
}
}
""")
),
GenerateJsonTestOption(
options: .init(
templateDirectory: nil,
templateRepo: "https://git.example.com/template.git",
version: nil,
useLocalTemplateDirectory: false
),
configuration: nil,
expectation: .success("""
{
"template" : {
"repo" : "https://git.example.com/template.git",
"version" : "main"
}
}
""")
),
GenerateJsonTestOption(
options: .init(
templateDirectory: nil,
templateRepo: nil,
version: nil,
useLocalTemplateDirectory: false
),
configuration: .init(template: .init(url: "https://git.example.com/template.git", version: "v0.1.0")),
expectation: .success("""
{
"template" : {
"repo" : "https://git.example.com/template.git",
"version" : "v0.1.0"
}
}
""")
)
]
)
func generateJson(input: GenerateJsonTestOption) async {
await withTestLogger(key: "generateJson") {
$0.configurationClient = .mock(input.configuration ?? .init())
$0.cliClient = .liveValue
} operation: {
@Dependency(\.cliClient) var cliClient
let json = try? await cliClient.generateJSON(
input.options,
logging: Self.loggingOptions,
encoder: jsonEncoder
)
switch input.expectation {
case let .success(expected):
#expect(json == expected)
case .failing:
#expect(json == nil)
}
}
}
func withMockConfiguration(
_ capturing: CliClient.CapturingClient,
configuration: Configuration = .mock,
key: String,
logLevel: Logger.Level = .trace,
depednencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
operation: @Sendable @escaping () async throws -> Void
) async rethrows {
try await withTestLogger(key: key, logLevel: logLevel) {
$0.configurationClient = .mock(configuration)
$0.cliClient = .capturing(capturing)
setupDependencies(&$0)
} operation: {
try await operation()
}
}
}
struct GenerateJsonTestOption: Sendable {
let options: CliClient.GenerateJsonOptions
let configuration: Configuration?
let expectation: GenerateJsonExpectation
}
enum GenerateJsonExpectation: Sendable {
case failing
case success(String)
}
extension ConfigurationClient {
static func mock(_ configuration: Configuration) -> Self {
var mock = Self.testValue
mock.find = { throw TestError() }
mock.load = { _ in configuration }
return mock
}
}
struct TestError: Error {}
let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys]
return encoder
}()

View File

@@ -18,6 +18,9 @@ struct ConfigurationClientTests: TestCase {
@Test(arguments: ["config.toml", "config.json"])
func generateConfigFile(fileName: String) async throws {
try await withTestLogger(key: "generateConfigFile") {
$0.asyncShellClient = .liveValue
$0.commandClient = .liveValue
$0.coders = .liveValue
$0.fileClient = .liveValue
} operation: {
@Dependency(\.logger) var logger
@@ -26,17 +29,14 @@ struct ConfigurationClientTests: TestCase {
try await withTemporaryDirectory { tempDir in
let tempFile = tempDir.appending(path: fileName)
try await configuration.generate(at: File(tempFile)!, force: false)
let output = try await configuration.generate(.init(
force: false,
json: fileName.hasSuffix("json"),
path: .file(File(tempFile)!)
))
#expect(FileManager.default.fileExists(atPath: tempFile.cleanFilePath))
#expect(fileClient.fileExists(tempFile))
// Ensure that we do not overwrite files if they exist.
do {
try await configuration.generate(at: File(tempFile)!, force: false)
#expect(Bool(false))
} catch {
#expect(Bool(true))
}
#expect(output == tempFile.cleanFilePath)
}
}
}
@@ -44,6 +44,9 @@ struct ConfigurationClientTests: TestCase {
@Test(arguments: ["config.toml", "config.json", nil])
func loadConfigFile(fileName: String?) async throws {
try await withTestLogger(key: "generateConfigFile") {
$0.asyncShellClient = .liveValue
$0.commandClient = .liveValue
$0.coders = .liveValue
$0.fileClient = .liveValue
} operation: {
@Dependency(\.logger) var logger
@@ -66,6 +69,8 @@ struct ConfigurationClientTests: TestCase {
@Test(arguments: ["config.toml", "config.json", ".hparc.json", ".hparc.toml"])
func findConfiguration(fileName: String) async throws {
try await withTestLogger(key: "findConfiguration") {
$0.asyncShellClient = .liveValue
$0.commandClient = .liveValue
$0.fileClient = .liveValue
} operation: {
@Dependency(\.logger) var logger
@@ -95,6 +100,8 @@ struct ConfigurationClientTests: TestCase {
@Test(arguments: ["config.toml", "config.json", ".hparc.json", ".hparc.toml"])
func findXdgConfiguration(fileName: String) async throws {
try await withTestLogger(key: "findXdgConfiguration") {
$0.asyncShellClient = .liveValue
$0.commandClient = .liveValue
$0.fileClient = .liveValue
} operation: {
@Dependency(\.logger) var logger
@@ -130,6 +137,29 @@ struct ConfigurationClientTests: TestCase {
}
}
}
@Test
func writeCreatesBackupFile() async throws {
try await withDependencies {
$0.asyncShellClient = .liveValue
$0.commandClient = .liveValue
$0.fileClient = .liveValue
} operation: {
let client = ConfigurationClient.liveValue
try await withGeneratedConfigFile(named: "config.toml", client: client) { configFile in
@Dependency(\.fileClient) var fileClient
let backupUrl = configFile.url.appendingPathExtension("back")
#expect(fileClient.fileExists(backupUrl) == false)
let config = Configuration()
try await client.write(config, to: configFile)
#expect(fileClient.fileExists(backupUrl))
}
}
}
}
func generateFindEnvironments(file: File) -> [[String: String]] {
@@ -143,23 +173,6 @@ func generateFindEnvironments(file: File) -> [[String: String]] {
]
}
// swiftlint:disable force_try
func withTemporaryDirectory(
_ operation: @Sendable (URL) async throws -> Void
) async rethrows {
let dir = FileManager.default.temporaryDirectory
let tempDir = dir.appending(path: UUID().uuidString)
try! FileManager.default.createDirectory(
atPath: tempDir.cleanFilePath,
withIntermediateDirectories: false
)
try await operation(tempDir)
try! FileManager.default.removeItem(at: tempDir)
}
// swiftlint:enable force_try
func withGeneratedConfigFile(
named fileName: String,
client: ConfigurationClient,
@@ -167,7 +180,11 @@ func withGeneratedConfigFile(
) async rethrows {
try await withTemporaryDirectory { tempDir in
let file = File(tempDir.appending(path: fileName))!
try await client.generate(at: file)
_ = try await client.generate(.init(
force: true,
json: fileName.hasSuffix("json"),
path: .file(file)
))
try await operation(file)
}
}
@@ -184,7 +201,11 @@ func withGeneratedXDGConfigFile(
withIntermediateDirectories: false
)
let file = File(xdgDir.appending(path: fileName))!
try await client.generate(at: file)
_ = try await client.generate(.init(
force: true,
json: fileName.hasSuffix("json"),
path: .file(file)
))
try await operation(file, tempDir)
}
}

View File

@@ -0,0 +1,67 @@
import FileClient
import Foundation
import Testing
import TestSupport
@Suite("FileClientTests")
struct FileClientTests {
@Test
func createDirectory() async throws {
try await withTemporaryDirectory { url in
let fileClient = FileClient.liveValue
let tempDir = url.appending(path: "temp")
try await fileClient.createDirectory(tempDir)
let isDirectory = try await fileClient.isDirectory(tempDir)
#expect(isDirectory)
}
}
@Test(arguments: ["vault.yml", "vault.yaml"])
func findVaultFile(fileName: String) async throws {
try await withTemporaryDirectory { url in
let fileClient = FileClient.liveValue
let vaultFilePath = url.appending(path: fileName)
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
let output = try await fileClient.findVaultFile(url)!
#expect(output.cleanFilePath == vaultFilePath.cleanFilePath)
let nilWhenFileNotDirectory = try await fileClient.findVaultFile(vaultFilePath)
#expect(nilWhenFileNotDirectory == nil)
}
}
@Test(arguments: ["vault.yml", "vault.yaml"])
func findVaultFileNestedInSubfolder(fileName: String) async throws {
try await withTemporaryDirectory { url in
let fileClient = FileClient.liveValue
let subDir = url.appending(path: "sub")
try await fileClient.createDirectory(subDir)
let vaultFilePath = subDir.appending(path: fileName)
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
let output = try await fileClient.findVaultFile(url)!
#expect(output.cleanFilePath == vaultFilePath.cleanFilePath)
}
}
@Test
func findVaultFileReturnsNil() async throws {
try await withTemporaryDirectory { url in
let fileClient = FileClient.liveValue
let subDir = url.appending(path: "sub")
try await fileClient.createDirectory(subDir)
let output = try await fileClient.findVaultFile(url)
#expect(output == nil)
}
}
}

View File

@@ -0,0 +1,333 @@
@_spi(Internal) import ConfigurationClient
@_spi(Internal) import PandocClient
import PlaybookClient
import Testing
import TestSupport
@Suite("PandocClientTests")
struct PandocClientTests: TestCase {
static let outputDirectory = "/output"
static let projectDirectory = "/project"
static let defaultFileName = "Report"
static let expectedIncludeInHeaders = [
"--include-in-header=/project/.build/head.tex",
"--include-in-header=/project/.build/footer.tex"
]
static let expectedFiles = [
"/project/.build/Report.md",
"/project/.build/Definitions.md"
]
static var sharedRunOptions: PandocClient.RunOptions {
.init(
buildDirectory: nil,
files: nil,
loggingOptions: loggingOptions,
includeInHeader: nil,
outputDirectory: outputDirectory,
projectDirectory: projectDirectory,
outputFileName: nil,
quiet: false,
shell: nil,
shouldBuild: true
)
}
@Test
func generateLatex() async throws {
try await withCapturingCommandClient("generateLatex") {
$0.configurationClient = .mock()
$0.playbookClient.run.buildProject = { _ in }
$0.pandocClient = .liveValue
} run: {
@Dependency(\.pandocClient) var pandocClient
let output = try await pandocClient.run.generateLatex(Self.sharedRunOptions)
#expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).tex")
} assert: { output in
let expected = ["pandoc"]
+ Self.expectedIncludeInHeaders
+ ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).tex"]
+ Self.expectedFiles
#expect(output.arguments == expected)
}
}
@Test
func generateHtml() async throws {
try await withCapturingCommandClient("generateHtml") {
$0.configurationClient = .mock()
$0.playbookClient.run.buildProject = { _ in }
$0.pandocClient = .liveValue
} run: {
@Dependency(\.pandocClient) var pandocClient
let output = try await pandocClient.run.generateHtml(Self.sharedRunOptions)
#expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).html")
} assert: { output in
let expected = ["pandoc"]
+ Self.expectedIncludeInHeaders
+ ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).html"]
+ Self.expectedFiles
#expect(output.arguments == expected)
}
}
@Test(
arguments: [
nil,
"lualatex"
]
)
func generatePdf(pdfEngine: String?) async throws {
try await withCapturingCommandClient("generatePdf") {
$0.configurationClient = .mock()
$0.playbookClient.run.buildProject = { _ in }
$0.pandocClient = .liveValue
} run: {
@Dependency(\.pandocClient) var pandocClient
let output = try await pandocClient.run.generatePdf(Self.sharedRunOptions, pdfEngine: pdfEngine)
#expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).pdf")
} assert: { output in
let expected = ["pandoc"]
+ Self.expectedIncludeInHeaders
+ ["--pdf-engine=\(pdfEngine ?? "xelatex")"]
+ ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).pdf"]
+ Self.expectedFiles
#expect(output.arguments == expected)
}
}
@Test(arguments: TestPdfEngine.testCases)
func parsePdfEngine(input: TestPdfEngine) {
#expect(input.engine == input.expectedEngine)
}
@Test(arguments: TestParseFiles.testCases)
func parseFiles(input: TestParseFiles) {
#expect(input.parsedFiles == input.expectedFiles)
}
@Test(arguments: TestParseIncludeInHeaderFiles.testCases)
func parseInclueInHeaderFiles(input: TestParseIncludeInHeaderFiles) {
#expect(input.parsedFiles == input.expectedHeaderFiles)
}
@Test(arguments: TestParseOutputFileName.testCases)
func parseOutputFileName(input: TestParseOutputFileName) {
#expect(input.parsedFileName == input.expected)
}
@Test(arguments: TestParseBuildDirectory.testCases)
func parseBuildDirectory(input: TestParseBuildDirectory) {
#expect(input.parsedBuildDirectory == input.expected)
}
}
struct TestPdfEngine: Sendable {
let fileType: PandocClient.FileType
let expectedEngine: String?
let configuration: Configuration
let defaults: Configuration.Generate
var engine: String? {
fileType.parsePdfEngine(configuration.generate, defaults)
}
static let testCases: [Self] = [
.init(fileType: .html, expectedEngine: nil, configuration: .init(), defaults: .default),
.init(fileType: .latex, expectedEngine: nil, configuration: .init(), defaults: .default),
.init(fileType: .pdf(engine: "lualatex"), expectedEngine: "lualatex", configuration: .init(), defaults: .default),
.init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .default),
.init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .init()),
.init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(generate: .default), defaults: .init())
]
}
struct TestParseFiles: Sendable {
let expectedFiles: [String]
let configuration: Configuration
let defaults: Configuration.Generate
let runOptions: PandocClient.RunOptions?
init(
expectedFiles: [String],
configuration: Configuration = .init(),
defaults: Configuration.Generate = .default,
runOptions: PandocClient.RunOptions? = nil
) {
self.expectedFiles = expectedFiles
self.configuration = configuration
self.defaults = defaults
self.runOptions = runOptions
}
var parsedFiles: [String] {
let runOptions = self.runOptions ?? PandocClient.RunOptions(
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
projectDirectory: nil,
quiet: true,
shouldBuild: false
)
return runOptions.parseFiles(configuration.generate, defaults)
}
static let testCases: [Self] = [
.init(expectedFiles: ["Report.md", "Definitions.md"]),
.init(expectedFiles: ["Report.md", "Definitions.md"], configuration: .init(generate: .default), defaults: .init()),
.init(expectedFiles: [], defaults: .init()),
.init(
expectedFiles: ["custom.md"],
configuration: .init(),
defaults: .init(),
runOptions: .init(
files: ["custom.md"],
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
projectDirectory: nil,
quiet: true,
shouldBuild: false
)
)
]
}
struct TestParseIncludeInHeaderFiles: Sendable {
let expectedHeaderFiles: [String]
let configuration: Configuration
let defaults: Configuration.Generate
let runOptions: PandocClient.RunOptions?
init(
expectedHeaderFiles: [String],
configuration: Configuration = .init(),
defaults: Configuration.Generate = .default,
runOptions: PandocClient.RunOptions? = nil
) {
self.expectedHeaderFiles = expectedHeaderFiles
self.configuration = configuration
self.defaults = defaults
self.runOptions = runOptions
}
var parsedFiles: [String] {
let runOptions = self.runOptions ?? PandocClient.RunOptions(
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
)
return runOptions.parseIncludeInHeader(configuration.generate, defaults)
}
static let testCases: [Self] = [
.init(expectedHeaderFiles: ["head.tex", "footer.tex"]),
.init(expectedHeaderFiles: ["head.tex", "footer.tex"], configuration: .init(generate: .default), defaults: .init()),
.init(expectedHeaderFiles: [], defaults: .init()),
.init(
expectedHeaderFiles: ["custom.tex"],
configuration: .init(),
defaults: .init(),
runOptions: .init(
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
includeInHeader: ["custom.tex"]
)
)
]
}
struct TestParseOutputFileName: Sendable {
let expected: String
let configuration: Configuration
let defaults: Configuration.Generate
let runOptions: PandocClient.RunOptions?
init(
expected: String,
configuration: Configuration = .init(),
defaults: Configuration.Generate = .default,
runOptions: PandocClient.RunOptions? = nil
) {
self.expected = expected
self.configuration = configuration
self.defaults = defaults
self.runOptions = runOptions
}
var parsedFileName: String {
let runOptions = self.runOptions ?? PandocClient.RunOptions(
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
)
return runOptions.parseOutputFileName(configuration.generate, defaults)
}
static let testCases: [Self] = [
.init(expected: "Report"),
.init(expected: "Report", configuration: .init(generate: .default), defaults: .init()),
.init(expected: "Report", defaults: .init()),
.init(
expected: "custom",
configuration: .init(),
defaults: .init(),
runOptions: .init(
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
outputFileName: "custom"
)
)
]
}
struct TestParseBuildDirectory: Sendable {
let expected: String
let configuration: Configuration
let defaults: Configuration.Generate
let runOptions: PandocClient.RunOptions?
init(
expected: String = ".build",
configuration: Configuration = .init(),
defaults: Configuration.Generate = .default,
runOptions: PandocClient.RunOptions? = nil
) {
self.expected = expected
self.configuration = configuration
self.defaults = defaults
self.runOptions = runOptions
}
var parsedBuildDirectory: String {
let runOptions = self.runOptions ?? PandocClient.RunOptions(
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
)
return runOptions.parseBuildDirectory(configuration.generate, defaults)
}
static let testCases: [Self] = [
.init(),
.init(configuration: .init(generate: .default), defaults: .init()),
.init(defaults: .init()),
.init(
expected: "custom",
configuration: .init(),
defaults: .init(),
runOptions: .init(
buildDirectory: "custom",
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
)
)
]
}

View File

@@ -0,0 +1,308 @@
import CodersClient
@_spi(Internal) import CommandClient
@_spi(Internal) import ConfigurationClient
import Dependencies
import FileClient
import Foundation
@_spi(Internal) import PlaybookClient
import ShellClient
import Testing
import TestSupport
@Suite("PlaybookClientTests")
struct PlaybookClientTests: TestCase {
static var sharedRunOptions: PlaybookClient.RunPlaybook.SharedRunOptions {
.init(loggingOptions: loggingOptions)
}
static let defaultPlaybookPath = "~/.local/share/hpa/playbook/main.yml"
static let defaultInventoryPath = "~/.local/share/hpa/playbook/inventory.ini"
static let mockVaultArg = Configuration.mock.vault.args![0]
@Test(.tags(.repository))
func repositoryInstallation() async throws {
try await withTestLogger(key: "repositoryInstallation") {
$0.fileClient = .liveValue
$0.asyncShellClient = .liveValue
$0.commandClient = .liveValue
} operation: {
try await withTemporaryDirectory { tempDirectory in
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
let pathUrl = tempDirectory.appending(path: "playbook")
let playbookClient = PlaybookClient.liveValue
let configuration = Configuration(playbook: .init(directory: pathUrl.cleanFilePath))
try? FileManager.default.removeItem(at: pathUrl)
try await playbookClient.repository.install(configuration)
logger.debug("Done cloning playbook")
let exists = try await fileClient.isDirectory(pathUrl)
#expect(exists)
}
}
}
@Test(
.tags(.repository),
arguments: [
(Configuration(), PlaybookClient.Constants.defaultInstallationPath),
(Configuration(playbook: .init(directory: "playbook")), "playbook")
]
)
func repositoryDirectory(configuration: Configuration, expected: String) async throws {
let client = PlaybookClient.liveValue
let result = try await client.repository.directory(configuration)
#expect(result == expected)
}
@Test(.tags(.run))
func runBuildProject() async throws {
let captured = CommandClient.CapturingClient()
try await withMockConfiguration(captured, key: "runBuildProject") {
@Dependency(\.playbookClient) var playbookClient
try await playbookClient.run.buildProject(.init(projectDirectory: "/foo", shared: Self.sharedRunOptions))
let arguments = await captured.options!.arguments
print(arguments)
#expect(arguments == [
"ansible-playbook", Self.defaultPlaybookPath,
"--inventory", Self.defaultInventoryPath,
Self.mockVaultArg,
"--tags", "build-project",
"--extra-vars", "project_dir=/foo"
])
}
}
@Test(
.tags(.run),
arguments: [
(true, "\'{\"template\":{\"path\":\"\(Configuration.mock.template.directory!)\"}}\'"),
(false, "\'{\"template\":{\"repo\":{\"url\":\"\(Configuration.mock.template.url!)\",\"version\":\"\(Configuration.mock.template.version!)\"}}}\'")
]
)
func runCreateProject(useLocalTemplateDirectory: Bool, json: String) async throws {
let captured = CommandClient.CapturingClient()
try await withMockConfiguration(captured, key: "runBuildProject") {
@Dependency(\.logger) var logger
@Dependency(\.playbookClient) var playbookClient
try await playbookClient.run.createProject(
.init(
projectDirectory: "/project",
shared: Self.sharedRunOptions,
useLocalTemplateDirectory: useLocalTemplateDirectory
)
)
let arguments = await captured.options!.arguments
logger.debug("\(arguments)")
#expect(arguments == [
"ansible-playbook", Self.defaultPlaybookPath,
"--inventory", Self.defaultInventoryPath,
Self.mockVaultArg,
"--tags", "setup-project",
"--extra-vars", "project_dir=/project",
"--extra-vars", json
])
}
}
@Test(arguments: CreateJsonTestOption.testCases)
func createJson(input: CreateJsonTestOption) {
withTestLogger(key: "generateJson") {
$0.coders.jsonEncoder = { jsonEncoder }
$0.configurationClient = .mock(input.configuration)
} operation: {
@Dependency(\.coders) var coders
let jsonData = try? input.options.createJSONData(
configuration: input.configuration,
encoder: coders.jsonEncoder()
)
switch input.expectation {
case let .success(expected):
let json = String(data: jsonData!, encoding: .utf8)!
if json != expected {
print("json:", json)
print("expected:", expected)
}
#expect(json == expected)
case .failure:
#expect(jsonData == nil)
}
}
}
@Test
func generateTemplate() async throws {
try await withCapturingCommandClient("generateTemplate") {
$0.configurationClient = .mock()
$0.playbookClient = .liveValue
} run: {
@Dependency(\.playbookClient) var playbookClient
let output = try await playbookClient.run.generateTemplate(.init(
shared: Self.sharedRunOptions,
templateDirectory: "/template"
))
#expect(output == "/template")
} assert: { output in
let expected = [
"ansible-playbook", Self.defaultPlaybookPath,
"--inventory", Self.defaultInventoryPath,
Self.mockVaultArg,
"--tags", "repo-template",
"--extra-vars", "output_dir=/template"
]
#expect(output.arguments == expected)
}
}
func withMockConfiguration(
_ capturing: CommandClient.CapturingClient,
configuration: Configuration = .mock,
key: String,
logLevel: Logger.Level = .trace,
depednencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
operation: @Sendable @escaping () async throws -> Void
) async rethrows {
try await withDependencies {
$0.configurationClient = .mock(configuration)
$0.commandClient = .capturing(capturing)
$0.playbookClient = .liveValue
setupDependencies(&$0)
} operation: {
try await operation()
}
}
}
struct CreateJsonTestOption: Sendable {
let options: PlaybookClient.RunPlaybook.CreateOptions
let configuration: Configuration
let expectation: Result<String, TestError>
static let testCases: [Self] = [
CreateJsonTestOption(
options: .init(
projectDirectory: "/project",
shared: PlaybookClientTests.sharedRunOptions,
template: .init(url: nil, version: nil, directory: nil),
useLocalTemplateDirectory: true
),
configuration: .init(),
expectation: .failing
),
CreateJsonTestOption(
options: .init(
projectDirectory: "/project",
shared: PlaybookClientTests.sharedRunOptions,
template: .init(url: nil, version: nil, directory: nil),
useLocalTemplateDirectory: false
),
configuration: .init(),
expectation: .failing
),
CreateJsonTestOption(
options: .init(
projectDirectory: "/project",
shared: PlaybookClientTests.sharedRunOptions,
template: .init(url: nil, version: nil, directory: "/template"),
useLocalTemplateDirectory: true
),
configuration: .init(template: .init(directory: "/template")),
expectation: .success("""
{
"template" : {
"path" : "/template"
}
}
""")
),
CreateJsonTestOption(
options: .init(
projectDirectory: "/project",
shared: PlaybookClientTests.sharedRunOptions,
template: .init(url: nil, version: nil, directory: "/template"),
useLocalTemplateDirectory: true
),
configuration: .init(template: .init(directory: "/template")),
expectation: .success("""
{
"template" : {
"path" : "/template"
}
}
""")
),
CreateJsonTestOption(
options: .init(
projectDirectory: "/project",
shared: PlaybookClientTests.sharedRunOptions,
template: .init(url: "https://git.example.com/template.git", version: "main", directory: nil),
useLocalTemplateDirectory: false
),
configuration: .init(),
expectation: .success("""
{
"template" : {
"repo" : {
"url" : "https://git.example.com/template.git",
"version" : "main"
}
}
}
""")
),
CreateJsonTestOption(
options: .init(
projectDirectory: "/project",
shared: PlaybookClientTests.sharedRunOptions,
template: .init(url: "https://git.example.com/template.git", version: "v0.1.0", directory: nil),
useLocalTemplateDirectory: false
),
configuration: .init(template: .init(url: "https://git.example.com/template.git", version: "v0.1.0")),
expectation: .success("""
{
"template" : {
"repo" : {
"url" : "https://git.example.com/template.git",
"version" : "v0.1.0"
}
}
}
""")
)
]
}
extension Result where Failure == TestError {
static var failing: Self { .failure(TestError()) }
}
struct TestError: Error {}
extension Tag {
@Tag static var repository: Self
@Tag static var run: Self
}
let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys]
return encoder
}()

View File

@@ -0,0 +1,129 @@
@_spi(Internal) import ConfigurationClient
import FileClient
import Foundation
import Testing
import TestSupport
@_spi(Internal) import VaultClient
@Suite("VaultClientTests")
struct VaultClientTests: TestCase {
@Test(
arguments: TestOptions.testCases
)
func decrypt(input: TestOptions) async throws {
try await withCapturingCommandClient("decrypt") {
$0.configurationClient = .mock(input.configuration)
$0.fileClient.findVaultFile = { _ in URL(filePath: "/vault.yml") }
$0.vaultClient = .liveValue
} run: {
@Dependency(\.vaultClient) var vaultClient
let output = try await vaultClient.run.decrypt(.init(
extraOptions: input.extraOptions,
loggingOptions: Self.loggingOptions,
outputFilePath: input.outputFilePath,
vaultFilePath: input.vaultFilePath
))
if let outputFilePath = input.outputFilePath {
#expect(output == outputFilePath)
} else if let vaultFilePath = input.vaultFilePath {
#expect(output == vaultFilePath)
} else {
#expect(output == "/vault.yml")
}
} assert: { options in
#expect(options.arguments == input.expected(.decrypt))
}
}
@Test(
arguments: TestOptions.testCases
)
func encrypt(input: TestOptions) async throws {
try await withCapturingCommandClient("decrypt") {
$0.configurationClient = .mock(input.configuration)
$0.fileClient.findVaultFile = { _ in URL(filePath: "/vault.yml") }
$0.vaultClient = .liveValue
} run: {
@Dependency(\.vaultClient) var vaultClient
let output = try await vaultClient.run.encrypt(.init(
extraOptions: input.extraOptions,
loggingOptions: Self.loggingOptions,
outputFilePath: input.outputFilePath,
vaultFilePath: input.vaultFilePath
))
if let outputFilePath = input.outputFilePath {
#expect(output == outputFilePath)
} else if let vaultFilePath = input.vaultFilePath {
#expect(output == vaultFilePath)
} else {
#expect(output == "/vault.yml")
}
} assert: { options in
#expect(options.arguments == input.expected(.encrypt))
}
}
}
struct TestOptions: Sendable {
let configuration: Configuration
let extraOptions: [String]?
let outputFilePath: String?
let vaultFilePath: String?
init(
configuration: Configuration = .init(),
extraOptions: [String]? = nil,
outputFilePath: String? = nil,
vaultFilePath: String? = nil
) {
self.configuration = configuration
self.extraOptions = extraOptions
self.outputFilePath = outputFilePath
self.vaultFilePath = vaultFilePath
}
func expected(_ route: VaultClient.Route) -> [String] {
var expected = [
"ansible-vault", "\(route.verb)"
]
if let outputFilePath {
expected.append(contentsOf: ["--output", outputFilePath])
}
if let extraOptions {
expected.append(contentsOf: extraOptions)
}
if let vaultArgs = configuration.vault.args {
expected.append(contentsOf: vaultArgs)
}
if route == .encrypt,
let id = configuration.vault.encryptId
{
expected.append(contentsOf: ["--encrypt-vault-id", id])
}
expected.append(vaultFilePath ?? "/vault.yml")
return expected
}
static let testCases: [Self] = [
TestOptions(vaultFilePath: "/vault.yml"),
TestOptions(extraOptions: ["--verbose"]),
TestOptions(configuration: .mock),
TestOptions(outputFilePath: "/output.yml")
]
}
struct TestError: Error {}

23
docker/Dockerfile Executable file
View File

@@ -0,0 +1,23 @@
# Used this to build the release version of the image.
# Build the executable
ARG SWIFT_IMAGE_VERSION="6.0.3"
FROM swift:${SWIFT_IMAGE_VERSION} AS build
WORKDIR /build
COPY ./Package.* ./
RUN swift package resolve
COPY . .
RUN swift build -c release -Xswiftc -g
# Run image
FROM swift:${SWIFT_IMAGE_VERSION}-slim
RUN export DEBIAN_FRONTEND=nointeractive DEBCONF_NOINTERACTIVE_SEEN=true && apt-get -q update && \
apt-get -q install -y \
ansible \
pandoc \
texlive \
&& rm -r /var/lib/apt/lists/*
COPY --from=build /build/.build/release/hpa /usr/local/bin
CMD ["/bin/bash", "-xc", "/usr/local/bin/hpa"]

10
docker/Dockerfile.test Normal file
View File

@@ -0,0 +1,10 @@
# Used to build a test image.
ARG SWIFT_IMAGE_VERSION="6.0.3"
FROM swift:${SWIFT_IMAGE_VERSION}
WORKDIR /app
COPY ./Package.* ./
RUN swift package resolve
COPY . .
RUN swift build
CMD ["/bin/bash", "-xc", "swift", "test"]

View File

@@ -1,18 +1,63 @@
docker_image_name := "swift-hpa"
install_path := "~/.local/share/bin/hpa"
completion_path := "~/.local/share/zsh/completions/_hpa"
tap_url := "https://git.housh.dev/michael/homebrew-formula"
tap := "michael/formula"
formula := "hpa"
# Build and bottle homebrew formula.
bottle:
@brew uninstall {{formula}} || true
@brew tap {{tap}} {{tap_url}}
@brew install --build-bottle {{formula}}
@brew bottle {{formula}} --json
bottle="$(ls *.gz)" && mv "${bottle}" "${bottle/--/-}"
# Build the command-line tool.
build mode="debug":
swift build -c {{mode}}
alias b := build
# Build the docker image.
build-docker file="Dockerfile" tag="latest":
@docker build \
--file docker/{{file}} \
--tag {{docker_image_name}}:{{tag}} .
build-docker-test: (build-docker "Dockerfile.test" "test")
# Build the docker test image used for testing.
build-docker-test: (build-docker "Dockerfile.test" "test")
# Run tests.
test *ARGS:
swift test {{ARGS}}
alias t := test
# Run tests in docker container.
test-docker *ARGS: (build-docker-test)
@docker run --rm \
--network host \
{{docker_image_name}}:test \
swift test {{ARGS}}
# Run the application.
run *ARGS:
swift run hpa {{ARGS}}
alias r := run
# Clean the build folder.
clean:
rm -rf .build
# Bump the version based on the git tag.
update-version:
@swift package \
--disable-sandbox \
--allow-writing-to-package-directory \
update-version \
hpa