feat: Working create command.

This commit is contained in:
2024-11-29 18:55:00 -05:00
parent 58e0f0e4b5
commit 84b002c997
8 changed files with 332 additions and 60 deletions

View File

@@ -12,7 +12,6 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
.package(url: "https://github.com/apple/swift-log", from: "1.6.0"),
.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")
],

View File

@@ -1,6 +1,7 @@
import Dependencies
import DependenciesMacros
import Foundation
import ShellClient
public extension DependencyValues {
var cliClient: CliClient {
@@ -14,6 +15,23 @@ 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 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 {
@@ -41,6 +59,22 @@ extension CliClient: DependencyKey {
}
return try .fromEnv(env, encoder: encoder, decoder: decoder)
} 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
))
}
try await shellClient.background(.init(
shell: shell,
environment: ProcessInfo.processInfo.environment,
in: nil,
args
))
}
}

View File

@@ -66,13 +66,13 @@ private struct LiveFileClient: @unchecked Sendable {
@Dependency(\.logger) var logger
logger.trace("Begin load file for: \(path(for: url))")
// if url.absoluteString.split(separator: ".json").count > 0 {
// // 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
// }
if url.absoluteString.contains(".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)
@@ -82,7 +82,9 @@ private struct LiveFileClient: @unchecked Sendable {
for line in lines {
logger.trace("Line: \(line)")
let strippedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
let splitLine = strippedLine.split(separator: "=")
let splitLine = strippedLine.split(separator: "=").map {
$0.replacingOccurrences(of: "\"", with: "")
}
logger.trace("Split Line: \(splitLine)")
guard splitLine.count >= 2 else { continue }

View File

@@ -8,13 +8,14 @@ struct BuildCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration.playbookCommandConfiguration(
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")
)
@OptionGroup var globals: GlobalOptions
@Argument(
help: "The project directory.",
help: "Path to the project directory.",
completion: .directory
)
var projectDir: String
@@ -25,11 +26,12 @@ struct BuildCommand: AsyncParsableCommand {
var extraArgs: [String] = []
mutating func run() async throws {
let args = [
try await runPlaybook(
commandName: Self.commandName,
globals: globals,
extraArgs: extraArgs,
"--tags", "build-project",
"--extra-vars", "project_dir=\(projectDir)"
] + extraArgs
try await runPlaybook(commandName: Self.commandName, globals: globals, args: args)
)
}
}

View File

@@ -1,15 +1,170 @@
import ArgumentParser
import CliClient
import Dependencies
import Foundation
import Logging
struct CreateProjectCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "create-project",
abstract: "Create a home performance assesment project."
static let commandName = "create"
static let configuration = CommandConfiguration.playbookCommandConfiguration(
commandName: commandName,
abstract: "Create a home performance assesment project.",
examples: (
label: "Create Assesment",
example: "\(commandName) /new/assement/path"
)
)
@OptionGroup var globals: GlobalOptions
@Option(
name: .shortAndLong,
help: "The template repository to use."
)
var repo: String?
@Option(
name: .shortAndLong,
help: "The repo branch or version to use."
)
var branch: String?
@Option(
name: .shortAndLong,
help: "Path to local template directory to use.",
completion: .directory
)
var templateDir: String?
@Flag(
name: .shortAndLong,
help: "Force using a local template directory."
)
var localTemplateDir = false
@Argument(
help: "Path to the project directory.",
completion: .directory
)
var projectDir: String
@Argument(
help: "Extra arguments passed to the playbook."
)
var extraArgs: [String] = []
mutating func run() async throws {
fatalError()
try await _run()
}
private func _run() async throws {
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,
globals: self.globals,
configuration: configuration,
extraArgs: extraArgs,
"--tags", "setup-project",
"--extra-vars", "project_dir=\(self.projectDir)",
"--extra-vars", "'\(jsonString)'"
)
}
}
}
private func parseOptions(
command: CreateProjectCommand,
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
}

View File

@@ -15,8 +15,28 @@ struct GlobalOptions: ParsableArguments {
var inventoryPath: String?
@Flag(
name: .long,
help: "Increase logging level."
name: .shortAndLong,
help: "Increase logging level (can be passed multiple times)."
)
var verbose: Int
@Flag(
name: .shortAndLong,
help: "Supress logging."
)
var quiet = false
@Flag(
name: .long,
help: "Supress only playbook logging."
)
var quietOnlyPlaybook = false
@Option(
name: .shortAndLong,
help: "Optional shell to use when calling shell commands.",
completion: .file()
)
var shell: String?
}

View File

@@ -7,30 +7,47 @@ import Rainbow
import ShellClient
extension CommandConfiguration {
static func playbookCommandConfiguration(commandName: String, abstract: String) -> Self {
Self(
static func playbookCommandConfiguration(
commandName: String,
abstract: String,
examples: (label: String, example: String)...
) -> Self {
guard examples.count > 0 else {
fatalError("Did not supply any examples.")
}
return Self(
commandName: commandName,
abstract: "\(abstract.blue)",
discussion: """
\("IMPORTANT NOTE:".red) Any extra arguments to pass to the playbook invocation have to
be at the end with `--` before any arguments otherwise there will
be an "Unkown option" error.
\("NOTE:".yellow) Most options are not required if you have a configuration file setup.
\("Example of passing extra args to the playbook:".yellow)
\("Examples:".yellow)
$ hpa \(commandName) /my/project -- --vault-id "myId@$SCRIPTS/vault-gopass-client"
\(examples.map { "\($0.label.green.italic)\n $ hpa \($0.example)" }.joined(separator: "\n"))
\("See Also:".yellow)
\("Passing extra args to the playbook.".green.italic)
$ hpa \(examples[0].example) -- --vault-id "myId@$SCRIPTS/vault-gopass-client"
You can run the following command to see the options that can be passed to the playbook
invocation.
\("See Also:".yellow) \("Ansible playbook options.".italic)
$ ansible-playbook --help
\("IMPORTANT NOTE:".red) Any extra arguments to pass to the playbook invocation have to
be at the end with `--` before any arguments otherwise there will
be an "Unkown option" error. See examples above.
"""
)
}
}
extension GlobalOptions {
var shellOrDefault: ShellCommand.Shell {
guard let shell else { return .zsh(useDashC: true) }
return .custom(path: shell, useDashC: true)
}
}
func ensureString(
globals: GlobalOptions,
configuration: Configuration,
@@ -41,37 +58,78 @@ func ensureString(
return global
}
guard let configuration = configuration[keyPath: configurationKeyPath] else {
throw PlaybookNotFound()
throw RunPlaybookError.playbookNotFound
}
return configuration
}
func withSetupLogger(
commandName: String,
globals: GlobalOptions,
dependencies setupDependencies: (inout DependencyValues) -> Void = { _ in },
operation: @escaping () async throws -> Void
) async rethrows {
try await withDependencies {
$0.logger = .init(label: "\("hpa".yellow)")
if globals.quietOnlyPlaybook || !globals.quiet {
switch globals.verbose {
case 0:
$0.logger.logLevel = .info
case 1:
$0.logger.logLevel = .debug
case 2...:
$0.logger.logLevel = .trace
default:
$0.logger.logLevel = .info
}
}
$0.logger[metadataKey: "command"] = "\(commandName.blue)"
} operation: {
try await operation()
}
}
func runPlaybook(
commandName: String,
globals: GlobalOptions,
args: [String]
configuration: Configuration? = nil,
extraArgs: [String],
_ args: String...
) async throws {
try await withDependencies {
$0.logger = .init(label: "\("hpa".yellow)")
switch globals.verbose {
case 0:
$0.logger.logLevel = .info
case 1:
$0.logger.logLevel = .debug
case 2:
$0.logger.logLevel = .trace
default:
$0.logger.logLevel = .info
try await runPlaybook(
commandName: commandName,
globals: globals,
configuration: configuration,
extraArgs: extraArgs,
args
)
}
private extension CliClient {
func ensuredConfiguration(_ configuration: Configuration?) throws -> Configuration {
guard let configuration else {
return try loadConfiguration()
}
$0.logger[metadataKey: "command"] = "\(commandName.blue)"
} operation: {
return configuration
}
}
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
@Dependency(\.asyncShellClient) var shellClient
logger.debug("Begin run playbook: \(globals)")
let configuration = try cliClient.loadConfiguration()
logger.debug("Loaded configuration: \(configuration)")
let configuration = try cliClient.ensuredConfiguration(configuration)
logger.debug("Configuration: \(configuration)")
let playbookDir = try ensureString(
globals: globals,
@@ -91,19 +149,21 @@ func runPlaybook(
var playbookArgs = [
"ansible-playbook", playbook,
"--inventory", inventory
] + args
]
if let defaultArgs = configuration.defaultPlaybookArgs {
playbookArgs.append(defaultArgs)
}
try await shellClient.foreground(.init(
shell: .zsh(useDashC: true),
environment: ProcessInfo.processInfo.environment,
in: nil,
playbookArgs
))
try await cliClient.runCommand(
quiet: globals.quietOnlyPlaybook ? true : globals.quiet,
shell: globals.shellOrDefault,
playbookArgs + args + extraArgs
)
}
}
struct PlaybookNotFound: Error {}
enum RunPlaybookError: Error {
case playbookNotFound
case configurationError
}

View File

@@ -17,7 +17,7 @@ func testFindConfigPaths() throws {
@Test
func loadConfiguration() throws {
try withTestLogger(key: "loadConfiguration", logLevel: .trace) {
try withTestLogger(key: "loadConfiguration", logLevel: .debug) {
$0.cliClient = .liveValue
$0.fileClient = .liveValue
} operation: {
@@ -37,7 +37,7 @@ func withTestLogger(
) rethrows {
try withDependencies {
$0.logger = .init(label: label)
$0.logger[metadataKey: "instance"] = "\(key)"
$0.logger[metadataKey: "test"] = "\(key)"
$0.logger.logLevel = logLevel
} operation: {
try operation()
@@ -53,7 +53,7 @@ func withTestLogger(
) rethrows {
try withDependencies {
$0.logger = .init(label: label)
$0.logger[metadataKey: "instance"] = "\(key)"
$0.logger[metadataKey: "test"] = "\(key)"
$0.logger.logLevel = logLevel
setupDependencies(&$0)
} operation: {