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: [ 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/apple/swift-log", from: "1.6.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")
], ],

View File

@@ -1,6 +1,7 @@
import Dependencies import Dependencies
import DependenciesMacros import DependenciesMacros
import Foundation import Foundation
import ShellClient
public extension DependencyValues { public extension DependencyValues {
var cliClient: CliClient { var cliClient: CliClient {
@@ -14,6 +15,23 @@ public struct CliClient: Sendable {
public var decoder: @Sendable () -> JSONDecoder = { .init() } public var decoder: @Sendable () -> JSONDecoder = { .init() }
public var encoder: @Sendable () -> JSONEncoder = { .init() } public var encoder: @Sendable () -> JSONEncoder = { .init() }
public var loadConfiguration: @Sendable () throws -> Configuration 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 { extension CliClient: DependencyKey {
@@ -41,6 +59,22 @@ extension CliClient: DependencyKey {
} }
return try .fromEnv(env, encoder: encoder, decoder: decoder) 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 @Dependency(\.logger) var logger
logger.trace("Begin load file for: \(path(for: url))") logger.trace("Begin load file for: \(path(for: url))")
// if url.absoluteString.split(separator: ".json").count > 0 { if url.absoluteString.contains(".json") {
// // Handle json file. // Handle json file.
// let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
// let dict = (try? decoder.decode([String: String].self, from: data)) ?? [:] let dict = (try? decoder.decode([String: String].self, from: data)) ?? [:]
// env.merge(dict, uniquingKeysWith: { $1 }) env.merge(dict, uniquingKeysWith: { $1 })
// return return
// } }
let string = try String(contentsOfFile: path(for: url), encoding: .utf8) let string = try String(contentsOfFile: path(for: url), encoding: .utf8)
@@ -82,7 +82,9 @@ private struct LiveFileClient: @unchecked Sendable {
for line in lines { for line in lines {
logger.trace("Line: \(line)") logger.trace("Line: \(line)")
let strippedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) 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)") logger.trace("Split Line: \(splitLine)")
guard splitLine.count >= 2 else { continue } guard splitLine.count >= 2 else { continue }

View File

@@ -8,13 +8,14 @@ struct BuildCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration.playbookCommandConfiguration( static let configuration = CommandConfiguration.playbookCommandConfiguration(
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")
) )
@OptionGroup var globals: GlobalOptions @OptionGroup var globals: GlobalOptions
@Argument( @Argument(
help: "The project directory.", help: "Path to the project directory.",
completion: .directory completion: .directory
) )
var projectDir: String var projectDir: String
@@ -25,11 +26,12 @@ struct BuildCommand: AsyncParsableCommand {
var extraArgs: [String] = [] var extraArgs: [String] = []
mutating func run() async throws { mutating func run() async throws {
let args = [ try await runPlaybook(
commandName: Self.commandName,
globals: globals,
extraArgs: extraArgs,
"--tags", "build-project", "--tags", "build-project",
"--extra-vars", "project_dir=\(projectDir)" "--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 ArgumentParser
import CliClient
import Dependencies
import Foundation
import Logging
struct CreateProjectCommand: AsyncParsableCommand { struct CreateProjectCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration( static let commandName = "create"
commandName: "create-project",
abstract: "Create a home performance assesment project." 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 @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 { 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? var inventoryPath: String?
@Flag( @Flag(
name: .long, name: .shortAndLong,
help: "Increase logging level." help: "Increase logging level (can be passed multiple times)."
) )
var verbose: Int 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 import ShellClient
extension CommandConfiguration { extension CommandConfiguration {
static func playbookCommandConfiguration(commandName: String, abstract: String) -> Self { static func playbookCommandConfiguration(
Self( 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, commandName: commandName,
abstract: "\(abstract.blue)", abstract: "\(abstract.blue)",
discussion: """ discussion: """
\("IMPORTANT NOTE:".red) Any extra arguments to pass to the playbook invocation have to \("NOTE:".yellow) Most options are not required if you have a configuration file setup.
be at the end with `--` before any arguments otherwise there will
be an "Unkown option" error.
\("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 \("See Also:".yellow) \("Ansible playbook options.".italic)
invocation.
$ ansible-playbook --help $ 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( func ensureString(
globals: GlobalOptions, globals: GlobalOptions,
configuration: Configuration, configuration: Configuration,
@@ -41,37 +58,78 @@ func ensureString(
return global return global
} }
guard let configuration = configuration[keyPath: configurationKeyPath] else { guard let configuration = configuration[keyPath: configurationKeyPath] else {
throw PlaybookNotFound() throw RunPlaybookError.playbookNotFound
} }
return configuration return configuration
} }
func runPlaybook( func withSetupLogger(
commandName: String, commandName: String,
globals: GlobalOptions, globals: GlobalOptions,
args: [String] dependencies setupDependencies: (inout DependencyValues) -> Void = { _ in },
) async throws { operation: @escaping () async throws -> Void
) async rethrows {
try await withDependencies { try await withDependencies {
$0.logger = .init(label: "\("hpa".yellow)") $0.logger = .init(label: "\("hpa".yellow)")
if globals.quietOnlyPlaybook || !globals.quiet {
switch globals.verbose { switch globals.verbose {
case 0: case 0:
$0.logger.logLevel = .info $0.logger.logLevel = .info
case 1: case 1:
$0.logger.logLevel = .debug $0.logger.logLevel = .debug
case 2: case 2...:
$0.logger.logLevel = .trace $0.logger.logLevel = .trace
default: default:
$0.logger.logLevel = .info $0.logger.logLevel = .info
} }
}
$0.logger[metadataKey: "command"] = "\(commandName.blue)" $0.logger[metadataKey: "command"] = "\(commandName.blue)"
} operation: { } operation: {
try await operation()
}
}
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
)
}
private extension CliClient {
func ensuredConfiguration(_ configuration: Configuration?) throws -> Configuration {
guard let configuration else {
return try loadConfiguration()
}
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(\.cliClient) var cliClient
@Dependency(\.logger) var logger @Dependency(\.logger) var logger
@Dependency(\.asyncShellClient) var shellClient
logger.debug("Begin run playbook: \(globals)") 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( let playbookDir = try ensureString(
globals: globals, globals: globals,
@@ -91,19 +149,21 @@ func runPlaybook(
var playbookArgs = [ var playbookArgs = [
"ansible-playbook", playbook, "ansible-playbook", playbook,
"--inventory", inventory "--inventory", inventory
] + args ]
if let defaultArgs = configuration.defaultPlaybookArgs { if let defaultArgs = configuration.defaultPlaybookArgs {
playbookArgs.append(defaultArgs) playbookArgs.append(defaultArgs)
} }
try await shellClient.foreground(.init( try await cliClient.runCommand(
shell: .zsh(useDashC: true), quiet: globals.quietOnlyPlaybook ? true : globals.quiet,
environment: ProcessInfo.processInfo.environment, shell: globals.shellOrDefault,
in: nil, playbookArgs + args + extraArgs
playbookArgs )
))
} }
} }
struct PlaybookNotFound: Error {} enum RunPlaybookError: Error {
case playbookNotFound
case configurationError
}

View File

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