Compare commits
31 Commits
0.1.1
...
8c402f3f5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
8c402f3f5f
|
|||
|
1429c51821
|
|||
|
1302b15ee2
|
|||
|
da810d0a45
|
|||
|
35d9422f07
|
|||
|
601869d457
|
|||
|
6d0108da0c
|
|||
|
bc0b740f95
|
|||
|
303cdef84b
|
|||
|
b5afc77428
|
|||
|
3f56dda568
|
|||
|
d1b3379815
|
|||
|
b557a60fa3
|
|||
|
5f4ef3b5b5
|
|||
|
ba1e61d99e
|
|||
|
bd56660683
|
|||
|
a480e942bc
|
|||
|
2b265a4ca5
|
|||
|
56a0bca00c
|
|||
|
7b30b78b67
|
|||
|
ce6eb3ec2f
|
|||
|
8302ede99e
|
|||
|
d9e91538fb
|
|||
|
c1a14ea855
|
|||
|
ddb5e6767a
|
|||
|
9c784d4dcb
|
|||
|
92cd6afa2b
|
|||
|
87390c4b63
|
|||
|
a8d35fed37
|
|||
|
357914ba93
|
|||
|
a39536364b
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,3 +6,7 @@ DerivedData/
|
|||||||
.swiftpm/configuration/registries.json
|
.swiftpm/configuration/registries.json
|
||||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
.netrc
|
.netrc
|
||||||
|
.nvim/*
|
||||||
|
.swiftpm/*
|
||||||
|
./hpa.toml
|
||||||
|
./Version.*
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ disabled_rules:
|
|||||||
- closing_brace
|
- closing_brace
|
||||||
- fuction_body_length
|
- fuction_body_length
|
||||||
- opening_brace
|
- opening_brace
|
||||||
|
- nesting
|
||||||
|
|
||||||
included:
|
included:
|
||||||
- Sources
|
- Sources
|
||||||
|
|||||||
31
.swiftpm/swift-hpa-Package.xctestplan
Normal file
31
.swiftpm/swift-hpa-Package.xctestplan
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"configurations" : [
|
||||||
|
{
|
||||||
|
"id" : "BE1E3DDC-11A9-41D4-B82D-5EF22CCB79D9",
|
||||||
|
"name" : "Configuration 1",
|
||||||
|
"options" : {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultOptions" : {
|
||||||
|
"testTimeoutsEnabled" : true
|
||||||
|
},
|
||||||
|
"testTargets" : [
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:",
|
||||||
|
"identifier" : "CliClientTests",
|
||||||
|
"name" : "CliClientTests"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:",
|
||||||
|
"identifier" : "ConfigurationClientTests",
|
||||||
|
"name" : "ConfigurationClientTests"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "8767e1814bf3d2b706110688b4b4d253de070cc5be3f0f91d5790acb7d0b7ad5",
|
"originHash" : "a3b90cdeec7e11e73a0a47c1f8dffa472c22f0c6385282eb11096210e3e7ad8b",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "combine-schedulers",
|
"identity" : "combine-schedulers",
|
||||||
@@ -28,6 +28,24 @@
|
|||||||
"version" : "1.5.0"
|
"version" : "1.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-cli-doc",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://git.housh.dev/michael/swift-cli-doc.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "e524056dc65c5ce7a6a77bdea4e5fa0bf724019b",
|
||||||
|
"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",
|
"identity" : "swift-clocks",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -91,6 +109,15 @@
|
|||||||
"version" : "600.0.1"
|
"version" : "600.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "tomlkit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/LebJe/TOMLKit.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "ec6198d37d495efc6acd4dffbd262cdca7ff9b3f",
|
||||||
|
"version" : "0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "xctest-dynamic-overlay",
|
"identity" : "xctest-dynamic-overlay",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
118
Package.swift
118
Package.swift
@@ -1,5 +1,4 @@
|
|||||||
// swift-tools-version: 6.0
|
// swift-tools-version: 6.0
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
@@ -9,20 +8,32 @@ let package = Package(
|
|||||||
products: [
|
products: [
|
||||||
.executable(name: "hpa", targets: ["hpa"]),
|
.executable(name: "hpa", targets: ["hpa"]),
|
||||||
.library(name: "CliClient", targets: ["CliClient"]),
|
.library(name: "CliClient", targets: ["CliClient"]),
|
||||||
.library(name: "CliDoc", targets: ["CliDoc"])
|
.library(name: "CodersClient", targets: ["CodersClient"]),
|
||||||
|
.library(name: "CommandClient", targets: ["CommandClient"]),
|
||||||
|
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"]),
|
||||||
|
.library(name: "FileClient", targets: ["FileClient"]),
|
||||||
|
.library(name: "PlaybookClient", targets: ["PlaybookClient"]),
|
||||||
|
.library(name: "VaultClient", targets: ["VaultClient"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
|
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
|
||||||
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.5.2"),
|
.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://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: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "hpa",
|
name: "hpa",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"CliClient",
|
"CliClient",
|
||||||
"CliDoc",
|
"ConfigurationClient",
|
||||||
|
"FileClient",
|
||||||
|
"PlaybookClient",
|
||||||
|
"VaultClient",
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
|
.product(name: "CliDoc", package: "swift-cli-doc"),
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
.product(name: "ShellClient", package: "swift-shell-client")
|
.product(name: "ShellClient", package: "swift-shell-client")
|
||||||
]
|
]
|
||||||
@@ -30,19 +41,108 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "CliClient",
|
name: "CliClient",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
"CommandClient",
|
||||||
|
"CodersClient",
|
||||||
|
"ConfigurationClient",
|
||||||
|
"PlaybookClient",
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
.product(name: "ShellClient", package: "swift-shell-client")
|
.product(name: "ShellClient", package: "swift-shell-client")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.testTarget(name: "CliClientTests", dependencies: ["CliClient"]),
|
.testTarget(
|
||||||
.target(
|
name: "CliClientTests",
|
||||||
name: "CliDoc",
|
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
"CliClient",
|
||||||
|
"TestSupport"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "CodersClient",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
|
.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")
|
.product(name: "ShellClient", package: "swift-shell-client")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.testTarget(name: "CliDocTests", dependencies: ["CliDoc"])
|
.target(name: "Constants"),
|
||||||
|
.target(
|
||||||
|
name: "TestSupport",
|
||||||
|
dependencies: [
|
||||||
|
"CommandClient",
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "ShellClient", package: "swift-shell-client")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "ConfigurationClient",
|
||||||
|
dependencies: [
|
||||||
|
"CodersClient",
|
||||||
|
"FileClient",
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
|
.product(name: "ShellClient", package: "swift-shell-client")
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.copy("Resources/hpa.toml")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "ConfigurationClientTests",
|
||||||
|
dependencies: [
|
||||||
|
"ConfigurationClient",
|
||||||
|
"TestSupport"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "FileClient",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "DependenciesMacros", package: "swift-dependencies")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "PlaybookClient",
|
||||||
|
dependencies: [
|
||||||
|
"CodersClient",
|
||||||
|
"CommandClient",
|
||||||
|
"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"]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
189
Sources/CliClient/CliClient+Commands.swift
Normal file
189
Sources/CliClient/CliClient+Commands.swift
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import ConfigurationClient
|
||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import FileClient
|
||||||
|
import Foundation
|
||||||
|
import PlaybookClient
|
||||||
|
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 installDependencies(
|
||||||
|
quiet: Bool = false,
|
||||||
|
shell: String? = nil,
|
||||||
|
extraArgs: [String]? = nil
|
||||||
|
) async throws {
|
||||||
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
@Dependency(\.configurationClient) var configurationClient
|
||||||
|
|
||||||
|
var arguments = [
|
||||||
|
"brew", "install"
|
||||||
|
] + Constants.brewPackages
|
||||||
|
|
||||||
|
if let extraArgs {
|
||||||
|
arguments.append(contentsOf: extraArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await runCommand(
|
||||||
|
quiet: quiet,
|
||||||
|
shell: shell.orDefault,
|
||||||
|
arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
let configuration = try await configurationClient.findAndLoad()
|
||||||
|
try await playbookClient.repository.install(configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPlaybookCommand(
|
||||||
|
_ options: PlaybookOptions,
|
||||||
|
logging loggingOptions: LoggingOptions
|
||||||
|
) async throws {
|
||||||
|
try await withLogger(loggingOptions) {
|
||||||
|
@Dependency(\.configurationClient) var configurationClient
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
|
let configuration = try await configurationClient.ensuredConfiguration(options.configuration)
|
||||||
|
logger.trace("Configuration: \(configuration)")
|
||||||
|
|
||||||
|
let playbookDirectory = try await playbookClient.repository.directory(configuration)
|
||||||
|
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 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 {}
|
||||||
223
Sources/CliClient/CliClient+PandocCommands.swift
Normal file
223
Sources/CliClient/CliClient+PandocCommands.swift
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import ConfigurationClient
|
||||||
|
import Dependencies
|
||||||
|
import Foundation
|
||||||
|
import ShellClient
|
||||||
|
|
||||||
|
// TODO: Need to parse / ensure that header includes and files are in the build directory, not sure
|
||||||
|
// exactly how to handle if they're not, but it seems reasonable to potentially allow files that are
|
||||||
|
// outside of the build directory to be included.
|
||||||
|
|
||||||
|
public extension CliClient {
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func runPandocCommand(
|
||||||
|
_ options: PandocOptions,
|
||||||
|
logging loggingOptions: LoggingOptions
|
||||||
|
) async throws -> String {
|
||||||
|
try await withLogger(loggingOptions) {
|
||||||
|
@Dependency(\.configurationClient) var configurationClient
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
let configuration = try await configurationClient.findAndLoad()
|
||||||
|
logger.trace("Configuration: \(configuration)")
|
||||||
|
|
||||||
|
let ensuredOptions = try await ensurePandocOptions(
|
||||||
|
configuration: configuration,
|
||||||
|
options: options
|
||||||
|
)
|
||||||
|
|
||||||
|
let projectDirectory = options.projectDirectory ?? ProcessInfo.processInfo.environment["PWD"]
|
||||||
|
guard let projectDirectory else {
|
||||||
|
throw CliClientError.generate(.projectDirectoryNotSpecified)
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputDirectory = options.outputDirectory ?? projectDirectory
|
||||||
|
let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredExtensionFileName)"
|
||||||
|
|
||||||
|
var arguments = [
|
||||||
|
"pandoc"
|
||||||
|
]
|
||||||
|
arguments += ensuredOptions.includeInHeader.map {
|
||||||
|
"--include-in-header=\(projectDirectory)/\(ensuredOptions.buildDirectory)/\($0)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if let pdfEngine = ensuredOptions.pdfEngine {
|
||||||
|
arguments.append("--pdf-engine=\(pdfEngine)")
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments.append("--output=\(outputPath)")
|
||||||
|
arguments += ensuredOptions.files.map {
|
||||||
|
"\(projectDirectory)/\(ensuredOptions.buildDirectory)/\($0)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.shouldBuild {
|
||||||
|
logger.trace("Building project...")
|
||||||
|
try await runPlaybookCommand(
|
||||||
|
.init(
|
||||||
|
arguments: [
|
||||||
|
"--tags", "build-project",
|
||||||
|
"--extra-vars", "project_dir=\(projectDirectory)"
|
||||||
|
],
|
||||||
|
configuration: configuration,
|
||||||
|
quiet: options.quiet,
|
||||||
|
shell: options.shell
|
||||||
|
),
|
||||||
|
logging: loggingOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.trace("Running pandoc with arguments: \(arguments)")
|
||||||
|
|
||||||
|
try await runCommand(
|
||||||
|
quiet: options.quiet,
|
||||||
|
shell: options.shell.orDefault,
|
||||||
|
arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
return outputPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public struct EnsuredPandocOptions: Equatable, Sendable {
|
||||||
|
public let buildDirectory: String
|
||||||
|
public let files: [String]
|
||||||
|
public let includeInHeader: [String]
|
||||||
|
public let outputFileName: String
|
||||||
|
public let outputFileType: CliClient.PandocOptions.FileType
|
||||||
|
public let pdfEngine: String?
|
||||||
|
|
||||||
|
public var ensuredExtensionFileName: String {
|
||||||
|
let extensionString: String
|
||||||
|
|
||||||
|
switch outputFileType {
|
||||||
|
case .html:
|
||||||
|
extensionString = ".html"
|
||||||
|
case .latex:
|
||||||
|
extensionString = ".tex"
|
||||||
|
case .pdf:
|
||||||
|
extensionString = ".pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !outputFileName.hasSuffix(extensionString) {
|
||||||
|
return outputFileName + extensionString
|
||||||
|
}
|
||||||
|
return outputFileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public func ensurePandocOptions(
|
||||||
|
configuration: Configuration,
|
||||||
|
options: CliClient.PandocOptions
|
||||||
|
) async throws -> EnsuredPandocOptions {
|
||||||
|
let defaults = Configuration.Generate.default
|
||||||
|
let pdfEngine = parsePdfEngine(configuration.generate, defaults, options)
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
buildDirectory: parseBuildDirectory(configuration.generate, defaults, options),
|
||||||
|
files: parseFiles(configuration.generate, defaults, options),
|
||||||
|
includeInHeader: parseIncludeInHeader(configuration.generate, defaults, options),
|
||||||
|
outputFileName: parseOutputFileName(configuration.generate, defaults, options),
|
||||||
|
outputFileType: options.outputFileType,
|
||||||
|
pdfEngine: pdfEngine
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parsePdfEngine(
|
||||||
|
_ configuration: Configuration.Generate?,
|
||||||
|
_ defaults: Configuration.Generate,
|
||||||
|
_ options: CliClient.PandocOptions
|
||||||
|
) -> String? {
|
||||||
|
switch options.outputFileType {
|
||||||
|
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 "xelatex"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseFiles(
|
||||||
|
_ configuration: Configuration.Generate?,
|
||||||
|
_ defaults: Configuration.Generate,
|
||||||
|
_ options: CliClient.PandocOptions
|
||||||
|
) -> [String] {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
if let files = options.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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseIncludeInHeader(
|
||||||
|
_ configuration: Configuration.Generate?,
|
||||||
|
_ defaults: Configuration.Generate,
|
||||||
|
_ options: CliClient.PandocOptions
|
||||||
|
) -> [String] {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
if let files = options.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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseOutputFileName(
|
||||||
|
_ configuration: Configuration.Generate?,
|
||||||
|
_ defaults: Configuration.Generate,
|
||||||
|
_ options: CliClient.PandocOptions
|
||||||
|
) -> String {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
if let output = options.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 "Report"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseBuildDirectory(
|
||||||
|
_ configuration: Configuration.Generate?,
|
||||||
|
_ defaults: Configuration.Generate,
|
||||||
|
_ options: CliClient.PandocOptions
|
||||||
|
) -> String {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
if let output = options.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
216
Sources/CliClient/CliClient+RunPlaybook.swift
Normal file
216
Sources/CliClient/CliClient+RunPlaybook.swift
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import ConfigurationClient
|
||||||
|
import Dependencies
|
||||||
|
import Foundation
|
||||||
|
import PlaybookClient
|
||||||
|
|
||||||
|
extension CliClient.RunPlaybook {
|
||||||
|
|
||||||
|
static func makeCommonArguments(
|
||||||
|
configuration: Configuration,
|
||||||
|
inventoryFilePath: String?
|
||||||
|
) async throws -> [String] {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
|
let playbookDirectory = try await playbookClient.repository.directory(configuration)
|
||||||
|
let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)"
|
||||||
|
logger.trace("Playbook path: \(playbookPath)")
|
||||||
|
|
||||||
|
let inventoryPath = ensuredInventoryPath(
|
||||||
|
inventoryFilePath,
|
||||||
|
configuration: configuration,
|
||||||
|
playbookDirectory: playbookDirectory
|
||||||
|
)
|
||||||
|
logger.trace("Inventory path: \(inventoryPath)")
|
||||||
|
|
||||||
|
var arguments = [
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CliClient.RunPlaybook.BuildOptions {
|
||||||
|
private func applyArguments(
|
||||||
|
to arguments: inout [String],
|
||||||
|
configuration: Configuration
|
||||||
|
) throws {
|
||||||
|
let projectDirectory = projectDirectory
|
||||||
|
?? ProcessInfo.processInfo.environment["PWD"]
|
||||||
|
|
||||||
|
guard let projectDirectory else {
|
||||||
|
throw CliClientError.projectDirectoryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments.append(contentsOf: [
|
||||||
|
"--tags", "build-project",
|
||||||
|
"--extra-vars", "project_dir=\(projectDirectory)"
|
||||||
|
])
|
||||||
|
|
||||||
|
if let extraOptions = extraOptions {
|
||||||
|
arguments.append(contentsOf: extraOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CliClient.RunPlaybook.CreateOptions {
|
||||||
|
private func applyArguments(
|
||||||
|
to arguments: inout [String],
|
||||||
|
configuration: Configuration
|
||||||
|
) throws {
|
||||||
|
let json = try createJSONData(configuration: configuration)
|
||||||
|
|
||||||
|
arguments.append(contentsOf: [
|
||||||
|
"--tags", "setup-project",
|
||||||
|
"--extra-vars", "project_dir=\(projectDirectory)",
|
||||||
|
"--extra-vars", "'\(json)'"
|
||||||
|
])
|
||||||
|
|
||||||
|
if let extraOptions {
|
||||||
|
arguments.append(contentsOf: extraOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CliClient.PlaybookOptions.Route {
|
||||||
|
|
||||||
|
private func parseInventoryPath(
|
||||||
|
_ configuration: Configuration,
|
||||||
|
_ playbookDirectory: String
|
||||||
|
) -> String {
|
||||||
|
let inventoryFilePath: String?
|
||||||
|
|
||||||
|
switch self {
|
||||||
|
case let .build(options):
|
||||||
|
inventoryFilePath = options.inventoryFilePath
|
||||||
|
case let .create(options):
|
||||||
|
inventoryFilePath = options.inventoryFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensuredInventoryPath(
|
||||||
|
inventoryFilePath,
|
||||||
|
configuration: configuration,
|
||||||
|
playbookDirectory: playbookDirectory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeArguments(configuration: Configuration) async throws -> [String] {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
|
let playbookDirectory = try await playbookClient.repository.directory(configuration)
|
||||||
|
let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)"
|
||||||
|
logger.trace("Playbook path: \(playbookPath)")
|
||||||
|
|
||||||
|
let inventoryPath = parseInventoryPath(configuration, playbookDirectory)
|
||||||
|
logger.trace("Inventory path: \(inventoryPath)")
|
||||||
|
|
||||||
|
var arguments = [
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// try applyArguments(to: &arguments, configuration: configuration)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
extension CliClient.RunPlaybook.CreateOptions {
|
||||||
|
|
||||||
|
func createJSONData(
|
||||||
|
configuration: Configuration,
|
||||||
|
encoder: JSONEncoder = .init()
|
||||||
|
) throws -> Data {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
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 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: .init(url: repo, version: version ?? "main"))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Template: Encodable {
|
||||||
|
let repo: Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Repo: Encodable {
|
||||||
|
let url: String
|
||||||
|
let version: String
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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 decoder: @Sendable () -> JSONDecoder = { .init() }
|
|
||||||
public var encoder: @Sendable () -> JSONEncoder = { .init() }
|
|
||||||
public var loadConfiguration: @Sendable () throws -> Configuration
|
|
||||||
public var runCommand: @Sendable ([String], Bool, ShellCommand.Shell) async throws -> Void
|
|
||||||
public var createConfiguration: @Sendable (_ path: String, _ json: Bool) throws -> Void
|
|
||||||
public var findVaultFileInCurrentDirectory: @Sendable () throws -> String
|
|
||||||
|
|
||||||
public func runCommand(
|
|
||||||
quiet: Bool,
|
|
||||||
shell: ShellCommand.Shell,
|
|
||||||
_ args: [String]
|
|
||||||
) async throws {
|
|
||||||
try await runCommand(args, quiet, shell)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func runCommand(
|
|
||||||
quiet: Bool,
|
|
||||||
shell: ShellCommand.Shell,
|
|
||||||
_ args: String...
|
|
||||||
) async throws {
|
|
||||||
try await runCommand(args, quiet, shell)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CliClient: DependencyKey {
|
|
||||||
|
|
||||||
public static func live(
|
|
||||||
decoder: JSONDecoder = .init(),
|
|
||||||
encoder: JSONEncoder = .init(),
|
|
||||||
env: [String: String]
|
|
||||||
) -> Self {
|
|
||||||
@Dependency(\.fileClient) var fileClient
|
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
|
|
||||||
return .init {
|
|
||||||
decoder
|
|
||||||
} encoder: {
|
|
||||||
encoder
|
|
||||||
} loadConfiguration: {
|
|
||||||
let url = try findConfigurationFiles(env: env)
|
|
||||||
var env = env
|
|
||||||
|
|
||||||
logger.trace("Loading configuration from: \(url)")
|
|
||||||
try fileClient.loadFile(url, &env, decoder)
|
|
||||||
|
|
||||||
return try .fromEnv(env)
|
|
||||||
|
|
||||||
} runCommand: { args, quiet, shell in
|
|
||||||
@Dependency(\.asyncShellClient) var shellClient
|
|
||||||
if !quiet {
|
|
||||||
try await shellClient.foreground(.init(
|
|
||||||
shell: shell,
|
|
||||||
environment: ProcessInfo.processInfo.environment,
|
|
||||||
in: nil,
|
|
||||||
args
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
try await shellClient.background(.init(
|
|
||||||
shell: shell,
|
|
||||||
environment: ProcessInfo.processInfo.environment,
|
|
||||||
in: nil,
|
|
||||||
args
|
|
||||||
))
|
|
||||||
}
|
|
||||||
} createConfiguration: { path, json in
|
|
||||||
|
|
||||||
// Early out if a file exists at the path already.
|
|
||||||
guard !fileClient.fileExists(path) else {
|
|
||||||
throw CliClientError.fileExistsAtPath(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
var path = path
|
|
||||||
let data: Data
|
|
||||||
|
|
||||||
if !json {
|
|
||||||
// Write the default env template.
|
|
||||||
data = Data(Configuration.fileTemplate.utf8)
|
|
||||||
} else {
|
|
||||||
if !path.contains(".json") {
|
|
||||||
path += ".json"
|
|
||||||
}
|
|
||||||
data = try jsonEncoder.encode(Configuration.mock)
|
|
||||||
}
|
|
||||||
|
|
||||||
try fileClient.write(path, data)
|
|
||||||
} findVaultFileInCurrentDirectory: {
|
|
||||||
guard let url = try fileClient.findVaultFileInCurrentDirectory() else {
|
|
||||||
throw CliClientError.vaultFileNotFound
|
|
||||||
}
|
|
||||||
return path(for: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static var liveValue: CliClient {
|
|
||||||
.live(env: ProcessInfo.processInfo.environment)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static let testValue: CliClient = Self()
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CliClientError: Error {
|
|
||||||
case fileExistsAtPath(String)
|
|
||||||
case vaultFileNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
private let jsonEncoder: JSONEncoder = {
|
|
||||||
var encoder = JSONEncoder()
|
|
||||||
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
|
|
||||||
return encoder
|
|
||||||
}()
|
|
||||||
16
Sources/CliClient/CliClientError.swift
Normal file
16
Sources/CliClient/CliClientError.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum CliClientError: Error {
|
||||||
|
case brewfileNotFound
|
||||||
|
case encodingError
|
||||||
|
case playbookDirectoryNotFound
|
||||||
|
case projectDirectoryNotFound
|
||||||
|
case generate(GenerateError)
|
||||||
|
case templateDirectoryNotFound
|
||||||
|
case templateDirectoryOrRepoNotSpecified
|
||||||
|
case vaultFileNotFound
|
||||||
|
|
||||||
|
public enum GenerateError: Sendable {
|
||||||
|
case projectDirectoryNotSpecified
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import Dependencies
|
|
||||||
import Foundation
|
|
||||||
import ShellClient
|
|
||||||
|
|
||||||
/// Represents the configuration.
|
|
||||||
public struct Configuration: Codable, Sendable {
|
|
||||||
|
|
||||||
public let playbookDir: String?
|
|
||||||
public let inventoryPath: String?
|
|
||||||
public let templateRepo: String?
|
|
||||||
public let templateRepoVersion: String?
|
|
||||||
public let templateDir: String?
|
|
||||||
public let defaultPlaybookArgs: [String]?
|
|
||||||
public let defaultVaultArgs: [String]?
|
|
||||||
public let defaultVaultEncryptId: String?
|
|
||||||
|
|
||||||
fileprivate enum CodingKeys: String, CodingKey {
|
|
||||||
case playbookDir = "HPA_PLAYBOOK_DIR"
|
|
||||||
case inventoryPath = "HPA_DEFAULT_INVENTORY"
|
|
||||||
case templateRepo = "HPA_TEMPLATE_REPO"
|
|
||||||
case templateRepoVersion = "HPA_TEMPLATE_VERSION"
|
|
||||||
case templateDir = "HPA_TEMPLATE_DIR"
|
|
||||||
case defaultPlaybookArgs = "HPA_DEFAULT_PLAYBOOK_ARGS"
|
|
||||||
case defaultVaultArgs = "HPA_DEFAULT_VAULT_ARGS"
|
|
||||||
case defaultVaultEncryptId = "HPA_DEFAULT_VAULT_ENCRYPT_ID"
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func fromEnv(
|
|
||||||
_ env: [String: String]
|
|
||||||
) throws -> Self {
|
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
|
|
||||||
logger.trace("Creating configuration from env...")
|
|
||||||
|
|
||||||
let hpaValues: [String: String] = env.filter { $0.key.contains("HPA") }
|
|
||||||
logger.debug("HPA env vars: \(hpaValues)")
|
|
||||||
|
|
||||||
return Configuration(
|
|
||||||
playbookDir: hpaValues.value(for: .playbookDir),
|
|
||||||
inventoryPath: hpaValues.value(for: .inventoryPath),
|
|
||||||
templateRepo: hpaValues.value(for: .templateRepo),
|
|
||||||
templateRepoVersion: hpaValues.value(for: .templateRepoVersion),
|
|
||||||
templateDir: hpaValues.value(for: .templateDir),
|
|
||||||
defaultPlaybookArgs: hpaValues.array(for: .defaultPlaybookArgs),
|
|
||||||
defaultVaultArgs: hpaValues.array(for: .defaultVaultArgs),
|
|
||||||
defaultVaultEncryptId: hpaValues.value(for: .defaultVaultEncryptId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static var mock: Self {
|
|
||||||
.init(
|
|
||||||
playbookDir: "/path/to/playbook",
|
|
||||||
inventoryPath: "/path/to/inventory.ini",
|
|
||||||
templateRepo: "https://git.example.com/consult-template.git",
|
|
||||||
templateRepoVersion: "main",
|
|
||||||
templateDir: "/path/to/local/template",
|
|
||||||
defaultPlaybookArgs: ["--tags", "debug"],
|
|
||||||
defaultVaultArgs: ["--vault-id=myId@$SCRIPTS/vault-gopass-client"],
|
|
||||||
defaultVaultEncryptId: "myId"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static var fileTemplate: String {
|
|
||||||
"""
|
|
||||||
# vi: ft=sh
|
|
||||||
|
|
||||||
# Example configuration, uncomment the lines and set the values appropriate for your
|
|
||||||
# usage.
|
|
||||||
|
|
||||||
# Set this to the location of the ansible-hpa-playbook on your local machine.
|
|
||||||
#HPA_PLAYBOOK_DIR="/path/to/ansible-hpa-playbook"
|
|
||||||
|
|
||||||
# Set this to the location of a template repository, which is used to create new assessment projects.
|
|
||||||
#HPA_TEMPLATE_REPO="https://git.example.com/your/template.git"
|
|
||||||
|
|
||||||
# Specify a branch, version, or sha of the template repository.
|
|
||||||
#HPA_TEMPLATE_VERSION="main" # branch, version, or sha
|
|
||||||
|
|
||||||
# Set this to a location of a template directory to use to create new projects.
|
|
||||||
#HPA_TEMPLATE_DIR="/path/to/local/template"
|
|
||||||
|
|
||||||
# Extra arguments that get passed directly to the ansible-playbook command.
|
|
||||||
#HPA_DEFAULT_PLAYBOOK_ARGS="--vault-id=consults@$SCRIPTS/vault-gopass-client"
|
|
||||||
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension [String: String] {
|
|
||||||
fileprivate func value(for codingKey: Configuration.CodingKeys) -> String? {
|
|
||||||
self[codingKey.rawValue]
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate func array(for codingKey: Configuration.CodingKeys) -> [String]? {
|
|
||||||
value(for: codingKey).flatMap { $0.split(separator: ",").map(String.init) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
Sources/CliClient/Constants.swift
Normal file
11
Sources/CliClient/Constants.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
enum Constants {
|
||||||
|
static let executableName = "hpa"
|
||||||
|
static let playbookBundleDirectoryName = "ansible-hpa-playbook"
|
||||||
|
static let playbookCommand = "ansible-playbook"
|
||||||
|
static let playbookFileName = "main.yml"
|
||||||
|
static let inventoryFileName = "inventory.ini"
|
||||||
|
static let vaultCommand = "ansible-vault"
|
||||||
|
static let brewPackages = [
|
||||||
|
"ansible", "imagemagick", "pandoc", "texLive"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import Dependencies
|
|
||||||
import DependenciesMacros
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@_spi(Internal)
|
|
||||||
public extension DependencyValues {
|
|
||||||
var fileClient: FileClient {
|
|
||||||
get { self[FileClient.self] }
|
|
||||||
set { self[FileClient.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@_spi(Internal)
|
|
||||||
@DependencyClient
|
|
||||||
public struct FileClient: Sendable {
|
|
||||||
public var loadFile: @Sendable (URL, inout [String: String], JSONDecoder) throws -> Void
|
|
||||||
public var homeDir: @Sendable () -> URL = { URL(string: "~/")! }
|
|
||||||
public var isDirectory: @Sendable (URL) -> Bool = { _ in false }
|
|
||||||
public var isReadable: @Sendable (URL) -> Bool = { _ in false }
|
|
||||||
public var fileExists: @Sendable (String) -> Bool = { _ in false }
|
|
||||||
public var findVaultFileInCurrentDirectory: @Sendable () throws -> URL?
|
|
||||||
public var write: @Sendable (String, Data) throws -> Void
|
|
||||||
}
|
|
||||||
|
|
||||||
@_spi(Internal)
|
|
||||||
extension FileClient: DependencyKey {
|
|
||||||
public static let testValue: FileClient = Self()
|
|
||||||
|
|
||||||
public static func live(fileManager: FileManager = .default) -> Self {
|
|
||||||
let client = LiveFileClient(fileManager: fileManager)
|
|
||||||
return Self(
|
|
||||||
loadFile: { try client.loadFile(at: $0, into: &$1, decoder: $2) },
|
|
||||||
homeDir: { client.homeDir },
|
|
||||||
isDirectory: { client.isDirectory(url: $0) },
|
|
||||||
isReadable: { client.isReadable(url: $0) },
|
|
||||||
fileExists: { client.fileExists(at: $0) },
|
|
||||||
findVaultFileInCurrentDirectory: { try client.findVaultFileInCurrentDirectory() },
|
|
||||||
write: { path, data in
|
|
||||||
try data.write(to: URL(filePath: path))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static let liveValue = Self.live()
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LiveFileClient: @unchecked Sendable {
|
|
||||||
|
|
||||||
private let fileManager: FileManager
|
|
||||||
|
|
||||||
init(fileManager: FileManager) {
|
|
||||||
self.fileManager = fileManager
|
|
||||||
}
|
|
||||||
|
|
||||||
var homeDir: URL { fileManager.homeDirectoryForCurrentUser }
|
|
||||||
|
|
||||||
func isDirectory(url: URL) -> Bool {
|
|
||||||
var isDirectory: ObjCBool = false
|
|
||||||
fileManager.fileExists(atPath: path(for: url), isDirectory: &isDirectory)
|
|
||||||
return isDirectory.boolValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func isReadable(url: URL) -> Bool {
|
|
||||||
fileManager.isReadableFile(atPath: path(for: url))
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileExists(at path: String) -> Bool {
|
|
||||||
fileManager.fileExists(atPath: path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findVaultFileInCurrentDirectory() throws -> URL? {
|
|
||||||
let urls = try fileManager.contentsOfDirectory(at: URL(filePath: "."), includingPropertiesForKeys: nil)
|
|
||||||
|
|
||||||
if let vault = urls.firstVaultFile {
|
|
||||||
return vault
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for folders that end with "vars" and search those next.
|
|
||||||
for folder in urls.filter({ $0.absoluteString.hasSuffix("vars/") }) {
|
|
||||||
let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
|
|
||||||
if let vault = files.firstVaultFile {
|
|
||||||
return vault
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to check all sub-folders
|
|
||||||
for folder in urls.filter({ self.isDirectory(url: $0) && !$0.absoluteString.hasSuffix("vars/") }) {
|
|
||||||
let files = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
|
|
||||||
if let vault = files.firstVaultFile {
|
|
||||||
return vault
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadFile(at url: URL, into env: inout [String: String], decoder: JSONDecoder) throws {
|
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
logger.trace("Begin load file for: \(path(for: url))")
|
|
||||||
|
|
||||||
if url.absoluteString.hasSuffix(".json") {
|
|
||||||
// Handle json file.
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
let dict = (try? decoder.decode([String: String].self, from: data)) ?? [:]
|
|
||||||
env.merge(dict, uniquingKeysWith: { $1 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let string = try String(contentsOfFile: path(for: url), encoding: .utf8)
|
|
||||||
|
|
||||||
logger.trace("Loaded file contents: \(string)")
|
|
||||||
|
|
||||||
let lines = string.split(separator: "\n")
|
|
||||||
for line in lines {
|
|
||||||
logger.trace("Line: \(line)")
|
|
||||||
let strippedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let splitLine = strippedLine.split(separator: "=").map {
|
|
||||||
$0.replacingOccurrences(of: "\"", with: "")
|
|
||||||
}
|
|
||||||
logger.trace("Split Line: \(splitLine)")
|
|
||||||
guard splitLine.count >= 2 else { continue }
|
|
||||||
|
|
||||||
if splitLine.count > 2 {
|
|
||||||
let rest = splitLine.dropFirst()
|
|
||||||
env[String(splitLine[0])] = String(rest.joined(separator: "="))
|
|
||||||
} else {
|
|
||||||
env[String(splitLine[0])] = String(splitLine[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension Array where Element == URL {
|
|
||||||
var firstVaultFile: URL? {
|
|
||||||
first { $0.absoluteString.hasSuffix("vault.yml") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
85
Sources/CliClient/GenerateJson.swift
Normal file
85
Sources/CliClient/GenerateJson.swift
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
// TODO: Remove.
|
||||||
|
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: .init(url: repo, version: version ?? "main"))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Template: Encodable {
|
||||||
|
let repo: Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Repo: Encodable {
|
||||||
|
let url: String
|
||||||
|
let version: String
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import Dependencies
|
|
||||||
import Foundation
|
|
||||||
import ShellClient
|
|
||||||
|
|
||||||
@_spi(Internal)
|
|
||||||
public func findConfigurationFiles(
|
|
||||||
env: [String: String] = ProcessInfo.processInfo.environment
|
|
||||||
) throws -> URL {
|
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
@Dependency(\.fileClient) var fileClient
|
|
||||||
|
|
||||||
logger.debug("Begin find configuration files.")
|
|
||||||
logger.trace("Env: \(env)")
|
|
||||||
|
|
||||||
// Check for environment variable pointing to a directory that the
|
|
||||||
// the configuration lives.
|
|
||||||
if let pwd = env["PWD"],
|
|
||||||
let url = fileClient.checkUrl(.file(pwd, ".hparc"))
|
|
||||||
{
|
|
||||||
logger.debug("Found configuration in current working directory.")
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for environment variable pointing to a file that the
|
|
||||||
// the configuration lives.
|
|
||||||
if let configFile = env["HPA_CONFIG_FILE"],
|
|
||||||
let url = fileClient.checkUrl(.file(configFile))
|
|
||||||
{
|
|
||||||
logger.debug("Found configuration from hpa config file env var.")
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for environment variable pointing to a directory that the
|
|
||||||
// the configuration lives.
|
|
||||||
if let configHome = env["HPA_CONFIG_HOME"],
|
|
||||||
let url = fileClient.checkUrl(.directory(configHome))
|
|
||||||
{
|
|
||||||
logger.debug("Found configuration from hpa config home env var.")
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check home directory for a `.hparc` file.
|
|
||||||
if let url = fileClient.checkUrl(.file(fileClient.homeDir().appending(path: ".hparc"))) {
|
|
||||||
logger.debug("Found configuration in home directory")
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check in xdg config home, under an hpa-playbook directory.
|
|
||||||
if let xdgConfigHome = env["XDG_CONFIG_HOME"],
|
|
||||||
let url = fileClient.checkUrl(.directory(xdgConfigHome, "hpa-playbook"))
|
|
||||||
{
|
|
||||||
logger.debug("XDG Config url: \(url.absoluteString)")
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
// We could not find configuration in any usual places.
|
|
||||||
throw ConfigurationError.configurationNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func path(for url: URL) -> String {
|
|
||||||
url.absoluteString.replacing("file://", with: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ConfigurationError: Error {
|
|
||||||
case configurationNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension FileClient {
|
|
||||||
|
|
||||||
enum ConfigurationUrlCheck {
|
|
||||||
case file(URL)
|
|
||||||
case directory(URL)
|
|
||||||
|
|
||||||
static func file(_ path: String) -> Self { .file(URL(filePath: path)) }
|
|
||||||
|
|
||||||
static func file(_ paths: String...) -> Self {
|
|
||||||
var url = URL(filePath: paths[0])
|
|
||||||
url = paths.dropFirst().reduce(into: url) { $0.append(path: $1) }
|
|
||||||
return .file(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func directory(_ path: String) -> Self { .directory(URL(filePath: path)) }
|
|
||||||
static func directory(_ paths: String...) -> Self {
|
|
||||||
var url = URL(filePath: paths[0])
|
|
||||||
url = paths.dropFirst().reduce(into: url) { $0.append(path: $1) }
|
|
||||||
return .directory(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkUrl(_ check: ConfigurationUrlCheck) -> URL? {
|
|
||||||
switch check {
|
|
||||||
case let .file(url):
|
|
||||||
if isReadable(url) { return url }
|
|
||||||
return nil
|
|
||||||
case let .directory(url):
|
|
||||||
return findConfigurationInDirectory(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func findConfigurationInDirectory(_ url: URL) -> URL? {
|
|
||||||
for file in ["config", "config.json"] {
|
|
||||||
let fileUrl = url.appending(path: file)
|
|
||||||
if isReadable(fileUrl) {
|
|
||||||
return fileUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
329
Sources/CliClient/Interface.swift
Normal file
329
Sources/CliClient/Interface.swift
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
struct RunPlaybook: Sendable {
|
||||||
|
public var buildProject: @Sendable (RunOptions, BuildOptions) async throws -> Void
|
||||||
|
public var createProject: @Sendable (RunOptions, CreateOptions) async throws -> String
|
||||||
|
|
||||||
|
public struct RunOptions: Equatable, Sendable {
|
||||||
|
public let loggingOptions: CliClient.LoggingOptions
|
||||||
|
public let quiet: Bool
|
||||||
|
public let shell: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BuildOptions: Equatable, Sendable {
|
||||||
|
public let extraOptions: [String]?
|
||||||
|
public let inventoryFilePath: String?
|
||||||
|
public let projectDirectory: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
extraOptions: [String]?,
|
||||||
|
inventoryFilePath: String?,
|
||||||
|
projectDirectory: String
|
||||||
|
) {
|
||||||
|
self.extraOptions = extraOptions
|
||||||
|
self.inventoryFilePath = inventoryFilePath
|
||||||
|
self.projectDirectory = projectDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CreateOptions: Equatable, Sendable {
|
||||||
|
public let extraOptions: [String]?
|
||||||
|
public let inventoryFilePath: String?
|
||||||
|
public let projectDirectory: String
|
||||||
|
public let template: Configuration.Template
|
||||||
|
public let useLocalTemplateDirectory: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
extraOptions: [String]?,
|
||||||
|
inventoryFilePath: String?,
|
||||||
|
projectDirectory: String,
|
||||||
|
template: Configuration.Template,
|
||||||
|
useLocalTemplateDirectory: Bool
|
||||||
|
) {
|
||||||
|
self.extraOptions = extraOptions
|
||||||
|
self.inventoryFilePath = inventoryFilePath
|
||||||
|
self.projectDirectory = projectDirectory
|
||||||
|
self.template = template
|
||||||
|
self.useLocalTemplateDirectory = useLocalTemplateDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension CliClient {
|
||||||
|
|
||||||
|
struct PandocOptions: Equatable, Sendable {
|
||||||
|
|
||||||
|
let buildDirectory: String?
|
||||||
|
let files: [String]?
|
||||||
|
let includeInHeader: [String]?
|
||||||
|
let outputDirectory: String?
|
||||||
|
let outputFileName: String?
|
||||||
|
let outputFileType: FileType
|
||||||
|
let projectDirectory: String?
|
||||||
|
let quiet: Bool
|
||||||
|
let shell: String?
|
||||||
|
let shouldBuild: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
buildDirectory: String? = nil,
|
||||||
|
files: [String]? = nil,
|
||||||
|
includeInHeader: [String]? = nil,
|
||||||
|
outputDirectory: String? = nil,
|
||||||
|
outputFileName: String? = nil,
|
||||||
|
outputFileType: FileType,
|
||||||
|
projectDirectory: String?,
|
||||||
|
quiet: Bool,
|
||||||
|
shell: String? = nil,
|
||||||
|
shouldBuild: Bool
|
||||||
|
) {
|
||||||
|
self.buildDirectory = buildDirectory
|
||||||
|
self.files = files
|
||||||
|
self.includeInHeader = includeInHeader
|
||||||
|
self.outputDirectory = outputDirectory
|
||||||
|
self.outputFileName = outputFileName
|
||||||
|
self.outputFileType = outputFileType
|
||||||
|
self.projectDirectory = projectDirectory
|
||||||
|
self.quiet = quiet
|
||||||
|
self.shell = shell
|
||||||
|
self.shouldBuild = shouldBuild
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable nesting
|
||||||
|
public enum FileType: Equatable, Sendable {
|
||||||
|
case html
|
||||||
|
case latex
|
||||||
|
case pdf(engine: String?)
|
||||||
|
}
|
||||||
|
// swiftlint:enable nesting
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Route {
|
||||||
|
case build(BuildOption)
|
||||||
|
case create(CreateOption)
|
||||||
|
|
||||||
|
public struct BuildOption: Equatable, Sendable {
|
||||||
|
public let extraOptions: [String]?
|
||||||
|
public let inventoryFilePath: String?
|
||||||
|
public let projectDirectory: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
extraOptions: [String]?,
|
||||||
|
inventoryFilePath: String?,
|
||||||
|
projectDirectory: String
|
||||||
|
) {
|
||||||
|
self.extraOptions = extraOptions
|
||||||
|
self.inventoryFilePath = inventoryFilePath
|
||||||
|
self.projectDirectory = projectDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CreateOption: Equatable, Sendable {
|
||||||
|
public let extraOptions: [String]?
|
||||||
|
public let inventoryFilePath: String?
|
||||||
|
public let projectDirectory: String
|
||||||
|
public let template: Configuration.Template
|
||||||
|
public let useLocalTemplateDirectory: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
extraOptions: [String]?,
|
||||||
|
inventoryFilePath: String?,
|
||||||
|
projectDirectory: String,
|
||||||
|
template: Configuration.Template,
|
||||||
|
useLocalTemplateDirectory: Bool
|
||||||
|
) {
|
||||||
|
self.extraOptions = extraOptions
|
||||||
|
self.inventoryFilePath = inventoryFilePath
|
||||||
|
self.projectDirectory = projectDirectory
|
||||||
|
self.template = template
|
||||||
|
self.useLocalTemplateDirectory = useLocalTemplateDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
Sources/CliClient/LoggingExtensions.swift
Normal file
29
Sources/CliClient/LoggingExtensions.swift
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Dependencies
|
||||||
|
import Logging
|
||||||
|
import ShellClient
|
||||||
|
|
||||||
|
// TODO: Remove.
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/// Use the `NodeBuilder` for generating an abstract.
|
|
||||||
public struct Abstract: NodeRepresentable {
|
|
||||||
@usableFromInline
|
|
||||||
let label: any NodeRepresentable
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(@NodeBuilder label: () -> any NodeRepresentable) {
|
|
||||||
self.label = label()
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public func render() -> String {
|
|
||||||
label.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import ArgumentParser
|
|
||||||
|
|
||||||
public extension CommandConfiguration {
|
|
||||||
|
|
||||||
/// Use `NodeBuilder` syntax for generating the abstract and discussion parameters.
|
|
||||||
init(
|
|
||||||
commandName: String? = nil,
|
|
||||||
abstract: Abstract,
|
|
||||||
usage: String? = nil,
|
|
||||||
discussion: Discussion,
|
|
||||||
version: String = "",
|
|
||||||
shouldDisplay: Bool = true,
|
|
||||||
subcommands: [any ParsableCommand.Type] = [],
|
|
||||||
groupedSubcommands: [CommandGroup] = [],
|
|
||||||
defaultSubcommand: ParsableCommand.Type? = nil,
|
|
||||||
helpNames: NameSpecification? = nil,
|
|
||||||
aliases: [String] = []
|
|
||||||
) {
|
|
||||||
self.init(
|
|
||||||
commandName: commandName,
|
|
||||||
abstract: abstract.render(),
|
|
||||||
usage: usage,
|
|
||||||
discussion: discussion.render(),
|
|
||||||
version: version,
|
|
||||||
shouldDisplay: shouldDisplay,
|
|
||||||
subcommands: subcommands,
|
|
||||||
groupedSubcommands: groupedSubcommands,
|
|
||||||
defaultSubcommand: defaultSubcommand,
|
|
||||||
helpNames: helpNames,
|
|
||||||
aliases: aliases
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/// Use the `NodeBuilder` for generating a discussion.
|
|
||||||
public struct Discussion: NodeRepresentable {
|
|
||||||
@usableFromInline
|
|
||||||
let label: any NodeRepresentable
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(@NodeBuilder label: () -> any NodeRepresentable) {
|
|
||||||
self.label = label()
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public func render() -> String {
|
|
||||||
label.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
public struct AnyNodeModifier: NodeModifier {
|
|
||||||
@usableFromInline
|
|
||||||
let modifier: any NodeModifier
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init<N: NodeModifier>(_ modifier: N) {
|
|
||||||
self.modifier = modifier
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public func render(_ node: any NodeRepresentable) -> any NodeRepresentable {
|
|
||||||
modifier.render(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import Rainbow
|
|
||||||
|
|
||||||
public extension NodeRepresentable {
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
func color(_ color: NamedColor) -> any NodeRepresentable {
|
|
||||||
modifier(ColorModifier(color: color))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension NodeModifier {
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
static func color(_ color: NamedColor) -> Self where Self == AnyNodeModifier {
|
|
||||||
.init(ColorModifier(color: color))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
struct ColorModifier: NodeModifier, @unchecked Sendable {
|
|
||||||
@usableFromInline
|
|
||||||
let color: NamedColor
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
init(color: NamedColor) {
|
|
||||||
self.color = color
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
func render(_ node: any NodeRepresentable) -> any NodeRepresentable {
|
|
||||||
node.render().applyingColor(color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import Rainbow
|
|
||||||
|
|
||||||
public extension NodeRepresentable {
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
func labelStyle(_ style: any NodeModifier) -> any NodeRepresentable {
|
|
||||||
return modifier(LabelStyleModifier(style))
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
func labelStyle(_ color: NamedColor) -> any NodeRepresentable {
|
|
||||||
return modifier(LabelStyleModifier(.color(color)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
func labelStyle(_ style: Style) -> any NodeRepresentable {
|
|
||||||
return modifier(LabelStyleModifier(.style(style)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
func labelStyle(_ styles: Style...) -> any NodeRepresentable {
|
|
||||||
return modifier(LabelStyleModifier(.style(styles)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
struct LabelStyleModifier: NodeModifier {
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let modifier: any NodeModifier
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
init(_ modifier: any NodeModifier) {
|
|
||||||
self.modifier = modifier
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
func render(_ node: any NodeRepresentable) -> any NodeRepresentable {
|
|
||||||
if let node = node as? LabeledContent {
|
|
||||||
return LabeledContent(separator: node.separator) {
|
|
||||||
node.label.modifier(modifier)
|
|
||||||
} content: {
|
|
||||||
node.content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
public enum LabelContentStyle: Sendable {
|
|
||||||
case inline
|
|
||||||
case `default`
|
|
||||||
case custom(any NodeRepresentable)
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension NodeRepresentable {
|
|
||||||
@inlinable
|
|
||||||
func labeledContentStyle(_ style: LabelContentStyle) -> any NodeRepresentable {
|
|
||||||
modifier(LabeledContentStyleModifier(style: style))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
struct LabeledContentStyleModifier: NodeModifier {
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let style: LabelContentStyle
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
init(style: LabelContentStyle) {
|
|
||||||
self.style = style
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
func render(_ node: any NodeRepresentable) -> any NodeRepresentable {
|
|
||||||
if let many = node as? _ManyNode {
|
|
||||||
var nodes = many.nodes
|
|
||||||
print("nodes: \(nodes)")
|
|
||||||
for (idx, node) in nodes.enumerated() {
|
|
||||||
if let labeled = node as? LabeledContent {
|
|
||||||
let newNode = labeled.applyingContentStyle(style)
|
|
||||||
nodes[idx] = newNode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _ManyNode(nodes, separator: many.separator)
|
|
||||||
} else if let labeled = node as? LabeledContent {
|
|
||||||
return labeled.applyingContentStyle(style)
|
|
||||||
}
|
|
||||||
print("no many or labeled nodes found in: \(node)")
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension LabeledContent {
|
|
||||||
|
|
||||||
func applyingContentStyle(_ style: LabelContentStyle) -> Self {
|
|
||||||
switch style {
|
|
||||||
case .inline:
|
|
||||||
return .init(separator: " ") { label } content: { content }
|
|
||||||
case .default:
|
|
||||||
return .init(separator: "\n") { label } content: { content }
|
|
||||||
case let .custom(custom):
|
|
||||||
return .init(separator: custom) { label } content: { content }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
public extension NodeRepresentable {
|
|
||||||
@inlinable
|
|
||||||
func repeating(_ count: Int, separator: (any NodeRepresentable)? = nil) -> any NodeRepresentable {
|
|
||||||
modifier(RepeatingNode(count: count, separator: separator))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
struct RepeatingNode: NodeModifier {
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let count: Int
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let separator: (any NodeRepresentable)?
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
init(
|
|
||||||
count: Int,
|
|
||||||
separator: (any NodeRepresentable)?
|
|
||||||
) {
|
|
||||||
self.count = count
|
|
||||||
self.separator = separator
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
func render(_ node: any NodeRepresentable) -> any NodeRepresentable {
|
|
||||||
let input = node.render()
|
|
||||||
var output = input
|
|
||||||
let separator = self.separator != nil
|
|
||||||
? self.separator!.render()
|
|
||||||
: ""
|
|
||||||
|
|
||||||
guard count > 0 else { return output }
|
|
||||||
|
|
||||||
var count = count - 1
|
|
||||||
while count > 0 {
|
|
||||||
output = "\(output)\(separator)\(input)"
|
|
||||||
count -= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import Rainbow
|
|
||||||
|
|
||||||
public extension NodeRepresentable {
|
|
||||||
@inlinable
|
|
||||||
func style(_ styles: Style...) -> any NodeRepresentable {
|
|
||||||
modifier(StyleModifier(styles: styles))
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
func style(_ styles: [Style]) -> any NodeRepresentable {
|
|
||||||
modifier(StyleModifier(styles: styles))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension NodeModifier {
|
|
||||||
@inlinable
|
|
||||||
static func style(_ styles: Style...) -> Self where Self == AnyNodeModifier {
|
|
||||||
.init(StyleModifier(styles: styles))
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
static func style(_ styles: [Style]) -> Self where Self == AnyNodeModifier {
|
|
||||||
.init(StyleModifier(styles: styles))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
struct StyleModifier: NodeModifier, @unchecked Sendable {
|
|
||||||
@usableFromInline
|
|
||||||
let styles: [Style]
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
init(styles: [Style]) {
|
|
||||||
self.styles = styles
|
|
||||||
}
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
func render(_ node: any NodeRepresentable) -> any NodeRepresentable {
|
|
||||||
styles.reduce(node.render()) { $0.applyingStyle($1) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// swiftlint:disable type_name
|
|
||||||
public protocol Node: NodeRepresentable {
|
|
||||||
associatedtype _Body: NodeRepresentable
|
|
||||||
typealias Body = _Body
|
|
||||||
|
|
||||||
@NodeBuilder
|
|
||||||
var body: Body { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
// swiftlint:enable type_name
|
|
||||||
|
|
||||||
public extension Node {
|
|
||||||
@inlinable
|
|
||||||
func render() -> String {
|
|
||||||
body.render()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Foo: Node {
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public var body: some NodeRepresentable {
|
|
||||||
LabeledContent("Foo") { "Bar" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
@resultBuilder
|
|
||||||
public enum NodeBuilder {
|
|
||||||
|
|
||||||
public static func buildPartialBlock<N: NodeRepresentable>(first: N) -> N {
|
|
||||||
first
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func buildPartialBlock<N0: NodeRepresentable, N1: NodeRepresentable>(
|
|
||||||
accumulated: N0,
|
|
||||||
next: N1
|
|
||||||
) -> _ManyNode {
|
|
||||||
.init([accumulated, next], separator: .empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func buildArray<N: NodeRepresentable>(_ components: [N]) -> _ManyNode {
|
|
||||||
.init(components, separator: .empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func buildEither<N: NodeRepresentable>(
|
|
||||||
first component: N
|
|
||||||
) -> N {
|
|
||||||
component
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func buildEither<N: NodeRepresentable>(
|
|
||||||
second component: N
|
|
||||||
) -> N {
|
|
||||||
component
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func buildBlock<N: NodeRepresentable>(_ components: N...) -> _ManyNode {
|
|
||||||
.init(components)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This breaks things ??
|
|
||||||
// public static func buildExpression<N: NodeRepresentable>(_ expression: N) -> AnyNode {
|
|
||||||
// expression.eraseToAnyNode()
|
|
||||||
// }
|
|
||||||
|
|
||||||
public static func buildOptional<N: NodeRepresentable>(_ component: N?) -> _ConditionalNode<N, Text> {
|
|
||||||
switch component {
|
|
||||||
case let .some(node):
|
|
||||||
return .first(node)
|
|
||||||
case .none:
|
|
||||||
return .second(.empty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func buildFinalResult<N: NodeRepresentable>(_ component: N) -> N {
|
|
||||||
component
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func buildLimitedAvailability<N: NodeRepresentable>(_ component: N) -> N {
|
|
||||||
component
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// These are nodes that are only used by the `NodeBuilder` / internally.
|
|
||||||
|
|
||||||
// swiftlint:disable type_name
|
|
||||||
public enum _ConditionalNode<
|
|
||||||
TrueNode: NodeRepresentable,
|
|
||||||
FalseNode: NodeRepresentable
|
|
||||||
>: NodeRepresentable {
|
|
||||||
case first(TrueNode)
|
|
||||||
case second(FalseNode)
|
|
||||||
|
|
||||||
public func render() -> String {
|
|
||||||
switch self {
|
|
||||||
case let .first(node): return node.render()
|
|
||||||
case let .second(node): return node.render()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct _ManyNode: NodeRepresentable {
|
|
||||||
@usableFromInline
|
|
||||||
let nodes: [any NodeRepresentable]
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let separator: any NodeRepresentable
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
init(
|
|
||||||
_ nodes: [any NodeRepresentable],
|
|
||||||
separator: any NodeRepresentable = "\n" as any NodeRepresentable
|
|
||||||
) {
|
|
||||||
// Flatten the nodes.
|
|
||||||
var allNodes = [any NodeRepresentable]()
|
|
||||||
for node in nodes {
|
|
||||||
if let many = node as? Self {
|
|
||||||
allNodes += many.nodes
|
|
||||||
} else {
|
|
||||||
allNodes.append(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.nodes = allNodes
|
|
||||||
self.separator = separator
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public func render() -> String {
|
|
||||||
nodes.map { $0.render() }
|
|
||||||
.joined(separator: separator.render())
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// swiftlint:enable type_name
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
public protocol NodeModifier: Sendable {
|
|
||||||
func render(_ node: any NodeRepresentable) -> any NodeRepresentable
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension NodeRepresentable {
|
|
||||||
func modifier(_ modifier: NodeModifier) -> any NodeRepresentable {
|
|
||||||
modifier.render(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
public protocol NodeRepresentable: Sendable {
|
|
||||||
func render() -> String
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
// TODO: This doesn't seem correct, maybe remove this type.
|
|
||||||
public struct AnyNode: NodeRepresentable {
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let makeBody: @Sendable () -> String
|
|
||||||
|
|
||||||
// @usableFromInline
|
|
||||||
// let node: Value
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init<N: NodeRepresentable>(@NodeBuilder _ build: () -> N) {
|
|
||||||
self.makeBody = build().render
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init<N: NodeRepresentable>(_ node: N) {
|
|
||||||
self.makeBody = node.render
|
|
||||||
}
|
|
||||||
|
|
||||||
public func render() -> String {
|
|
||||||
makeBody()
|
|
||||||
}
|
|
||||||
//
|
|
||||||
// public var body: some NodeRepresentable {
|
|
||||||
// node
|
|
||||||
// }
|
|
||||||
|
|
||||||
// @inlinable
|
|
||||||
// public func render() -> String {
|
|
||||||
// node.render()
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension NodeRepresentable {
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
func eraseToAnyNode() -> AnyNode {
|
|
||||||
.init(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
public struct Group: NodeRepresentable {
|
|
||||||
@usableFromInline
|
|
||||||
let node: any NodeRepresentable
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(
|
|
||||||
separator: any NodeRepresentable = " ",
|
|
||||||
@NodeBuilder node: () -> any NodeRepresentable
|
|
||||||
) {
|
|
||||||
if let many = node as? _ManyNode {
|
|
||||||
self.node = _ManyNode(many.nodes, separator: separator)
|
|
||||||
} else {
|
|
||||||
self.node = node()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// @inlinable
|
|
||||||
// public init(
|
|
||||||
// separator: any NodeRepresentable,
|
|
||||||
// @NodeBuilder _ build: () -> any NodeRepresentable
|
|
||||||
// ) {
|
|
||||||
// self.init(separator: separator.eraseToAnyNode(), node: build())
|
|
||||||
// }
|
|
||||||
|
|
||||||
// @inlinable
|
|
||||||
// public init(
|
|
||||||
// separator: String = " ",
|
|
||||||
// @NodeBuilder _ build: () -> any NodeRepresentable
|
|
||||||
// ) {
|
|
||||||
// self.init(separator: separator.eraseToAnyNode(), node: build())
|
|
||||||
// }
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public func render() -> String {
|
|
||||||
node.render()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Group2<Content: NodeRepresentable>: Node {
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let content: Content
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let separator: any NodeRepresentable
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(
|
|
||||||
separator: any NodeRepresentable = " ",
|
|
||||||
@NodeBuilder _ content: () -> Content
|
|
||||||
) {
|
|
||||||
self.content = content()
|
|
||||||
self.separator = separator
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public var body: some NodeRepresentable {
|
|
||||||
guard let content = content as? _ManyNode else {
|
|
||||||
return content.eraseToAnyNode()
|
|
||||||
}
|
|
||||||
return _ManyNode(content.nodes, separator: separator).eraseToAnyNode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
@preconcurrency import Rainbow
|
|
||||||
|
|
||||||
public struct Header: NodeRepresentable, @unchecked Sendable {
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let node: any NodeRepresentable
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let color: NamedColor?
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let styles: [Style]?
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(
|
|
||||||
@NodeBuilder _ build: () -> any NodeRepresentable
|
|
||||||
) {
|
|
||||||
self.node = build()
|
|
||||||
self.color = nil
|
|
||||||
self.styles = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(
|
|
||||||
_ text: String,
|
|
||||||
color: NamedColor? = .yellow,
|
|
||||||
styles: [Style]? = [.bold]
|
|
||||||
) {
|
|
||||||
self.color = color
|
|
||||||
self.node = Text(text)
|
|
||||||
self.styles = styles
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public func render() -> String {
|
|
||||||
var node = node
|
|
||||||
if let color {
|
|
||||||
node = node.color(color)
|
|
||||||
}
|
|
||||||
if let styles {
|
|
||||||
node = node.style(styles)
|
|
||||||
}
|
|
||||||
return node.render()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
public struct LabeledContent: NodeRepresentable {
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let label: any NodeRepresentable
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let content: any NodeRepresentable
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let separator: any NodeRepresentable
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(
|
|
||||||
separator: (any NodeRepresentable)? = nil,
|
|
||||||
@NodeBuilder label: () -> any NodeRepresentable,
|
|
||||||
@NodeBuilder content: () -> any NodeRepresentable
|
|
||||||
) {
|
|
||||||
self.separator = separator ?? "\n"
|
|
||||||
self.label = label()
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public func render() -> String {
|
|
||||||
Group(separator: separator) {
|
|
||||||
label
|
|
||||||
content
|
|
||||||
}.render()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension LabeledContent {
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
init(
|
|
||||||
_ label: String,
|
|
||||||
separator: (any NodeRepresentable)? = nil,
|
|
||||||
@NodeBuilder content: () -> any NodeRepresentable
|
|
||||||
) {
|
|
||||||
self.init(separator: separator) {
|
|
||||||
Text(label)
|
|
||||||
} content: {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct LabeledContent2<Label: Node, Content: Node>: Node {
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let separator: any NodeRepresentable
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let label: Label
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let content: Content
|
|
||||||
|
|
||||||
public init(
|
|
||||||
separator: any NodeRepresentable = " ",
|
|
||||||
@NodeBuilder label: () -> Label,
|
|
||||||
@NodeBuilder content: () -> Content
|
|
||||||
) {
|
|
||||||
self.separator = separator
|
|
||||||
self.label = label()
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some NodeRepresentable {
|
|
||||||
Group2(separator: separator) {
|
|
||||||
label
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
public struct Section: NodeRepresentable {
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let content: any NodeRepresentable
|
|
||||||
|
|
||||||
public init(@NodeBuilder content: () -> any NodeRepresentable) {
|
|
||||||
self.content = content()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func render() -> String {
|
|
||||||
guard let many = content as? _ManyNode else {
|
|
||||||
return content.render() + "\n\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
let node = _ManyNode(many.nodes, separator: "\n\n")
|
|
||||||
return node.render()
|
|
||||||
|
|
||||||
// var output = ""
|
|
||||||
// let lastIndex = many.nodes.count - 1
|
|
||||||
//
|
|
||||||
// for (idx, content) in many.nodes.enumerated() {
|
|
||||||
// if idx != lastIndex {
|
|
||||||
// output += content.render() + "\n\n"
|
|
||||||
// } else {
|
|
||||||
// output += content.render() + "\n"
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// return output
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
public struct ShellCommand: NodeRepresentable {
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let symbol: any NodeRepresentable
|
|
||||||
|
|
||||||
@usableFromInline
|
|
||||||
let command: any NodeRepresentable
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(
|
|
||||||
@NodeBuilder symbol: () -> any NodeRepresentable,
|
|
||||||
@NodeBuilder command: () -> any NodeRepresentable
|
|
||||||
) {
|
|
||||||
self.symbol = symbol()
|
|
||||||
self.command = command()
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(
|
|
||||||
symbol: String = " $",
|
|
||||||
@NodeBuilder command: () -> any NodeRepresentable
|
|
||||||
) {
|
|
||||||
self.init { symbol } command: { command() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(
|
|
||||||
symbol: String = " $",
|
|
||||||
command: String
|
|
||||||
) {
|
|
||||||
self.init { symbol } command: { command }
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public func render() -> String {
|
|
||||||
Group(separator: " ") {
|
|
||||||
symbol
|
|
||||||
command
|
|
||||||
}.render()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
extension String: NodeRepresentable {
|
|
||||||
@inlinable
|
|
||||||
public func render() -> String {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension String: Node {
|
|
||||||
|
|
||||||
public var body: some NodeRepresentable {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
@preconcurrency import Rainbow
|
|
||||||
|
|
||||||
public struct Text: Node {
|
|
||||||
@usableFromInline
|
|
||||||
let text: String
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public init(_ text: String) {
|
|
||||||
self.text = text
|
|
||||||
}
|
|
||||||
|
|
||||||
@inlinable
|
|
||||||
public var body: some NodeRepresentable {
|
|
||||||
text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension NodeRepresentable {
|
|
||||||
|
|
||||||
static func empty() -> Self where Self == Text {
|
|
||||||
Text("")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func newLine(count: Int = 1) -> Self where Self == AnyNode {
|
|
||||||
"\n".repeating(count).eraseToAnyNode()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func space(count: Int = 1) -> Self where Self == AnyNode {
|
|
||||||
" ".repeating(count).eraseToAnyNode()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
39
Sources/CodersClient/Coders.swift
Normal file
39
Sources/CodersClient/Coders.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Foundation
|
||||||
|
import TOMLKit
|
||||||
|
|
||||||
|
public extension DependencyValues {
|
||||||
|
var coders: Coders {
|
||||||
|
get { self[Coders.self] }
|
||||||
|
set { self[Coders.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct Coders: Sendable {
|
||||||
|
public var jsonDecoder: @Sendable () -> JSONDecoder = { .init() }
|
||||||
|
public var jsonEncoder: @Sendable () -> JSONEncoder = { .init() }
|
||||||
|
public var tomlDecoder: @Sendable () -> TOMLDecoder = { .init() }
|
||||||
|
public var tomlEncoder: @Sendable () -> TOMLEncoder = { .init() }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Coders: DependencyKey {
|
||||||
|
|
||||||
|
public static var testValue: Self { Self() }
|
||||||
|
|
||||||
|
public static var liveValue: Self {
|
||||||
|
.init(
|
||||||
|
jsonDecoder: { JSONDecoder() },
|
||||||
|
jsonEncoder: { defaultJsonEncoder },
|
||||||
|
tomlDecoder: { TOMLDecoder() },
|
||||||
|
tomlEncoder: { TOMLEncoder() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let defaultJsonEncoder: JSONEncoder = {
|
||||||
|
var encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
|
||||||
|
return encoder
|
||||||
|
}()
|
||||||
210
Sources/CommandClient/CommandClient.swift
Normal file
210
Sources/CommandClient/CommandClient.swift
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct LoggingOptions: Equatable, Sendable {
|
||||||
|
public let commandName: String
|
||||||
|
public let logLevel: Logger.Level
|
||||||
|
|
||||||
|
public init(commandName: String, logLevel: Logger.Level) {
|
||||||
|
self.commandName = commandName
|
||||||
|
self.logLevel = logLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
self = .zsh(useDashC: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
Sources/ConfigurationClient/Configuration.swift
Normal file
138
Sources/ConfigurationClient/Configuration.swift
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// NOTE: When adding items, then the 'hpa.toml' resource file needs to be updated.
|
||||||
|
|
||||||
|
/// Represents configurable settings for the command line tool.
|
||||||
|
public struct Configuration: Codable, Equatable, Sendable {
|
||||||
|
public let args: [String]?
|
||||||
|
public let useVaultArgs: Bool
|
||||||
|
public let generate: Generate?
|
||||||
|
public let playbook: Playbook?
|
||||||
|
public let template: Template
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var mock: Self {
|
||||||
|
.init(
|
||||||
|
args: [],
|
||||||
|
useVaultArgs: true,
|
||||||
|
playbook: nil,
|
||||||
|
template: .mock,
|
||||||
|
vault: .mock
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Generate: Codable, Equatable, Sendable {
|
||||||
|
public let buildDirectory: String?
|
||||||
|
public let files: [String]?
|
||||||
|
public let includeInHeader: [String]?
|
||||||
|
public let outputFileName: String?
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default` = Self.mock
|
||||||
|
|
||||||
|
public static var mock: Self {
|
||||||
|
.init(
|
||||||
|
buildDirectory: ".build",
|
||||||
|
files: ["Report.md", "Definitions.md"],
|
||||||
|
includeInHeader: ["head.tex", "footer.tex"],
|
||||||
|
outputFileName: "Report",
|
||||||
|
pdfEngine: "xelatex"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Playbook: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let directory: String?
|
||||||
|
public let inventory: String?
|
||||||
|
public let version: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
directory: String? = nil,
|
||||||
|
inventory: String? = nil,
|
||||||
|
version: String? = nil
|
||||||
|
) {
|
||||||
|
self.directory = directory
|
||||||
|
self.inventory = inventory
|
||||||
|
self.version = version
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var mock: Self { .init() }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Template: Codable, Equatable, Sendable {
|
||||||
|
public let url: String?
|
||||||
|
public let version: String?
|
||||||
|
public let directory: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
url: String? = nil,
|
||||||
|
version: String? = nil,
|
||||||
|
directory: String? = nil
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.version = version
|
||||||
|
self.directory = directory
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var mock: Self {
|
||||||
|
.init(
|
||||||
|
url: "https://git.example.com/consult-template.git",
|
||||||
|
version: "1.0.0",
|
||||||
|
directory: "/path/to/local/template-directory"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Vault: Codable, Equatable, Sendable {
|
||||||
|
public let args: [String]?
|
||||||
|
public let encryptId: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
args: [String]? = nil,
|
||||||
|
encryptId: String? = nil
|
||||||
|
) {
|
||||||
|
self.args = args
|
||||||
|
self.encryptId = encryptId
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var mock: Self {
|
||||||
|
.init(
|
||||||
|
args: [
|
||||||
|
"--vault-id=myId@$SCRIPTS/vault-gopass-client"
|
||||||
|
],
|
||||||
|
encryptId: "myId"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
243
Sources/ConfigurationClient/ConfigurationClient.swift
Normal file
243
Sources/ConfigurationClient/ConfigurationClient.swift
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import CodersClient
|
||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import FileClient
|
||||||
|
import Foundation
|
||||||
|
import ShellClient
|
||||||
|
|
||||||
|
public extension DependencyValues {
|
||||||
|
var configurationClient: ConfigurationClient {
|
||||||
|
get { self[ConfigurationClient.self] }
|
||||||
|
set { self[ConfigurationClient.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func write(
|
||||||
|
_ configuration: Configuration,
|
||||||
|
to file: File,
|
||||||
|
force: Bool = false
|
||||||
|
) async throws {
|
||||||
|
try await write(file, configuration, force)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ConfigurationClient: DependencyKey {
|
||||||
|
public static var testValue: Self { Self() }
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|
||||||
|
private let environment: [String: String]
|
||||||
|
|
||||||
|
@Dependency(\.coders) var coders
|
||||||
|
@Dependency(\.fileClient) var fileManager
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
let validFileNames = [
|
||||||
|
"config.json", "config.toml",
|
||||||
|
".hparc.json", ".hparc.toml"
|
||||||
|
]
|
||||||
|
|
||||||
|
init(environment: [String: String]) {
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
func find() async throws -> File {
|
||||||
|
logger.debug("Begin find configuration.")
|
||||||
|
|
||||||
|
if let pwd = environment["PWD"],
|
||||||
|
let file = await findInDirectory(pwd)
|
||||||
|
{
|
||||||
|
logger.debug("Found configuration in pwd: \(file.path)")
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
if let configHome = environment.hpaConfigHome,
|
||||||
|
let file = await findInDirectory(configHome)
|
||||||
|
{
|
||||||
|
logger.debug("Found configuration in config home env var: \(file.path)")
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
if let configFile = environment.hpaConfigFile,
|
||||||
|
isReadable(configFile),
|
||||||
|
let file = File(configFile)
|
||||||
|
{
|
||||||
|
logger.debug("Found configuration in config file env var: \(file.path)")
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
if let file = await findInDirectory(fileManager.homeDirectory()) {
|
||||||
|
logger.debug("Found configuration in home directory: \(file.path)")
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
if let file = await findInDirectory("\(environment.xdgConfigHome)/\(HPAKey.configDirName)") {
|
||||||
|
logger.debug("Found configuration in xdg config directory: \(file.path)")
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ConfigurationError.configurationNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func generate(at file: File, force: Bool) async throws {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
logger.debug("Begin generating configuration: \(file.path), force: \(force)")
|
||||||
|
|
||||||
|
let expandedPath = file.path.replacingOccurrences(
|
||||||
|
of: "~",
|
||||||
|
with: fileManager.homeDirectory().cleanFilePath
|
||||||
|
)
|
||||||
|
|
||||||
|
let fileUrl = URL(filePath: expandedPath)
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
guard !fileManager.fileExists(fileUrl) else {
|
||||||
|
throw ConfigurationError.fileExists(path: file.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileDirectory = fileUrl.deletingLastPathComponent()
|
||||||
|
let directoryExists = try await fileManager.isDirectory(fileDirectory)
|
||||||
|
|
||||||
|
if !directoryExists {
|
||||||
|
logger.debug("Creating directory at: \(fileDirectory.cleanFilePath)")
|
||||||
|
try await fileManager.createDirectory(fileDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(file: File?) async throws -> Configuration {
|
||||||
|
guard let file else { return .init() }
|
||||||
|
let data = try await fileManager.load(file.url)
|
||||||
|
|
||||||
|
switch file {
|
||||||
|
case .json:
|
||||||
|
return try coders.jsonDecoder().decode(Configuration.self, from: data)
|
||||||
|
case .toml:
|
||||||
|
guard let string = String(data: data, encoding: .utf8) else {
|
||||||
|
throw ConfigurationError.decodingError
|
||||||
|
}
|
||||||
|
return try coders.tomlDecoder().decode(Configuration.self, from: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(
|
||||||
|
_ configuration: Configuration,
|
||||||
|
to file: File,
|
||||||
|
force: Bool
|
||||||
|
) async throws {
|
||||||
|
if !force {
|
||||||
|
guard !fileManager.fileExists(file.url) else {
|
||||||
|
throw ConfigurationError.fileExists(path: file.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
|
||||||
|
switch file {
|
||||||
|
case .json:
|
||||||
|
data = try coders.jsonEncoder().encode(configuration)
|
||||||
|
case .toml:
|
||||||
|
let string = try coders.tomlEncoder().encode(configuration)
|
||||||
|
data = Data(string.utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await fileManager.write(data, file.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findInDirectory(_ directory: URL) async -> File? {
|
||||||
|
for file in validFileNames {
|
||||||
|
let url = directory.appending(path: file)
|
||||||
|
if isReadable(url), let file = File(url) {
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findInDirectory(_ directory: String) async -> File? {
|
||||||
|
await findInDirectory(URL(filePath: directory))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isReadable(_ file: URL) -> Bool {
|
||||||
|
FileManager.default.isReadableFile(atPath: file.cleanFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isReadable(_ file: String) -> Bool {
|
||||||
|
isReadable(URL(filePath: file))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConfigurationError: Error {
|
||||||
|
case configurationNotFound
|
||||||
|
case resourceNotFound
|
||||||
|
case decodingError
|
||||||
|
case fileExists(path: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockFindError: Error {}
|
||||||
28
Sources/ConfigurationClient/Constants.swift
Normal file
28
Sources/ConfigurationClient/Constants.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
@_spi(Internal)
|
||||||
|
public enum EnvironmentKey {
|
||||||
|
static let xdgConfigHome = "XDG_CONFIG_HOME"
|
||||||
|
static let hpaConfigHome = "HPA_CONFIG_HOME"
|
||||||
|
static let hpaConfigFile = "HPA_CONFIG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
@_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"
|
||||||
|
}
|
||||||
|
|
||||||
|
extension [String: String] {
|
||||||
|
var xdgConfigHome: String {
|
||||||
|
self[EnvironmentKey.xdgConfigHome] ?? "~/.config"
|
||||||
|
}
|
||||||
|
|
||||||
|
var hpaConfigHome: String? {
|
||||||
|
self[EnvironmentKey.hpaConfigHome]
|
||||||
|
}
|
||||||
|
|
||||||
|
var hpaConfigFile: String? {
|
||||||
|
self[EnvironmentKey.hpaConfigFile]
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Sources/ConfigurationClient/File.swift
Normal file
52
Sources/ConfigurationClient/File.swift
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import FileClient
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Represents a file location and type on disk for a configuration file.
|
||||||
|
public enum File: Equatable, Sendable {
|
||||||
|
|
||||||
|
case json(URL)
|
||||||
|
case toml(URL)
|
||||||
|
|
||||||
|
public init?(_ url: URL) {
|
||||||
|
if url.cleanFilePath.hasSuffix("json") {
|
||||||
|
self = .json(url)
|
||||||
|
} else if url.cleanFilePath.hasSuffix("toml") {
|
||||||
|
self = .toml(url)
|
||||||
|
} else {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
public var url: URL {
|
||||||
|
switch self {
|
||||||
|
case let .json(url): return url
|
||||||
|
case let .toml(url): return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var path: String {
|
||||||
|
url.cleanFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var `default`: Self {
|
||||||
|
let fileUrl = FileManager.default
|
||||||
|
.homeDirectoryForCurrentUser
|
||||||
|
.appending(path: ".config")
|
||||||
|
.appending(path: HPAKey.configDirName)
|
||||||
|
.appending(path: HPAKey.defaultFileName)
|
||||||
|
|
||||||
|
return .toml(fileUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
72
Sources/ConfigurationClient/Resources/hpa.toml
Normal file
72
Sources/ConfigurationClient/Resources/hpa.toml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# NOTE:
|
||||||
|
# Configuration settings for the `hpa` command line tool.
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
#[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]
|
||||||
|
# The directory path on your local system to the template files.
|
||||||
|
directory = '/path/to/local/template-directory'
|
||||||
|
|
||||||
|
# The url to a git repository that contains your template files.
|
||||||
|
url = 'https://git.example.com/consult-template.git'
|
||||||
|
|
||||||
|
# The version, tag, branch, or sha of the template files to clone from the remote
|
||||||
|
# template repository. In general it is best practice to use a version instead of a
|
||||||
|
# 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
|
||||||
|
# files.
|
||||||
|
args = [ '--vault-id=myId@$SCRIPTS/vault-gopass-client' ]
|
||||||
|
|
||||||
|
# An id to use when encrypting `ansible-vault` files.
|
||||||
|
encryptId = 'myId'
|
||||||
3
Sources/Constants/Constants.swift
Normal file
3
Sources/Constants/Constants.swift
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
public enum Constants {
|
||||||
|
public static let executableName = "hpa"
|
||||||
|
}
|
||||||
119
Sources/FileClient/FileClient.swift
Normal file
119
Sources/FileClient/FileClient.swift
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension DependencyValues {
|
||||||
|
var fileClient: FileClient {
|
||||||
|
get { self[FileClient.self] }
|
||||||
|
set { self[FileClient.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct FileClient: Sendable {
|
||||||
|
public var copy: @Sendable (URL, URL) async throws -> Void
|
||||||
|
public var createDirectory: @Sendable (URL) async throws -> Void
|
||||||
|
public var fileExists: @Sendable (URL) -> Bool = { _ in true }
|
||||||
|
public var findVaultFileInCurrentDirectory: @Sendable () async throws -> URL?
|
||||||
|
public var homeDirectory: @Sendable () -> URL = { URL(filePath: "~/") }
|
||||||
|
public var isDirectory: @Sendable (URL) async throws -> Bool
|
||||||
|
public var load: @Sendable (URL) async throws -> Data
|
||||||
|
public var write: @Sendable (Data, URL) async throws -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FileClient: DependencyKey {
|
||||||
|
public static let testValue: FileClient = Self()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LiveFileClient: Sendable {
|
||||||
|
|
||||||
|
private var manager: FileManager { FileManager.default }
|
||||||
|
|
||||||
|
func copy(_ url: URL, to toUrl: URL) async throws {
|
||||||
|
try manager.copyItem(at: url, to: toUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func creatDirectory(_ url: URL) async throws {
|
||||||
|
try manager.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check the current directory
|
||||||
|
if let vault = urls.firstVaultFile { return vault }
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func homeDirectory() -> URL {
|
||||||
|
manager.homeDirectoryForCurrentUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDirectory(_ url: URL) -> Bool {
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
manager.fileExists(atPath: url.cleanFilePath, isDirectory: &isDirectory)
|
||||||
|
return isDirectory.boolValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(from url: URL) async throws -> Data {
|
||||||
|
try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(_ data: Data, to url: URL) async throws {
|
||||||
|
try data.write(to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Array where Element == URL {
|
||||||
|
var firstVaultFile: URL? {
|
||||||
|
first { url in
|
||||||
|
let string = url.absoluteString
|
||||||
|
return string.hasSuffix("vault.yml") || string.hasSuffix("vault.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension URL {
|
||||||
|
var cleanFilePath: String {
|
||||||
|
absoluteString.replacing("file://", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Sources/PlaybookClient/Constants.swift
Normal file
12
Sources/PlaybookClient/Constants.swift
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// TODO: Use an actuall version tag for 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
79
Sources/PlaybookClient/PlaybookClient+Repository.swift
Normal file
79
Sources/PlaybookClient/PlaybookClient+Repository.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
245
Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift
Normal file
245
Sources/PlaybookClient/PlaybookClient+RunPlaybook.swift
Normal 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
|
||||||
|
}()
|
||||||
175
Sources/PlaybookClient/PlaybookClient.swift
Normal file
175
Sources/PlaybookClient/PlaybookClient.swift
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
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 {
|
||||||
|
var playbookClient: PlaybookClient {
|
||||||
|
get { self[PlaybookClient.self] }
|
||||||
|
set { self[PlaybookClient.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct PlaybookClient: Sendable {
|
||||||
|
public var repository: Repository
|
||||||
|
public var run: RunPlaybook
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension PlaybookClient {
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
struct Repository: Sendable {
|
||||||
|
public var install: @Sendable (Configuration?) async throws -> Void
|
||||||
|
public var directory: @Sendable (Configuration?) async throws -> String
|
||||||
|
|
||||||
|
public func install() async throws {
|
||||||
|
try await install(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func directory() async throws -> String {
|
||||||
|
try await directory(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension PlaybookClient {
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
struct RunPlaybook: Sendable {
|
||||||
|
public var buildProject: @Sendable (BuildOptions) async throws -> Void
|
||||||
|
public var createProject: @Sendable (CreateOptions, JSONEncoder?) async throws -> Void
|
||||||
|
public var generateTemplate: @Sendable (GenerateTemplateOptions) async throws -> String
|
||||||
|
|
||||||
|
public func createProject(_ options: CreateOptions) async throws {
|
||||||
|
try await createProject(options, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SharedRunOptions: Equatable, Sendable {
|
||||||
|
public let extraOptions: [String]?
|
||||||
|
public let inventoryFilePath: String?
|
||||||
|
public let loggingOptions: LoggingOptions
|
||||||
|
public let quiet: Bool
|
||||||
|
public let shell: String?
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public struct BuildOptions: Equatable, Sendable {
|
||||||
|
public let projectDirectory: String?
|
||||||
|
public let shared: SharedRunOptions
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public struct CreateOptions: Equatable, Sendable {
|
||||||
|
public let projectDirectory: String
|
||||||
|
public let shared: SharedRunOptions
|
||||||
|
public let template: Configuration.Template?
|
||||||
|
public let useLocalTemplateDirectory: Bool
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public struct GenerateTemplateOptions: Equatable, Sendable {
|
||||||
|
public let shared: SharedRunOptions
|
||||||
|
public let templateDirectory: String
|
||||||
|
public let templateVarsDirectory: String?
|
||||||
|
public let useVault: Bool
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Sources/PlaybookClient/PlaybookClientError.swift
Normal file
8
Sources/PlaybookClient/PlaybookClientError.swift
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PlaybookClientError: Error {
|
||||||
|
case encodingError
|
||||||
|
case projectDirectoryNotFound
|
||||||
|
case templateDirectoryNotFound
|
||||||
|
case templateDirectoryOrRepoNotSpecified
|
||||||
|
}
|
||||||
167
Sources/TestSupport/TestSupport.swift
Normal file
167
Sources/TestSupport/TestSupport.swift
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
@_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,
|
||||||
|
operation: @escaping @Sendable () throws -> Void
|
||||||
|
) rethrows {
|
||||||
|
try TestSupport.withTestLogger(
|
||||||
|
key: key,
|
||||||
|
label: "\(Self.self)",
|
||||||
|
logLevel: logLevel
|
||||||
|
) {
|
||||||
|
try operation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withTestLogger(
|
||||||
|
key: String,
|
||||||
|
logLevel: Logger.Level = .debug,
|
||||||
|
dependencies setupDependencies: @escaping (inout DependencyValues) -> Void,
|
||||||
|
operation: @escaping @Sendable () throws -> Void
|
||||||
|
) rethrows {
|
||||||
|
try TestSupport.withTestLogger(
|
||||||
|
key: key,
|
||||||
|
label: "\(Self.self)",
|
||||||
|
logLevel: logLevel,
|
||||||
|
dependencies: setupDependencies
|
||||||
|
) {
|
||||||
|
try operation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withTestLogger(
|
||||||
|
key: String,
|
||||||
|
logLevel: Logger.Level = .debug,
|
||||||
|
operation: @escaping @Sendable () async throws -> Void
|
||||||
|
) async rethrows {
|
||||||
|
try await TestSupport.withTestLogger(
|
||||||
|
key: key,
|
||||||
|
label: "\(Self.self)",
|
||||||
|
logLevel: logLevel
|
||||||
|
) {
|
||||||
|
try await operation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withTestLogger(
|
||||||
|
key: String,
|
||||||
|
logLevel: Logger.Level = .debug,
|
||||||
|
dependencies setupDependencies: @escaping (inout DependencyValues) -> Void,
|
||||||
|
operation: @escaping @Sendable () async throws -> Void
|
||||||
|
) async rethrows {
|
||||||
|
try await TestSupport.withTestLogger(
|
||||||
|
key: key,
|
||||||
|
label: "\(Self.self)",
|
||||||
|
logLevel: logLevel,
|
||||||
|
dependencies: setupDependencies
|
||||||
|
) {
|
||||||
|
try await operation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func withTestLogger(
|
||||||
|
key: String,
|
||||||
|
label: String,
|
||||||
|
logLevel: Logger.Level = .debug,
|
||||||
|
operation: @escaping @Sendable () throws -> Void
|
||||||
|
) rethrows {
|
||||||
|
try withDependencies {
|
||||||
|
$0.logger = .init(label: label)
|
||||||
|
$0.logger[metadataKey: "test"] = "\(key)"
|
||||||
|
$0.logger.logLevel = logLevel
|
||||||
|
} operation: {
|
||||||
|
try operation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func withTestLogger(
|
||||||
|
key: String,
|
||||||
|
label: String,
|
||||||
|
logLevel: Logger.Level = .debug,
|
||||||
|
dependencies setupDependencies: @escaping (inout DependencyValues) -> Void,
|
||||||
|
operation: @escaping @Sendable () throws -> Void
|
||||||
|
) rethrows {
|
||||||
|
try withDependencies {
|
||||||
|
$0.logger = .init(label: label)
|
||||||
|
$0.logger[metadataKey: "test"] = "\(key)"
|
||||||
|
$0.logger.logLevel = logLevel
|
||||||
|
setupDependencies(&$0)
|
||||||
|
} operation: {
|
||||||
|
try operation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func withTestLogger(
|
||||||
|
key: String,
|
||||||
|
label: String,
|
||||||
|
logLevel: Logger.Level = .debug,
|
||||||
|
operation: @escaping @Sendable () async throws -> Void
|
||||||
|
) async rethrows {
|
||||||
|
try await withDependencies {
|
||||||
|
$0.logger = .init(label: label)
|
||||||
|
$0.logger[metadataKey: "test"] = "\(key)"
|
||||||
|
$0.logger.logLevel = logLevel
|
||||||
|
} operation: {
|
||||||
|
try await operation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func withTestLogger(
|
||||||
|
key: String,
|
||||||
|
label: String,
|
||||||
|
logLevel: Logger.Level = .debug,
|
||||||
|
dependencies setupDependencies: @escaping (inout DependencyValues) -> Void,
|
||||||
|
operation: @escaping @Sendable () async throws -> Void
|
||||||
|
) async rethrows {
|
||||||
|
try await withDependencies {
|
||||||
|
$0.logger = .init(label: label)
|
||||||
|
$0.logger[metadataKey: "test"] = "\(key)"
|
||||||
|
$0.logger.logLevel = logLevel
|
||||||
|
setupDependencies(&$0)
|
||||||
|
} operation: {
|
||||||
|
try await operation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TestSupportError: Error {
|
||||||
|
case optionsNotSet
|
||||||
|
}
|
||||||
15
Sources/TestSupport/WithTempDir.swift
Normal file
15
Sources/TestSupport/WithTempDir.swift
Normal 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
|
||||||
164
Sources/VaultClient/VaultClient.swift
Normal file
164
Sources/VaultClient/VaultClient.swift
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import CommandClient
|
||||||
|
import ConfigurationClient
|
||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import FileClient
|
||||||
|
|
||||||
|
// TODO: Add edit / view routes, possibly create?
|
||||||
|
|
||||||
|
public extension DependencyValues {
|
||||||
|
var vaultClient: VaultClient {
|
||||||
|
get { self[VaultClient.self] }
|
||||||
|
set { self[VaultClient.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct VaultClient: Sendable {
|
||||||
|
public var run: Run
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct Run: Sendable {
|
||||||
|
public var decrypt: @Sendable (RunOptions) async throws -> String
|
||||||
|
public var encrypt: @Sendable (RunOptions) async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RunOptions: Equatable, Sendable {
|
||||||
|
|
||||||
|
public let extraOptions: [String]?
|
||||||
|
public let loggingOptions: LoggingOptions
|
||||||
|
public let outputFilePath: String?
|
||||||
|
public let quiet: Bool
|
||||||
|
public let shell: String?
|
||||||
|
public let vaultFilePath: String?
|
||||||
|
|
||||||
|
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 encrypt
|
||||||
|
case decrypt
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -9,8 +9,13 @@ struct Application: AsyncParsableCommand {
|
|||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: Constants.appName,
|
commandName: Constants.appName,
|
||||||
abstract: createAbstract("A utility for working with ansible hpa playbook."),
|
abstract: createAbstract("A utility for working with ansible hpa playbook."),
|
||||||
|
version: VERSION ?? "0.0.0",
|
||||||
subcommands: [
|
subcommands: [
|
||||||
BuildCommand.self, CreateCommand.self, VaultCommand.self, UtilsCommand.self
|
BuildCommand.self,
|
||||||
|
CreateCommand.self,
|
||||||
|
GenerateCommand.self,
|
||||||
|
VaultCommand.self,
|
||||||
|
UtilsCommand.self
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import CliClient
|
import CliClient
|
||||||
import Dependencies
|
import Dependencies
|
||||||
|
import Foundation
|
||||||
|
import PlaybookClient
|
||||||
|
|
||||||
struct BuildCommand: AsyncParsableCommand {
|
struct BuildCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
@@ -9,29 +11,38 @@ struct BuildCommand: AsyncParsableCommand {
|
|||||||
static let configuration = CommandConfiguration.playbook(
|
static let configuration = CommandConfiguration.playbook(
|
||||||
commandName: commandName,
|
commandName: commandName,
|
||||||
abstract: "Build a home performance assesment project.",
|
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
|
@OptionGroup var globals: GlobalOptions
|
||||||
|
|
||||||
@Argument(
|
@Option(
|
||||||
help: "Path to the project directory.",
|
help: "Path to the project directory.",
|
||||||
completion: .directory
|
completion: .directory
|
||||||
)
|
)
|
||||||
var projectDir: String
|
var projectDirectory: String?
|
||||||
|
|
||||||
@Argument(
|
@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 {
|
mutating func run() async throws {
|
||||||
try await runPlaybook(
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
|
try await playbookClient.run.buildProject(.init(
|
||||||
|
projectDirectory: projectDirectory,
|
||||||
|
shared: globals.sharedPlaybookRunOptions(
|
||||||
commandName: Self.commandName,
|
commandName: Self.commandName,
|
||||||
globals: globals,
|
extraOptions: extraOptions
|
||||||
extraArgs: extraArgs,
|
|
||||||
"--tags", "build-project",
|
|
||||||
"--extra-vars", "project_dir=\(projectDir)"
|
|
||||||
)
|
)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import CliClient
|
import CliClient
|
||||||
|
import ConfigurationClient
|
||||||
import Dependencies
|
import Dependencies
|
||||||
import Foundation
|
import Foundation
|
||||||
import Logging
|
import Logging
|
||||||
|
import PlaybookClient
|
||||||
|
|
||||||
struct CreateCommand: AsyncParsableCommand {
|
struct CreateCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
static let commandName = "create"
|
static let commandName = "create"
|
||||||
|
|
||||||
static let configuration = CommandConfiguration.playbook2(
|
static let configuration = CommandConfiguration.playbook(
|
||||||
commandName: commandName,
|
commandName: commandName,
|
||||||
abstract: "Create a home performance assesment project.",
|
abstract: "Create a home performance assesment project.",
|
||||||
examples: (
|
examples: (
|
||||||
@@ -53,118 +55,30 @@ struct CreateCommand: AsyncParsableCommand {
|
|||||||
@Argument(
|
@Argument(
|
||||||
help: "Extra arguments passed to the playbook."
|
help: "Extra arguments passed to the playbook."
|
||||||
)
|
)
|
||||||
var extraArgs: [String] = []
|
var extraOptions: [String] = []
|
||||||
|
|
||||||
mutating func run() async throws {
|
mutating func run() async throws {
|
||||||
try await _run()
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
}
|
try await playbookClient.run.createProject(.init(
|
||||||
|
projectDirectory: projectDir,
|
||||||
private func _run() async throws {
|
shared: globals.sharedPlaybookRunOptions(
|
||||||
try await withSetupLogger(commandName: Self.commandName, globals: globals) {
|
|
||||||
@Dependency(\.cliClient) var cliClient
|
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
|
|
||||||
let encoder = cliClient.encoder()
|
|
||||||
|
|
||||||
let configuration = try cliClient.loadConfiguration()
|
|
||||||
|
|
||||||
logger.debug("Configuration: \(configuration)")
|
|
||||||
|
|
||||||
let jsonData = try parseOptions(
|
|
||||||
command: self,
|
|
||||||
configuration: configuration,
|
|
||||||
logger: logger,
|
|
||||||
encoder: encoder
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let jsonString = String(data: jsonData, encoding: .utf8) else {
|
|
||||||
throw CreateError.encodingError
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("JSON string: \(jsonString)")
|
|
||||||
|
|
||||||
try await runPlaybook(
|
|
||||||
commandName: Self.commandName,
|
commandName: Self.commandName,
|
||||||
globals: self.globals,
|
extraOptions: extraOptions
|
||||||
configuration: configuration,
|
),
|
||||||
extraArgs: extraArgs,
|
template: .init(directory: templateDir),
|
||||||
"--tags", "setup-project",
|
useLocalTemplateDirectory: localTemplateDir
|
||||||
"--extra-vars", "project_dir=\(self.projectDir)",
|
))
|
||||||
"--extra-vars", "'\(jsonString)'"
|
print(projectDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension CreateCommand {
|
||||||
|
var generateJsonOptions: CliClient.GenerateJsonOptions {
|
||||||
|
.init(
|
||||||
|
templateDirectory: templateDir,
|
||||||
|
templateRepo: repo,
|
||||||
|
version: branch,
|
||||||
|
useLocalTemplateDirectory: localTemplateDir
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func parseOptions(
|
|
||||||
command: CreateCommand,
|
|
||||||
configuration: Configuration,
|
|
||||||
logger: Logger,
|
|
||||||
encoder: JSONEncoder
|
|
||||||
) throws -> Data {
|
|
||||||
let templateDir = command.templateDir ?? configuration.templateDir
|
|
||||||
let templateRepo = command.repo ?? configuration.templateRepo
|
|
||||||
let version = (command.branch ?? configuration.templateRepoVersion) ?? "main"
|
|
||||||
|
|
||||||
logger.debug("""
|
|
||||||
(\(command.localTemplateDir), \(String(describing: templateDir)), \(String(describing: templateRepo)))
|
|
||||||
""")
|
|
||||||
|
|
||||||
switch (command.localTemplateDir, 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 CreateError.templateDirNotFound
|
|
||||||
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 CreateError.templateDirOrRepoNotSpecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CreateError: Error {
|
|
||||||
case encodingError
|
|
||||||
case templateDirNotFound
|
|
||||||
case templateDirOrRepoNotSpecified
|
|
||||||
}
|
|
||||||
|
|||||||
14
Sources/hpa/GenerateCommands/GenerateCommand.swift
Normal file
14
Sources/hpa/GenerateCommands/GenerateCommand.swift
Normal 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
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
24
Sources/hpa/GenerateCommands/GenerateHtmlCommand.swift
Normal file
24
Sources/hpa/GenerateCommands/GenerateHtmlCommand.swift
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliClient
|
||||||
|
import Dependencies
|
||||||
|
|
||||||
|
// 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(\.cliClient) var cliClient
|
||||||
|
|
||||||
|
try await cliClient.runPandocCommand(
|
||||||
|
globals.pandocOptions(.html),
|
||||||
|
logging: globals.loggingOptions(commandName: Self.commandName)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
23
Sources/hpa/GenerateCommands/GenerateLatexCommand.swift
Normal file
23
Sources/hpa/GenerateCommands/GenerateLatexCommand.swift
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliClient
|
||||||
|
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(\.cliClient) var cliClient
|
||||||
|
|
||||||
|
try await cliClient.runPandocCommand(
|
||||||
|
globals.pandocOptions(.latex),
|
||||||
|
logging: globals.loggingOptions(commandName: Self.commandName)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
87
Sources/hpa/GenerateCommands/GenerateOptions.swift
Normal file
87
Sources/hpa/GenerateCommands/GenerateOptions.swift
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliClient
|
||||||
|
|
||||||
|
@dynamicMemberLookup
|
||||||
|
struct GenerateOptions: ParsableArguments {
|
||||||
|
|
||||||
|
@OptionGroup var basic: BasicGlobalOptions
|
||||||
|
|
||||||
|
@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) -> CliClient.LoggingOptions {
|
||||||
|
basic.loggingOptions(commandName: commandName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pandocOptions(
|
||||||
|
_ fileType: CliClient.PandocOptions.FileType
|
||||||
|
) -> CliClient.PandocOptions {
|
||||||
|
.init(
|
||||||
|
files: files.count > 0 ? files : nil,
|
||||||
|
includeInHeader: includeInHeader.count > 0 ? includeInHeader : nil,
|
||||||
|
outputDirectory: outputDirectory,
|
||||||
|
outputFileName: outputFileName,
|
||||||
|
outputFileType: fileType,
|
||||||
|
projectDirectory: projectDirectory,
|
||||||
|
quiet: basic.quiet,
|
||||||
|
shell: basic.shell,
|
||||||
|
shouldBuild: !noBuild
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Sources/hpa/GenerateCommands/GeneratePdfCommand.swift
Normal file
32
Sources/hpa/GenerateCommands/GeneratePdfCommand.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliClient
|
||||||
|
import Dependencies
|
||||||
|
|
||||||
|
// 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(\.cliClient) var cliClient
|
||||||
|
|
||||||
|
let output = try await cliClient.runPandocCommand(
|
||||||
|
globals.pandocOptions(.pdf(engine: pdfEngine)),
|
||||||
|
logging: globals.loggingOptions(commandName: Self.commandName)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,66 +4,17 @@ import Rainbow
|
|||||||
|
|
||||||
extension CommandConfiguration {
|
extension CommandConfiguration {
|
||||||
|
|
||||||
static func create(
|
|
||||||
commandName: String,
|
|
||||||
abstract: String,
|
|
||||||
usesExtraArgs: Bool = true,
|
|
||||||
discussion: Discussion
|
|
||||||
) -> Self {
|
|
||||||
.init(
|
|
||||||
commandName: commandName,
|
|
||||||
abstract: createAbstract(abstract),
|
|
||||||
discussion: discussion.render()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func create(
|
|
||||||
commandName: String,
|
|
||||||
abstract: String,
|
|
||||||
usesExtraArgs: Bool = true,
|
|
||||||
discussion nodes: [Node]
|
|
||||||
) -> Self {
|
|
||||||
.init(
|
|
||||||
commandName: commandName,
|
|
||||||
abstract: createAbstract(abstract),
|
|
||||||
discussion: Discussion(nodes: nodes, usesExtraArgs: usesExtraArgs).render()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func playbook2(
|
|
||||||
commandName: String,
|
|
||||||
abstract: String,
|
|
||||||
parentCommand: String? = nil,
|
|
||||||
examples: (label: String, example: String)...
|
|
||||||
) -> Self {
|
|
||||||
.init(
|
|
||||||
commandName: commandName,
|
|
||||||
abstract: Abstract { abstract.blue },
|
|
||||||
usage: """
|
|
||||||
\(Constants.appName)\(parentCommand != nil ? " \(parentCommand!)" : "") \(commandName)
|
|
||||||
""".blue.bold.italic
|
|
||||||
+ " [OPTIONS]".green
|
|
||||||
+ " [ARGUMENTS]".cyan
|
|
||||||
+ " --" + " [EXTRA-OPTIONS...]".magenta,
|
|
||||||
discussion: CliDoc.Discussion.playbook(examples: examples)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func playbook(
|
static func playbook(
|
||||||
commandName: String,
|
commandName: String,
|
||||||
abstract: String,
|
abstract: String,
|
||||||
parentCommand: String? = nil,
|
parentCommand: String? = nil,
|
||||||
examples: (label: String, example: String)...
|
examples: Example...
|
||||||
) -> Self {
|
) -> Self {
|
||||||
.create(
|
.init(
|
||||||
commandName: commandName,
|
commandName: commandName,
|
||||||
abstract: abstract,
|
abstract: Abstract { abstract.blue },
|
||||||
discussion: .default(
|
usage: Usage.default(commandName: commandName, parentCommand: parentCommand),
|
||||||
usesExtraArgs: true,
|
discussion: Discussion.playbook(examples: examples)
|
||||||
parentCommand: parentCommand,
|
|
||||||
examples: examples,
|
|
||||||
seeAlso: .seeAlso(label: "Ansible playbook options.", command: "ansible-playbook")
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,272 +23,39 @@ func createAbstract(_ string: String) -> String {
|
|||||||
"\(string.blue)"
|
"\(string.blue)"
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Remove
|
extension Usage where Content == AnyTextNode {
|
||||||
struct Discussion {
|
|
||||||
let nodes: [Node]
|
|
||||||
|
|
||||||
static func `default`(
|
static func `default`(
|
||||||
usesExtraArgs: Bool,
|
commandName: String,
|
||||||
parentCommand: String?,
|
parentCommand: String?,
|
||||||
examples: [(label: String, example: String)],
|
usesArguments: Bool = true,
|
||||||
seeAlso: Node?
|
usesOptions: Bool = true,
|
||||||
|
usesExtraArguments: Bool = true
|
||||||
) -> Self {
|
) -> Self {
|
||||||
var nodes = Array.defaultNodes + examples.nodes(parentCommand)
|
.init {
|
||||||
if let seeAlso { nodes.append(seeAlso) }
|
HStack {
|
||||||
if usesExtraArgs && examples.count > 0 { nodes.append(.important(label: Constants.importantExtraArgsNote)) }
|
HStack {
|
||||||
return .init(
|
"\(Constants.appName)"
|
||||||
nodes: nodes,
|
if let parentCommand {
|
||||||
usesExtraArgs: usesExtraArgs
|
parentCommand
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
commandName
|
||||||
init(usesExtraArgs: Bool = true, _ nodes: Node...) {
|
|
||||||
self.init(nodes: nodes, usesExtraArgs: usesExtraArgs)
|
|
||||||
}
|
}
|
||||||
|
.color(.blue).bold()
|
||||||
|
|
||||||
init(nodes: [Node], usesExtraArgs: Bool = true) {
|
if usesOptions {
|
||||||
var nodes = nodes
|
"[OPTIONS]".color(.green)
|
||||||
|
|
||||||
if let firstExampleIndex = nodes.firstIndex(where: \.isExampleNode) {
|
|
||||||
nodes.insert(.exampleHeading, at: firstExampleIndex)
|
|
||||||
}
|
}
|
||||||
|
if usesArguments {
|
||||||
if usesExtraArgs {
|
"[ARGUMENTS]".color(.cyan)
|
||||||
if let lastExampleIndex = nodes.lastIndex(where: \.isExampleNode) {
|
|
||||||
guard case let .example(.container(exampleNodes, _)) = nodes[lastExampleIndex] else {
|
|
||||||
// this should never happen.
|
|
||||||
print(nodes[lastExampleIndex])
|
|
||||||
fatalError()
|
|
||||||
}
|
}
|
||||||
|
if usesExtraArguments {
|
||||||
// Get the last element of the example container, which will be the
|
"--"
|
||||||
// text for the example.
|
"[EXTRA-OPTIONS]".color(.magenta)
|
||||||
guard let exampleNode = exampleNodes.last else {
|
|
||||||
print(exampleNodes)
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
// replace the first element, which is the header so that we can
|
|
||||||
// replace it with a new header.
|
|
||||||
nodes.insert(
|
|
||||||
.example(.exampleContainer(
|
|
||||||
"Passing extra args.",
|
|
||||||
"\(exampleNode.render().replacingOccurrences(of: " $ ", with: ""))"
|
|
||||||
+ " -- --vault-id \"myId@$SCRIPTS/vault-gopass-client\""
|
|
||||||
)),
|
|
||||||
at: nodes.index(after: lastExampleIndex)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.nodes = nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
func render() -> String { nodes.render() }
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Node {
|
|
||||||
|
|
||||||
struct Separator {
|
|
||||||
|
|
||||||
let string: String
|
|
||||||
let count: Int
|
|
||||||
|
|
||||||
init(_ string: String, repeating count: Int = 1) {
|
|
||||||
self.string = string
|
|
||||||
self.count = count
|
|
||||||
}
|
|
||||||
|
|
||||||
var value: String { string.repeating(count) }
|
|
||||||
|
|
||||||
static var empty: Self { .init("") }
|
|
||||||
|
|
||||||
static func space(_ count: Int = 1) -> Self {
|
|
||||||
.init(" ", repeating: count)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func newLine(_ count: Int = 1) -> Self {
|
|
||||||
.init("\n", repeating: count)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func tab(_ count: Int) -> Self {
|
|
||||||
.init("\t", repeating: count)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A container that holds onto other nodes to be rendered.
|
.eraseToAnyTextNode()
|
||||||
indirect case container(_ nodes: [Node], separator: Separator = .newLine())
|
|
||||||
|
|
||||||
/// A container to identify example nodes, so some other nodes can get injected
|
|
||||||
/// when this is rendered.
|
|
||||||
///
|
|
||||||
/// NOTE: Things blow up if this is not used correctly, at least when using the `Discussion`
|
|
||||||
/// to render the nodes, so this is not safe to create aside from the static methods that
|
|
||||||
/// create an example node. If not using in the context of a `Discussion` then it's fine to
|
|
||||||
/// use, as it doesn't do anything except stand as an type identifier.
|
|
||||||
indirect case example(_ node: Node)
|
|
||||||
|
|
||||||
/// A root node that renders the given string without modification.
|
|
||||||
case text(_ text: String)
|
|
||||||
|
|
||||||
static func container(separator: Separator = .newLine(), _ nodes: Node...) -> Self {
|
|
||||||
.container(nodes, separator: separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func container(_ lhs: Node, _ rhs: Node, separator: Separator = .newLine()) -> Self {
|
|
||||||
.container([lhs, rhs], separator: separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func styledText(_ text: String, color: NamedColor? = nil, styles: [Style]? = nil) -> Self {
|
|
||||||
var string = text
|
|
||||||
if let color {
|
|
||||||
string = string.applyingColor(color)
|
|
||||||
}
|
|
||||||
if let styles {
|
|
||||||
for style in styles {
|
|
||||||
string = string.applyingStyle(style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return .text(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func styledText(_ text: String, color: NamedColor? = nil, style: Style) -> Self {
|
|
||||||
.styledText(text, color: color, styles: [style])
|
|
||||||
}
|
|
||||||
|
|
||||||
static func boldYellowText(_ text: String) -> Self {
|
|
||||||
.styledText(text, color: .yellow, styles: [.bold])
|
|
||||||
}
|
|
||||||
|
|
||||||
static func section(header: Node, content: Node, separator: Separator = .newLine()) -> Self {
|
|
||||||
.container([header, content], separator: separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func section(header: String, label: Node, separator: Separator = .newLine()) -> Self {
|
|
||||||
.section(header: .boldYellowText(header), content: label, separator: separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func important(label: String) -> Self {
|
|
||||||
.section(
|
|
||||||
header: .text("IMPORTANT NOTE:".red.bold.underline),
|
|
||||||
content: .text(label.italic),
|
|
||||||
separator: .newLine()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func note(_ text: String, inline: Bool = true) -> Self {
|
|
||||||
.section(header: "NOTE:", label: .text(text.italic), separator: inline ? .space() : .newLine())
|
|
||||||
}
|
|
||||||
|
|
||||||
static func shellCommand(_ text: String, symbol: String = " $") -> Self {
|
|
||||||
.text("\(symbol) \(text)")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func seeAlso(label: String, command: String, appendHelpToCommand: Bool = true) -> Self {
|
|
||||||
.container([
|
|
||||||
.container(.boldYellowText("See Also:"), .text(label.italic), separator: .space()),
|
|
||||||
.shellCommand("\(appendHelpToCommand ? command + " --help" : command)")
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
static var exampleHeading: Self {
|
|
||||||
.section(header: "Examples:", label: .text("Some common examples."), separator: .space())
|
|
||||||
}
|
|
||||||
|
|
||||||
var isExampleNode: Bool {
|
|
||||||
if case .example = self { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate static func exampleContainer(_ label: String, _ command: String) -> Self {
|
|
||||||
.container(
|
|
||||||
.exampleLabel("\(label)\n"),
|
|
||||||
.shellCommand(command, symbol: " $"),
|
|
||||||
separator: .empty
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renders an example usage of the command.
|
|
||||||
static func example(label: String, example: String, parentCommand: String?) -> Self {
|
|
||||||
let string: String
|
|
||||||
if let parent = parentCommand {
|
|
||||||
string = "\(Constants.appName) \(parent) \(example)"
|
|
||||||
} else {
|
|
||||||
string = "\(Constants.appName) \(example)"
|
|
||||||
}
|
|
||||||
return .example(.exampleContainer(label, string))
|
|
||||||
}
|
|
||||||
|
|
||||||
static func exampleLabel(_ label: String) -> Node {
|
|
||||||
.text(" \(label.green.italic)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func render() -> String {
|
|
||||||
switch self {
|
|
||||||
case let .container(nodes, separator):
|
|
||||||
return nodes.render(separator: separator)
|
|
||||||
case let .text(text):
|
|
||||||
return text
|
|
||||||
case let .example(node):
|
|
||||||
return node.render()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Node: ExpressibleByStringLiteral {
|
|
||||||
|
|
||||||
typealias StringLiteralType = String
|
|
||||||
|
|
||||||
init(stringLiteral value: String) {
|
|
||||||
self = .text(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Node: ExpressibleByArrayLiteral {
|
|
||||||
|
|
||||||
typealias ArrayLiteralElement = Node
|
|
||||||
|
|
||||||
init(arrayLiteral elements: Node...) {
|
|
||||||
self = .container(elements)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension Array where Element == (label: String, example: String) {
|
|
||||||
func nodes(_ parentCommand: String?) -> [Node] {
|
|
||||||
map { .example(label: $0.label, example: $0.example, parentCommand: parentCommand) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Array where Element == Node {
|
|
||||||
|
|
||||||
static var defaultNodes: Self {
|
|
||||||
[.note("Most options are not required if you have a configuration file setup.")]
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate func render(separator: Node.Separator = .newLine()) -> String {
|
|
||||||
map {
|
|
||||||
// Strip of any new-line characters from the last section of the rendered string
|
|
||||||
// of the node. This allows us to have a consistent single new-line between each
|
|
||||||
// rendered node.
|
|
||||||
var string = $0.render()[...]
|
|
||||||
while string.hasSuffix("\n") {
|
|
||||||
string = string.dropLast()
|
|
||||||
}
|
|
||||||
return String(string)
|
|
||||||
}
|
|
||||||
.joined(separator: separator.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension String {
|
|
||||||
func repeating(_ count: Int) -> Self {
|
|
||||||
guard count > 0 else { return self }
|
|
||||||
var count = count
|
|
||||||
var output = self
|
|
||||||
|
|
||||||
while count > 0 {
|
|
||||||
output = "\(output)\(self)"
|
|
||||||
count -= 1
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,86 +1,146 @@
|
|||||||
import CliDoc
|
import CliDoc
|
||||||
|
|
||||||
extension CliDoc.Discussion {
|
extension CliDoc.Discussion where Content == AnyTextNode {
|
||||||
static func playbook(
|
static func playbook(
|
||||||
examples: [(label: String, example: String)]
|
examples: [Example]
|
||||||
) -> Self {
|
) -> Self {
|
||||||
.init {
|
.init {
|
||||||
Section {
|
VStack {
|
||||||
Note.mostOptionsNotRequired
|
Note.mostOptionsNotRequired
|
||||||
Examples(examples: examples)
|
ExampleSection.default(examples: examples)
|
||||||
SeeAlso(label: "Ansible playbook options.", command: "ansible-playbook --help")
|
SeeAlso(label: "Ansible playbook options.", command: "ansible-playbook --help")
|
||||||
ImportantNote.passingExtraArgs
|
ImportantNote.passingExtraArgs
|
||||||
}
|
}
|
||||||
.labeledContentStyle(.custom("foo:"))
|
.separator(.newLine(count: 2))
|
||||||
|
.eraseToAnyTextNode()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ShellCommand {
|
extension Array where Element == Example {
|
||||||
static func hpaCommand(_ command: String) -> Self {
|
func addingPassingOptions(command: String) -> Self {
|
||||||
.init(command: "\(Constants.appName) \(command)")
|
var output = self
|
||||||
|
output.append((
|
||||||
|
label: "Passing extra arguments / options",
|
||||||
|
example: "\(command) -- --vaultId=myId@$SCRIPTS/vault-gopass-client"
|
||||||
|
))
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SeeAlso: NodeRepresentable {
|
extension ExampleSection where Header == String, Label == String {
|
||||||
|
static func `default`(
|
||||||
|
examples: [Example],
|
||||||
|
usesPassingExtraOptions: Bool = true,
|
||||||
|
usesPipeToOtherCommand: Bool = true
|
||||||
|
) -> some TextNode {
|
||||||
|
var examples = examples
|
||||||
|
|
||||||
let node: any NodeRepresentable
|
|
||||||
|
|
||||||
init(label: String, command: String) {
|
|
||||||
self.node = Group(separator: "\n") {
|
|
||||||
Note("SEE ALSO:") {
|
|
||||||
label
|
|
||||||
}
|
|
||||||
ShellCommand(command: command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func render() -> String {
|
|
||||||
node.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Examples: NodeRepresentable {
|
|
||||||
typealias Example = (label: String, example: String)
|
|
||||||
let examples: [Example]
|
|
||||||
|
|
||||||
func render() -> String {
|
|
||||||
Group(separator: "\n") {
|
|
||||||
Note("EXAMPLES:") { "Common usage examples." }
|
|
||||||
for (label, command) in examples {
|
|
||||||
LabeledContent("\t\(label.green.bold)", separator: "\n") {
|
|
||||||
ShellCommand.hpaCommand(command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let first = examples.first {
|
if let first = examples.first {
|
||||||
LabeledContent("\n\tPassing extra options.".green.bold, separator: "\n") {
|
if usesPassingExtraOptions {
|
||||||
ShellCommand.hpaCommand("\(first.example) -- --vault-id \"myId@$SCRIPTS/vault-gopass-client\"")
|
examples = examples.addingPassingOptions(command: first.example)
|
||||||
|
}
|
||||||
|
if usesPipeToOtherCommand {
|
||||||
|
examples = examples.addingPipeToOtherCommand(command: first.example)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.render()
|
return Self(examples: examples) {
|
||||||
|
"EXAMPLES:"
|
||||||
|
} label: {
|
||||||
|
"Some common usage examples."
|
||||||
|
}
|
||||||
|
.exampleStyle(HPAExampleStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Note: NodeRepresentable {
|
struct HPAExampleStyle: ExampleStyle {
|
||||||
|
|
||||||
let node: any NodeRepresentable
|
func render(content: ExampleConfiguration) -> some TextNode {
|
||||||
|
VStack {
|
||||||
|
content.examples.map { example in
|
||||||
|
VStack {
|
||||||
|
example.label.color(.green).bold()
|
||||||
|
ShellCommand.hpaCommand(example.example)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.separator(.newLine(count: 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ShellCommand where Content == String, Symbol == String {
|
||||||
|
static func hpaCommand(_ command: String) -> Self {
|
||||||
|
.init { "\(Constants.appName) \(command)" } symbol: { "$" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SeeAlso<Label: TextNode, Content: TextNode>: TextNode {
|
||||||
|
|
||||||
|
let title = "SEE ALSO:"
|
||||||
|
let label: Label
|
||||||
|
let content: Content
|
||||||
|
|
||||||
init(
|
init(
|
||||||
_ label: String = "NOTE:",
|
@TextBuilder content: () -> Content,
|
||||||
@NodeBuilder _ content: () -> any NodeRepresentable
|
@TextBuilder label: () -> Label
|
||||||
) {
|
) {
|
||||||
self.node = LabeledContent(
|
self.content = content()
|
||||||
separator: " ",
|
self.label = label()
|
||||||
label: { label.yellow.bold },
|
|
||||||
content: { content().style(.italic) }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func render() -> String {
|
var body: some TextNode {
|
||||||
node.render()
|
VStack {
|
||||||
|
LabeledContent {
|
||||||
|
label.italic()
|
||||||
|
} label: {
|
||||||
|
title.color(.yellow).bold().underline()
|
||||||
|
}
|
||||||
|
ShellCommand { content } symbol: { "$" }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension SeeAlso where Content == String, Label == Empty {
|
||||||
|
init(command: String) {
|
||||||
|
self.init { "\(Constants.appName) \(command)" } label: { Empty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SeeAlso where Content == String, Label == String {
|
||||||
|
init(label: String, command: String) {
|
||||||
|
self.init { "\(Constants.appName) \(command)" } label: { label }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Note<Content: TextNode>: TextNode {
|
||||||
|
|
||||||
|
let label: String = "NOTE:"
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
init(
|
||||||
|
@TextBuilder _ content: () -> Content
|
||||||
|
) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some TextNode {
|
||||||
|
HStack {
|
||||||
|
label.color(.yellow).bold()
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Note where Content == String {
|
||||||
static var mostOptionsNotRequired: Self {
|
static var mostOptionsNotRequired: Self {
|
||||||
.init {
|
.init {
|
||||||
"Most options are not required if you have a configuration file setup."
|
"Most options are not required if you have a configuration file setup."
|
||||||
@@ -88,26 +148,27 @@ struct Note: NodeRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Fix the text.
|
struct ImportantNote<Content: TextNode>: TextNode {
|
||||||
struct ImportantNote: NodeRepresentable {
|
|
||||||
|
|
||||||
let node: any NodeRepresentable
|
let label: String = "IMPORTANT NOTE:"
|
||||||
|
let content: Content
|
||||||
|
|
||||||
init(
|
init(
|
||||||
_ label: String = "IMPORTANT NOTE:",
|
@TextBuilder _ content: () -> Content
|
||||||
@NodeBuilder _ content: () -> any NodeRepresentable
|
|
||||||
) {
|
) {
|
||||||
self.node = LabeledContent(
|
self.content = content()
|
||||||
separator: "\n",
|
|
||||||
label: { label.red.bold.underline },
|
|
||||||
content: { content().style(.italic) }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func render() -> String {
|
var body: some TextNode {
|
||||||
node.render()
|
HStack {
|
||||||
|
label.color(.red).bold().underline()
|
||||||
|
content
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ImportantNote where Content == String {
|
||||||
static var passingExtraArgs: Self {
|
static var passingExtraArgs: Self {
|
||||||
.init {
|
.init {
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
import CliClient
|
||||||
|
import ConfigurationClient
|
||||||
|
import PlaybookClient
|
||||||
|
|
||||||
struct BasicGlobalOptions: ParsableArguments {
|
struct BasicGlobalOptions: ParsableArguments {
|
||||||
@Flag(
|
@Flag(
|
||||||
@@ -26,10 +29,10 @@ struct BasicGlobalOptions: ParsableArguments {
|
|||||||
struct GlobalOptions: ParsableArguments {
|
struct GlobalOptions: ParsableArguments {
|
||||||
|
|
||||||
@Option(
|
@Option(
|
||||||
name: .shortAndLong,
|
name: .long,
|
||||||
help: "Optional path to the ansible hpa playbook directory."
|
help: "Optional path to the ansible hpa playbook directory."
|
||||||
)
|
)
|
||||||
var playbookDir: String?
|
var playbookDirectory: String?
|
||||||
|
|
||||||
@Option(
|
@Option(
|
||||||
name: .shortAndLong,
|
name: .shortAndLong,
|
||||||
@@ -55,3 +58,59 @@ struct GlobalOptions: ParsableArguments {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Update these to use CommandClient.LoggingOptions
|
||||||
|
|
||||||
|
extension GlobalOptions {
|
||||||
|
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
|
||||||
|
.init(
|
||||||
|
commandName: commandName,
|
||||||
|
logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BasicGlobalOptions {
|
||||||
|
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
|
||||||
|
.init(
|
||||||
|
commandName: commandName,
|
||||||
|
logLevel: .init(globals: self, quietOnlyPlaybook: false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Remove
|
||||||
|
extension GlobalOptions {
|
||||||
|
|
||||||
|
func playbookOptions(
|
||||||
|
arguments: [String],
|
||||||
|
configuration: Configuration?
|
||||||
|
) -> CliClient.PlaybookOptions {
|
||||||
|
.init(
|
||||||
|
arguments: arguments,
|
||||||
|
configuration: configuration,
|
||||||
|
inventoryFilePath: inventoryPath,
|
||||||
|
playbookDirectory: playbookDirectory,
|
||||||
|
quiet: quietOnlyPlaybook ? true : basic.quiet,
|
||||||
|
shell: basic.shell
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Dependencies
|
|
||||||
import Logging
|
import Logging
|
||||||
|
|
||||||
extension Logger.Level {
|
extension Logger.Level {
|
||||||
@@ -21,34 +20,3 @@ extension Logger.Level {
|
|||||||
self = .info
|
self = .info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func withSetupLogger(
|
|
||||||
commandName: String,
|
|
||||||
globals: BasicGlobalOptions,
|
|
||||||
quietOnlyPlaybook: Bool = false,
|
|
||||||
dependencies setupDependencies: (inout DependencyValues) -> Void = { _ in },
|
|
||||||
operation: @escaping () async throws -> Void
|
|
||||||
) async rethrows {
|
|
||||||
try await withDependencies {
|
|
||||||
$0.logger = .init(label: "\("hpa".yellow)")
|
|
||||||
$0.logger.logLevel = .init(globals: globals, quietOnlyPlaybook: quietOnlyPlaybook)
|
|
||||||
$0.logger[metadataKey: "command"] = "\(commandName.blue)"
|
|
||||||
} operation: {
|
|
||||||
try await operation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func withSetupLogger(
|
|
||||||
commandName: String,
|
|
||||||
globals: GlobalOptions,
|
|
||||||
dependencies setupDependencies: (inout DependencyValues) -> Void = { _ in },
|
|
||||||
operation: @escaping () async throws -> Void
|
|
||||||
) async rethrows {
|
|
||||||
try await withSetupLogger(
|
|
||||||
commandName: commandName,
|
|
||||||
globals: globals.basic,
|
|
||||||
quietOnlyPlaybook: globals.quietOnlyPlaybook,
|
|
||||||
dependencies: setupDependencies,
|
|
||||||
operation: operation
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import ArgumentParser
|
|
||||||
import CliClient
|
|
||||||
import Dependencies
|
|
||||||
import Foundation
|
|
||||||
import Logging
|
|
||||||
import Rainbow
|
|
||||||
import ShellClient
|
|
||||||
|
|
||||||
func runPlaybook(
|
|
||||||
commandName: String,
|
|
||||||
globals: GlobalOptions,
|
|
||||||
configuration: Configuration? = nil,
|
|
||||||
extraArgs: [String],
|
|
||||||
_ args: [String]
|
|
||||||
) async throws {
|
|
||||||
try await withSetupLogger(commandName: commandName, globals: globals) {
|
|
||||||
@Dependency(\.cliClient) var cliClient
|
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
|
|
||||||
logger.debug("Begin run playbook: \(globals)")
|
|
||||||
|
|
||||||
let configuration = try cliClient.ensuredConfiguration(configuration)
|
|
||||||
|
|
||||||
logger.debug("Configuration: \(configuration)")
|
|
||||||
|
|
||||||
let playbookDir = try ensureString(
|
|
||||||
globals: globals,
|
|
||||||
configuration: configuration,
|
|
||||||
globalsKeyPath: \.playbookDir,
|
|
||||||
configurationKeyPath: \.playbookDir
|
|
||||||
)
|
|
||||||
let playbook = "\(playbookDir)/\(Constants.playbookFileName)"
|
|
||||||
|
|
||||||
let inventory = (try? ensureString(
|
|
||||||
globals: globals,
|
|
||||||
configuration: configuration,
|
|
||||||
globalsKeyPath: \.inventoryPath,
|
|
||||||
configurationKeyPath: \.inventoryPath
|
|
||||||
)) ?? "\(playbookDir)/\(Constants.inventoryFileName)"
|
|
||||||
|
|
||||||
let defaultArgs = (configuration.defaultPlaybookArgs ?? [])
|
|
||||||
+ (configuration.defaultVaultArgs ?? [])
|
|
||||||
|
|
||||||
try await cliClient.runCommand(
|
|
||||||
quiet: globals.quietOnlyPlaybook ? true : globals.quiet,
|
|
||||||
shell: globals.shellOrDefault,
|
|
||||||
[
|
|
||||||
"ansible-playbook", playbook,
|
|
||||||
"--inventory", inventory
|
|
||||||
] + args
|
|
||||||
+ extraArgs
|
|
||||||
+ defaultArgs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPlaybook(
|
|
||||||
commandName: String,
|
|
||||||
globals: GlobalOptions,
|
|
||||||
configuration: Configuration? = nil,
|
|
||||||
extraArgs: [String],
|
|
||||||
_ args: String...
|
|
||||||
) async throws {
|
|
||||||
try await runPlaybook(
|
|
||||||
commandName: commandName,
|
|
||||||
globals: globals,
|
|
||||||
configuration: configuration,
|
|
||||||
extraArgs: extraArgs,
|
|
||||||
args
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CliClient {
|
|
||||||
func ensuredConfiguration(_ configuration: Configuration?) throws -> Configuration {
|
|
||||||
guard let configuration else {
|
|
||||||
return try loadConfiguration()
|
|
||||||
}
|
|
||||||
return configuration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension BasicGlobalOptions {
|
|
||||||
|
|
||||||
var shellOrDefault: ShellCommand.Shell {
|
|
||||||
guard let shell else { return .zsh(useDashC: true) }
|
|
||||||
return .custom(path: shell, useDashC: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureString(
|
|
||||||
globals: GlobalOptions,
|
|
||||||
configuration: Configuration,
|
|
||||||
globalsKeyPath: KeyPath<GlobalOptions, String?>,
|
|
||||||
configurationKeyPath: KeyPath<Configuration, String?>
|
|
||||||
) throws -> String {
|
|
||||||
if let global = globals[keyPath: globalsKeyPath] {
|
|
||||||
return global
|
|
||||||
}
|
|
||||||
guard let configuration = configuration[keyPath: configurationKeyPath] else {
|
|
||||||
throw RunPlaybookError.playbookNotFound
|
|
||||||
}
|
|
||||||
return configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RunPlaybookError: Error {
|
|
||||||
case playbookNotFound
|
|
||||||
case configurationError
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import Dependencies
|
|
||||||
import ShellClient
|
|
||||||
|
|
||||||
func runVault(
|
|
||||||
commandName: String,
|
|
||||||
options: VaultOptions,
|
|
||||||
_ args: [String]
|
|
||||||
) async throws {
|
|
||||||
try await withSetupLogger(commandName: commandName, globals: options.globals) {
|
|
||||||
@Dependency(\.cliClient) var cliClient
|
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
|
|
||||||
logger.debug("Begin run vault: \(options)")
|
|
||||||
|
|
||||||
let configuration = try cliClient.ensuredConfiguration(nil)
|
|
||||||
logger.debug("Configuration: \(configuration)")
|
|
||||||
|
|
||||||
let path: String
|
|
||||||
if let file = options.file {
|
|
||||||
path = file
|
|
||||||
} else {
|
|
||||||
path = try cliClient.findVaultFileInCurrentDirectory()
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Vault path: \(path)")
|
|
||||||
|
|
||||||
let defaultArgs = configuration.defaultVaultArgs ?? []
|
|
||||||
|
|
||||||
var vaultArgs = ["ansible-vault"]
|
|
||||||
+ args
|
|
||||||
+ defaultArgs
|
|
||||||
+ options.extraArgs
|
|
||||||
+ [path]
|
|
||||||
|
|
||||||
if args.contains("encrypt"),
|
|
||||||
!vaultArgs.contains("--encrypt-vault-id"),
|
|
||||||
let id = configuration.defaultVaultEncryptId
|
|
||||||
{
|
|
||||||
vaultArgs.append(contentsOf: ["--encrypt-vault-id", id])
|
|
||||||
}
|
|
||||||
|
|
||||||
try await cliClient.runCommand(
|
|
||||||
quiet: options.quiet,
|
|
||||||
shell: options.shellOrDefault,
|
|
||||||
vaultArgs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
18
Sources/hpa/Internal/VaultOptions+Globals.swift
Normal file
18
Sources/hpa/Internal/VaultOptions+Globals.swift
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import ArgumentParser
|
|
||||||
|
|
||||||
struct CreateProjectTemplateCommand: AsyncParsableCommand {
|
|
||||||
|
|
||||||
static let commandName = "create-template"
|
|
||||||
|
|
||||||
static let configuration = CommandConfiguration.playbook(
|
|
||||||
commandName: commandName,
|
|
||||||
abstract: "Create a home performance assesment project template.",
|
|
||||||
parentCommand: UtilsCommand.commandName,
|
|
||||||
examples: (label: "Create Template", example: "\(commandName) /path/to/project-template")
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptionGroup var globals: GlobalOptions
|
|
||||||
|
|
||||||
@Option(
|
|
||||||
name: .shortAndLong,
|
|
||||||
help: "Customize the directory where template variables are stored."
|
|
||||||
)
|
|
||||||
var templateVars: String?
|
|
||||||
|
|
||||||
@Flag(
|
|
||||||
name: .long,
|
|
||||||
help: "Do not generate ansible-vault variables file."
|
|
||||||
)
|
|
||||||
var noVault: Bool = false
|
|
||||||
|
|
||||||
@Argument(
|
|
||||||
help: "Path to the project template directory.",
|
|
||||||
completion: .directory
|
|
||||||
)
|
|
||||||
var path: String
|
|
||||||
|
|
||||||
@Argument(
|
|
||||||
help: "Extra arguments passed to the playbook."
|
|
||||||
)
|
|
||||||
var extraArgs: [String] = []
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
let varsDir = templateVars != nil
|
|
||||||
? ["--extra-vars", "repo_vars_dir=\(templateVars!)"]
|
|
||||||
: []
|
|
||||||
|
|
||||||
let vault = noVault ? ["--extra-vars", "use_vault=false"] : []
|
|
||||||
|
|
||||||
try await runPlaybook(
|
|
||||||
commandName: Self.commandName,
|
|
||||||
globals: globals,
|
|
||||||
extraArgs: extraArgs,
|
|
||||||
[
|
|
||||||
"--tags", "repo-template",
|
|
||||||
"--extra-vars", "output_dir=\(path)"
|
|
||||||
] + varsDir
|
|
||||||
+ vault
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import CliClient
|
import CliClient
|
||||||
|
import CliDoc
|
||||||
|
import ConfigurationClient
|
||||||
import Dependencies
|
import Dependencies
|
||||||
|
|
||||||
struct GenerateConfigurationCommand: AsyncParsableCommand {
|
struct GenerateConfigurationCommand: AsyncParsableCommand {
|
||||||
@@ -9,18 +11,23 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
|
|||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: commandName,
|
commandName: commandName,
|
||||||
abstract: createAbstract("Generate a local configuration file."),
|
abstract: createAbstract("Generate a local configuration file."),
|
||||||
discussion: """
|
discussion: Discussion {
|
||||||
|
VStack {
|
||||||
\("NOTE:".yellow) If a directory is not supplied then a configuration file will be created
|
Note {
|
||||||
at \("'~/.config/hpa-playbook/config'".yellow).
|
|
||||||
|
|
||||||
\("Example:".yellow)
|
|
||||||
|
|
||||||
\("Create a directory and generate the configuration file.".green)
|
|
||||||
$ mkdir -p ~/.config/hpa-playbook
|
|
||||||
$ hpa generate-config --path ~/.config/hpa-playbook
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
If a directory is not supplied then a configuration file will be created
|
||||||
|
at \("'~/.config/hpa-playbook/config'".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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.separator(.newLine(count: 2))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@Option(
|
@Option(
|
||||||
@@ -36,6 +43,12 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
|
|||||||
)
|
)
|
||||||
var json: Bool = false
|
var json: Bool = false
|
||||||
|
|
||||||
|
@Flag(
|
||||||
|
name: .shortAndLong,
|
||||||
|
help: "Force generation, overwriting a file if it exists."
|
||||||
|
)
|
||||||
|
var force: Bool = false
|
||||||
|
|
||||||
@OptionGroup var globals: BasicGlobalOptions
|
@OptionGroup var globals: BasicGlobalOptions
|
||||||
|
|
||||||
mutating func run() async throws {
|
mutating func run() async throws {
|
||||||
@@ -43,24 +56,26 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _run() async throws {
|
private func _run() async throws {
|
||||||
try await withSetupLogger(commandName: Self.commandName, globals: globals) {
|
|
||||||
@Dependency(\.cliClient) var cliClient
|
@Dependency(\.cliClient) var cliClient
|
||||||
|
@Dependency(\.configurationClient) var configurationClient
|
||||||
|
|
||||||
let actualPath: String
|
try await cliClient.withLogger(globals.loggingOptions(commandName: Self.commandName)) {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
if let path {
|
let actualPath: File
|
||||||
actualPath = "\(path)/config"
|
|
||||||
|
if let path, let file = File("\(path)/config.\(json ? "json" : "toml")") {
|
||||||
|
actualPath = file
|
||||||
} else {
|
} else {
|
||||||
let path = "~/.config/hpa-playbook/"
|
actualPath = .default
|
||||||
try await cliClient.runCommand(
|
|
||||||
quiet: false,
|
|
||||||
shell: globals.shellOrDefault,
|
|
||||||
"mkdir", "-p", path
|
|
||||||
)
|
|
||||||
actualPath = "\(path)/config"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try cliClient.createConfiguration(actualPath, json)
|
logger.debug("Generating config at path: \(actualPath.path)")
|
||||||
|
|
||||||
|
try await configurationClient.generate(
|
||||||
|
at: actualPath,
|
||||||
|
force: force
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliClient
|
||||||
|
import Dependencies
|
||||||
|
import PlaybookClient
|
||||||
|
|
||||||
|
struct GenerateProjectTemplateCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
|
static let commandName = "generate-template"
|
||||||
|
|
||||||
|
static let configuration = CommandConfiguration.playbook(
|
||||||
|
commandName: commandName,
|
||||||
|
abstract: "Generate a home performance assesment project template.",
|
||||||
|
parentCommand: UtilsCommand.commandName,
|
||||||
|
examples: (label: "Generate Template", example: "\(commandName) /path/to/project-template")
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var globals: GlobalOptions
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: .shortAndLong,
|
||||||
|
help: "Customize the directory where template variables are stored."
|
||||||
|
)
|
||||||
|
var templateVars: String?
|
||||||
|
|
||||||
|
@Flag(
|
||||||
|
name: .long,
|
||||||
|
help: "Do not generate ansible-vault variables file."
|
||||||
|
)
|
||||||
|
var noVault: Bool = false
|
||||||
|
|
||||||
|
@Argument(
|
||||||
|
help: "Path to the project template directory.",
|
||||||
|
completion: .directory
|
||||||
|
)
|
||||||
|
var path: String
|
||||||
|
|
||||||
|
@Argument(
|
||||||
|
help: "Extra arguments / options passed to the playbook."
|
||||||
|
)
|
||||||
|
var extraOptions: [String] = []
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
|
let output = try await playbookClient.run.generateTemplate(.init(
|
||||||
|
shared: globals.sharedPlaybookRunOptions(
|
||||||
|
commandName: Self.commandName,
|
||||||
|
extraOptions: extraOptions
|
||||||
|
),
|
||||||
|
templateDirectory: path,
|
||||||
|
templateVarsDirectory: templateVars,
|
||||||
|
useVault: !noVault
|
||||||
|
))
|
||||||
|
|
||||||
|
print(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift
Normal file
43
Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliClient
|
||||||
|
import CliDoc
|
||||||
|
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(\.cliClient) var cliClient
|
||||||
|
try await cliClient.installDependencies(
|
||||||
|
quiet: globals.quiet,
|
||||||
|
shell: globals.shell,
|
||||||
|
extraArgs: extraOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@ struct UtilsCommand: AsyncParsableCommand {
|
|||||||
|
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: commandName,
|
commandName: commandName,
|
||||||
abstract: "\("Utility commands.".blue)",
|
abstract: createAbstract("Utility commands."),
|
||||||
discussion: """
|
discussion: """
|
||||||
These are commands that are generally only run on occasion / less frequently used.
|
These are commands that are generally only run on occasion / less frequently used.
|
||||||
""",
|
""",
|
||||||
subcommands: [
|
subcommands: [
|
||||||
CreateProjectTemplateCommand.self, GenerateConfigurationCommand.self
|
GenerateProjectTemplateCommand.self,
|
||||||
|
GenerateConfigurationCommand.self,
|
||||||
|
InstallDependenciesCommand.self
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
import CliClient
|
||||||
|
import Dependencies
|
||||||
|
import VaultClient
|
||||||
|
|
||||||
struct DecryptCommand: AsyncParsableCommand {
|
struct DecryptCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
@@ -9,23 +12,22 @@ struct DecryptCommand: AsyncParsableCommand {
|
|||||||
abstract: createAbstract("Decrypt a vault file.")
|
abstract: createAbstract("Decrypt a vault file.")
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptionGroup var options: VaultOptions
|
|
||||||
|
|
||||||
@Option(
|
@Option(
|
||||||
name: .shortAndLong,
|
name: .shortAndLong,
|
||||||
help: "Output file."
|
help: "Output file."
|
||||||
)
|
)
|
||||||
var output: String?
|
var output: String?
|
||||||
|
|
||||||
|
@OptionGroup var options: VaultOptions
|
||||||
|
|
||||||
mutating func run() async throws {
|
mutating func run() async throws {
|
||||||
var args = ["decrypt"]
|
@Dependency(\.vaultClient) var vaultClient
|
||||||
if let output {
|
|
||||||
args.append(contentsOf: ["--output", output])
|
let output = try await vaultClient.run.decrypt(options.runOptions(
|
||||||
}
|
|
||||||
try await runVault(
|
|
||||||
commandName: Self.commandName,
|
commandName: Self.commandName,
|
||||||
options: options,
|
outputFilePath: output
|
||||||
args
|
))
|
||||||
)
|
|
||||||
|
print(output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
import CliClient
|
||||||
|
import Dependencies
|
||||||
|
import VaultClient
|
||||||
|
|
||||||
struct EncryptCommand: AsyncParsableCommand {
|
struct EncryptCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
@@ -9,23 +12,22 @@ struct EncryptCommand: AsyncParsableCommand {
|
|||||||
abstract: createAbstract("Encrypt a vault file.")
|
abstract: createAbstract("Encrypt a vault file.")
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptionGroup var options: VaultOptions
|
|
||||||
|
|
||||||
@Option(
|
@Option(
|
||||||
name: .shortAndLong,
|
name: .shortAndLong,
|
||||||
help: "Output file."
|
help: "Output file."
|
||||||
)
|
)
|
||||||
var output: String?
|
var output: String?
|
||||||
|
|
||||||
|
@OptionGroup var options: VaultOptions
|
||||||
|
|
||||||
mutating func run() async throws {
|
mutating func run() async throws {
|
||||||
var args = ["encrypt"]
|
@Dependency(\.vaultClient) var vaultClient
|
||||||
if let output {
|
|
||||||
args.append(contentsOf: ["--output", output])
|
let output = try await vaultClient.run.encrypt(options.runOptions(
|
||||||
}
|
|
||||||
try await runVault(
|
|
||||||
commandName: Self.commandName,
|
commandName: Self.commandName,
|
||||||
options: options,
|
outputFilePath: output
|
||||||
args
|
))
|
||||||
)
|
|
||||||
|
print(output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
import CliDoc
|
||||||
|
|
||||||
struct VaultCommand: AsyncParsableCommand {
|
struct VaultCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
@@ -7,12 +8,21 @@ struct VaultCommand: AsyncParsableCommand {
|
|||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: commandName,
|
commandName: commandName,
|
||||||
abstract: createAbstract("Vault commands."),
|
abstract: createAbstract("Vault commands."),
|
||||||
discussion: Discussion(
|
discussion: Discussion {
|
||||||
.text("""
|
VStack {
|
||||||
|
"""
|
||||||
Allows you to run `ansible-vault` commands on your project or project-template.
|
Allows you to run `ansible-vault` commands on your project or project-template.
|
||||||
"""),
|
|
||||||
.seeAlso(label: "Ansible Vault", command: "ansible-vault")
|
Ansible-vault allows you to store sensitive variables in an encrypted format.
|
||||||
).render(),
|
"""
|
||||||
|
SeeAlso {
|
||||||
|
"ansible-vault --help"
|
||||||
|
} label: {
|
||||||
|
"Ansible Vault"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.separator(.newLine(count: 2))
|
||||||
|
},
|
||||||
subcommands: [
|
subcommands: [
|
||||||
EncryptCommand.self, DecryptCommand.self
|
EncryptCommand.self, DecryptCommand.self
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
import CliClient
|
||||||
|
import VaultClient
|
||||||
|
|
||||||
// Holds the common options for vault commands, as they all share the
|
// Holds the common options for vault commands, as they all share the
|
||||||
// same structure.
|
// same structure.
|
||||||
@@ -17,7 +19,7 @@ struct VaultOptions: ParsableArguments {
|
|||||||
@Argument(
|
@Argument(
|
||||||
help: "Extra arguments to pass to the vault command."
|
help: "Extra arguments to pass to the vault command."
|
||||||
)
|
)
|
||||||
var extraArgs: [String] = []
|
var extraOptions: [String] = []
|
||||||
|
|
||||||
subscript<T>(dynamicMember keyPath: WritableKeyPath<BasicGlobalOptions, T>) -> T {
|
subscript<T>(dynamicMember keyPath: WritableKeyPath<BasicGlobalOptions, T>) -> T {
|
||||||
get { globals[keyPath: keyPath] }
|
get { globals[keyPath: keyPath] }
|
||||||
@@ -29,3 +31,29 @@ struct VaultOptions: ParsableArguments {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension VaultOptions {
|
||||||
|
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
|
||||||
|
globals.loggingOptions(commandName: commandName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VaultOptions {
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
2
Sources/hpa/Version.swift
Normal file
2
Sources/hpa/Version.swift
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Do not set this variable, it is set during the build process.
|
||||||
|
let VERSION: String? = nil
|
||||||
@@ -1,62 +1,333 @@
|
|||||||
@_spi(Internal) import CliClient
|
@_spi(Internal) import CliClient
|
||||||
|
import ConfigurationClient
|
||||||
import Dependencies
|
import Dependencies
|
||||||
|
import FileClient
|
||||||
|
import Foundation
|
||||||
|
import PlaybookClient
|
||||||
import ShellClient
|
import ShellClient
|
||||||
import Testing
|
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
|
@Test
|
||||||
func testFindConfigPaths() throws {
|
func capturingClient() async throws {
|
||||||
try withTestLogger(key: "testFindConfigPaths") {
|
let captured = CliClient.CapturingClient()
|
||||||
$0.fileClient = .liveValue
|
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: {
|
} operation: {
|
||||||
@Dependency(\.logger) var logger
|
@Dependency(\.cliClient) var cliClient
|
||||||
let urls = try findConfigurationFiles()
|
let configuration = Configuration.mock
|
||||||
logger.debug("urls: \(urls)")
|
|
||||||
// #expect(urls.count == 1)
|
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
|
@Test
|
||||||
func loadConfiguration() throws {
|
func shellOrDefault() {
|
||||||
try withTestLogger(key: "loadConfiguration", logLevel: .debug) {
|
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" : {
|
||||||
|
"url" : "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" : {
|
||||||
|
"url" : "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
|
$0.cliClient = .liveValue
|
||||||
$0.fileClient = .liveValue
|
|
||||||
} operation: {
|
} operation: {
|
||||||
@Dependency(\.cliClient) var client
|
@Dependency(\.cliClient) var cliClient
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
let config = try client.loadConfiguration()
|
let json = try? await cliClient.generateJSON(
|
||||||
logger.debug("\(config)")
|
input.options,
|
||||||
#expect(config.playbookDir != nil)
|
logging: Self.loggingOptions,
|
||||||
|
encoder: jsonEncoder
|
||||||
|
)
|
||||||
|
|
||||||
|
switch input.expectation {
|
||||||
|
case let .success(expected):
|
||||||
|
#expect(json == expected)
|
||||||
|
case .failing:
|
||||||
|
#expect(json == nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func withTestLogger(
|
func withMockConfiguration(
|
||||||
|
_ capturing: CliClient.CapturingClient,
|
||||||
|
configuration: Configuration = .mock,
|
||||||
key: String,
|
key: String,
|
||||||
label: String = "CliClientTests",
|
logLevel: Logger.Level = .trace,
|
||||||
logLevel: Logger.Level = .debug,
|
depednencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
|
||||||
operation: @escaping @Sendable () throws -> Void
|
operation: @Sendable @escaping () async throws -> Void
|
||||||
) rethrows {
|
) async rethrows {
|
||||||
try withDependencies {
|
try await withTestLogger(key: key, logLevel: logLevel) {
|
||||||
$0.logger = .init(label: label)
|
$0.configurationClient = .mock(configuration)
|
||||||
$0.logger[metadataKey: "test"] = "\(key)"
|
$0.cliClient = .capturing(capturing)
|
||||||
$0.logger.logLevel = logLevel
|
$0.playbookClient = .liveValue
|
||||||
} operation: {
|
|
||||||
try operation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func withTestLogger(
|
|
||||||
key: String,
|
|
||||||
label: String = "CliClientTests",
|
|
||||||
logLevel: Logger.Level = .debug,
|
|
||||||
dependencies setupDependencies: @escaping (inout DependencyValues) -> Void,
|
|
||||||
operation: @escaping @Sendable () throws -> Void
|
|
||||||
) rethrows {
|
|
||||||
try withDependencies {
|
|
||||||
$0.logger = .init(label: label)
|
|
||||||
$0.logger[metadataKey: "test"] = "\(key)"
|
|
||||||
$0.logger.logLevel = logLevel
|
|
||||||
setupDependencies(&$0)
|
setupDependencies(&$0)
|
||||||
} operation: {
|
} operation: {
|
||||||
try 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
|
||||||
|
}()
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
@_spi(Internal) import CliDoc
|
|
||||||
@preconcurrency import Rainbow
|
|
||||||
import Testing
|
|
||||||
import XCTest
|
|
||||||
|
|
||||||
final class CliDocTests: XCTestCase {
|
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
Rainbow.outputTarget = .console
|
|
||||||
Rainbow.enabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func testStringChecks() {
|
|
||||||
let expected = "Foo".green.bold
|
|
||||||
|
|
||||||
XCTAssert("Foo".green.bold == expected)
|
|
||||||
XCTAssert(expected != "Foo")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testRepeatingModifier() {
|
|
||||||
let node = AnyNode {
|
|
||||||
Text("foo").color(.green).style(.bold)
|
|
||||||
"\n".repeating(2)
|
|
||||||
Text("bar").repeating(2, separator: " ")
|
|
||||||
}
|
|
||||||
let expected = """
|
|
||||||
\("foo".green.bold)
|
|
||||||
|
|
||||||
bar bar
|
|
||||||
"""
|
|
||||||
XCTAssert(node.render() == expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testGroup1() {
|
|
||||||
let arguments = [
|
|
||||||
(true, "foo bar"),
|
|
||||||
(false, """
|
|
||||||
foo
|
|
||||||
bar
|
|
||||||
""")
|
|
||||||
]
|
|
||||||
|
|
||||||
for (inline, expected) in arguments {
|
|
||||||
let node = AnyNode {
|
|
||||||
Group(separator: inline ? " " : "\n") {
|
|
||||||
Text("foo")
|
|
||||||
Text("bar")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
XCTAssert(node.render() == expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testHeader() {
|
|
||||||
let header = Header("Foo")
|
|
||||||
let expected = "\("Foo".yellow.bold)"
|
|
||||||
XCTAssert(header.render() == expected)
|
|
||||||
|
|
||||||
let header2 = Header {
|
|
||||||
"Foo".yellow.bold
|
|
||||||
}
|
|
||||||
XCTAssert(header2.render() == expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testGroup() {
|
|
||||||
let group = Group {
|
|
||||||
Text("foo")
|
|
||||||
Text("bar")
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssert(group.render() == "foo bar")
|
|
||||||
|
|
||||||
let group2 = Group(separator: "\n") {
|
|
||||||
Text("foo")
|
|
||||||
Text("bar")
|
|
||||||
}
|
|
||||||
let expected = """
|
|
||||||
foo
|
|
||||||
bar
|
|
||||||
"""
|
|
||||||
XCTAssert(group2.render() == expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testLabeledContent() {
|
|
||||||
let node = LabeledContent("Foo") {
|
|
||||||
Text("Bar")
|
|
||||||
}
|
|
||||||
.labelStyle(.green)
|
|
||||||
.labelStyle(.bold)
|
|
||||||
|
|
||||||
let expected = """
|
|
||||||
\("Foo".green.bold)
|
|
||||||
Bar
|
|
||||||
"""
|
|
||||||
XCTAssert(node.render() == expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testLabeledContent2() {
|
|
||||||
let node = LabeledContent2 {
|
|
||||||
"Foo"
|
|
||||||
} content: {
|
|
||||||
Text("Bar")
|
|
||||||
}
|
|
||||||
// .labelStyle(.green)
|
|
||||||
// .labelStyle(.bold)
|
|
||||||
|
|
||||||
let expected = """
|
|
||||||
Foo Bar
|
|
||||||
"""
|
|
||||||
XCTAssert(node.render() == expected)
|
|
||||||
print(type(of: node.body))
|
|
||||||
XCTAssertNotNil(node.body as? _ManyNode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testShellCommand() {
|
|
||||||
let node = ShellCommand {
|
|
||||||
"ls -lah"
|
|
||||||
}
|
|
||||||
let expected = " $ ls -lah"
|
|
||||||
XCTAssert(node.render() == expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDiscussion() {
|
|
||||||
let node = Discussion {
|
|
||||||
Group(separator: "\n") {
|
|
||||||
LabeledContent(separator: " ") {
|
|
||||||
"NOTE:".yellow.bold
|
|
||||||
} content: {
|
|
||||||
"Foo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let expected = """
|
|
||||||
\("NOTE:".yellow.bold) Foo
|
|
||||||
"""
|
|
||||||
|
|
||||||
XCTAssert(node.render() == expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testFooNode() {
|
|
||||||
let foo = Foo()
|
|
||||||
XCTAssertNotNil(foo.body as? LabeledContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testGroup2() {
|
|
||||||
let node = Group2 {
|
|
||||||
Text("foo")
|
|
||||||
Text("bar")
|
|
||||||
}
|
|
||||||
print(node.render())
|
|
||||||
XCTAssertNotNil(node.body as? _ManyNode)
|
|
||||||
// XCTAssert(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
173
Tests/ConfigurationClientTests/ConfigurationClientTests.swift
Normal file
173
Tests/ConfigurationClientTests/ConfigurationClientTests.swift
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
@_spi(Internal) import ConfigurationClient
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import TestSupport
|
||||||
|
|
||||||
|
@Suite("ConfigurationClientTests")
|
||||||
|
struct ConfigurationClientTests: TestCase {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func sanity() {
|
||||||
|
withTestLogger(key: "sanity") {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
logger.debug("Testing sanity.")
|
||||||
|
#expect(Bool(true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: ["config.toml", "config.json"])
|
||||||
|
func generateConfigFile(fileName: String) async throws {
|
||||||
|
try await withTestLogger(key: "generateConfigFile") {
|
||||||
|
$0.fileClient = .liveValue
|
||||||
|
} operation: {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
@Dependency(\.fileClient) var fileClient
|
||||||
|
let configuration = ConfigurationClient.liveValue
|
||||||
|
|
||||||
|
try await withTemporaryDirectory { tempDir in
|
||||||
|
let tempFile = tempDir.appending(path: fileName)
|
||||||
|
try await configuration.generate(at: File(tempFile)!, force: false)
|
||||||
|
#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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: ["config.toml", "config.json", nil])
|
||||||
|
func loadConfigFile(fileName: String?) async throws {
|
||||||
|
try await withTestLogger(key: "generateConfigFile") {
|
||||||
|
$0.fileClient = .liveValue
|
||||||
|
} operation: {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
@Dependency(\.fileClient) var fileClient
|
||||||
|
let configuration = ConfigurationClient.liveValue
|
||||||
|
|
||||||
|
guard let fileName else {
|
||||||
|
let loaded = try await configuration.load(nil)
|
||||||
|
#expect(loaded == .init())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try await withGeneratedConfigFile(named: fileName, client: configuration) { file in
|
||||||
|
let loaded = try await configuration.load(file)
|
||||||
|
#expect(loaded == .mock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: ["config.toml", "config.json", ".hparc.json", ".hparc.toml"])
|
||||||
|
func findConfiguration(fileName: String) async throws {
|
||||||
|
try await withTestLogger(key: "findConfiguration") {
|
||||||
|
$0.fileClient = .liveValue
|
||||||
|
} operation: {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
@Dependency(\.fileClient) var fileClient
|
||||||
|
let client = ConfigurationClient.liveValue
|
||||||
|
|
||||||
|
try await withGeneratedConfigFile(named: fileName, client: client) { file in
|
||||||
|
for environment in generateFindEnvironments(file: file) {
|
||||||
|
if let home = environment["HOME"] {
|
||||||
|
try await withDependencies {
|
||||||
|
$0.fileClient.homeDirectory = { URL(filePath: home) }
|
||||||
|
} operation: {
|
||||||
|
let configuration = ConfigurationClient.live(environment: environment)
|
||||||
|
let found = try await configuration.find()
|
||||||
|
#expect(found == file)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let configuration = ConfigurationClient.live(environment: environment)
|
||||||
|
let found = try await configuration.find()
|
||||||
|
#expect(found == file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: ["config.toml", "config.json", ".hparc.json", ".hparc.toml"])
|
||||||
|
func findXdgConfiguration(fileName: String) async throws {
|
||||||
|
try await withTestLogger(key: "findXdgConfiguration") {
|
||||||
|
$0.fileClient = .liveValue
|
||||||
|
} operation: {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
@Dependency(\.fileClient) var fileClient
|
||||||
|
let client = ConfigurationClient.liveValue
|
||||||
|
|
||||||
|
try await withGeneratedXDGConfigFile(named: fileName, client: client) { file, xdgDir in
|
||||||
|
let environment = ["XDG_CONFIG_HOME": xdgDir.cleanFilePath]
|
||||||
|
let configuration = ConfigurationClient.live(environment: environment)
|
||||||
|
let found = try await configuration.find()
|
||||||
|
#expect(found == file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func testFailingFind() async {
|
||||||
|
await withTestLogger(key: "testFailingFind") {
|
||||||
|
$0.fileClient = .liveValue
|
||||||
|
} operation: {
|
||||||
|
await withTemporaryDirectory { tempDir in
|
||||||
|
let environment = [
|
||||||
|
"PWD": tempDir.cleanFilePath,
|
||||||
|
"HPA_CONFIG_HOME": tempDir.cleanFilePath
|
||||||
|
]
|
||||||
|
let configuration = ConfigurationClient.live(environment: environment)
|
||||||
|
do {
|
||||||
|
_ = try await configuration.find()
|
||||||
|
#expect(Bool(false))
|
||||||
|
} catch {
|
||||||
|
#expect(Bool(true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateFindEnvironments(file: File) -> [[String: String]] {
|
||||||
|
let directory = file.url.deletingLastPathComponent().cleanFilePath
|
||||||
|
|
||||||
|
return [
|
||||||
|
["PWD": directory],
|
||||||
|
["HPA_CONFIG_HOME": directory],
|
||||||
|
["HPA_CONFIG_FILE": file.path],
|
||||||
|
["HOME": directory]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func withGeneratedConfigFile(
|
||||||
|
named fileName: String,
|
||||||
|
client: ConfigurationClient,
|
||||||
|
_ operation: @Sendable (File) async throws -> Void
|
||||||
|
) async rethrows {
|
||||||
|
try await withTemporaryDirectory { tempDir in
|
||||||
|
let file = File(tempDir.appending(path: fileName))!
|
||||||
|
try await client.generate(at: file)
|
||||||
|
try await operation(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withGeneratedXDGConfigFile(
|
||||||
|
named fileName: String,
|
||||||
|
client: ConfigurationClient,
|
||||||
|
_ operation: @Sendable (File, URL) async throws -> Void
|
||||||
|
) async rethrows {
|
||||||
|
try await withTemporaryDirectory { tempDir in
|
||||||
|
let xdgDir = tempDir.appending(path: HPAKey.configDirName)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
atPath: xdgDir.cleanFilePath,
|
||||||
|
withIntermediateDirectories: false
|
||||||
|
)
|
||||||
|
let file = File(xdgDir.appending(path: fileName))!
|
||||||
|
try await client.generate(at: file)
|
||||||
|
try await operation(file, tempDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
309
Tests/PlaybookClientTests/PlaybookClientTests.swift
Normal file
309
Tests/PlaybookClientTests/PlaybookClientTests.swift
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
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 withDependencies {
|
||||||
|
$0.fileClient = .liveValue
|
||||||
|
$0.asyncShellClient = .liveValue
|
||||||
|
$0.commandClient = .liveValue
|
||||||
|
} operation: {
|
||||||
|
try await withTemporaryDirectory { tempDirectory in
|
||||||
|
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)
|
||||||
|
let exists = FileManager.default.fileExists(atPath: pathUrl.cleanFilePath)
|
||||||
|
#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
|
||||||
|
|
||||||
|
let configuration = Configuration.mock
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
let configuration = Configuration.mock
|
||||||
|
|
||||||
|
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
|
||||||
|
}()
|
||||||
129
Tests/VaultClientTests/VaultClientTests.swift
Normal file
129
Tests/VaultClientTests/VaultClientTests.swift
Normal 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.findVaultFileInCurrentDirectory = { 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.findVaultFileInCurrentDirectory = { 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 {}
|
||||||
Reference in New Issue
Block a user