feat: Reorganizes files
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
disabled_rules:
|
disabled_rules:
|
||||||
- closing_brace
|
- closing_brace
|
||||||
- fuction_body_length
|
- fuction_body_length
|
||||||
|
- opening_brace
|
||||||
|
|
||||||
included:
|
included:
|
||||||
- Sources
|
- Sources
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ extension CliClient: DependencyKey {
|
|||||||
logger.trace("Loading configuration from: \(url)")
|
logger.trace("Loading configuration from: \(url)")
|
||||||
try fileClient.loadFile(url, &env, decoder)
|
try fileClient.loadFile(url, &env, decoder)
|
||||||
|
|
||||||
return try .fromEnv(env, encoder: encoder, decoder: decoder)
|
return try .fromEnv(env)
|
||||||
|
|
||||||
} runCommand: { args, quiet, shell in
|
} runCommand: { args, quiet, shell in
|
||||||
@Dependency(\.asyncShellClient) var shellClient
|
@Dependency(\.asyncShellClient) var shellClient
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ public struct Configuration: Codable {
|
|||||||
public let templateRepo: String?
|
public let templateRepo: String?
|
||||||
public let templateRepoVersion: String?
|
public let templateRepoVersion: String?
|
||||||
public let templateDir: String?
|
public let templateDir: String?
|
||||||
public let defaultPlaybookArgs: String?
|
public let defaultPlaybookArgs: [String]?
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
fileprivate enum CodingKeys: String, CodingKey {
|
||||||
case playbookDir = "HPA_PLAYBOOK_DIR"
|
case playbookDir = "HPA_PLAYBOOK_DIR"
|
||||||
case inventoryPath = "HPA_DEFAULT_INVENTORY"
|
case inventoryPath = "HPA_DEFAULT_INVENTORY"
|
||||||
case templateRepo = "HPA_TEMPLATE_REPO"
|
case templateRepo = "HPA_TEMPLATE_REPO"
|
||||||
@@ -22,23 +22,26 @@ public struct Configuration: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static func fromEnv(
|
public static func fromEnv(
|
||||||
_ env: [String: String],
|
_ env: [String: String]
|
||||||
encoder: JSONEncoder = .init(),
|
|
||||||
decoder: JSONDecoder = .init()
|
|
||||||
) throws -> Self {
|
) throws -> Self {
|
||||||
@Dependency(\.logger) var logger
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
logger.trace("Creating configuration from env...")
|
logger.trace("Creating configuration from env...")
|
||||||
// logger.debug("\(env)")
|
|
||||||
|
|
||||||
let hpaValues = env.reduce(into: [String: String]()) { partial, next in
|
let hpaValues: [String: String] = env.filter { $0.key.contains("HPA") }
|
||||||
if next.key.contains("HPA") {
|
|
||||||
partial[next.key] = next.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.debug("HPA env vars: \(hpaValues)")
|
logger.debug("HPA env vars: \(hpaValues)")
|
||||||
let data = try encoder.encode(env)
|
|
||||||
return try decoder.decode(Configuration.self, from: data)
|
let defaultArgs: [String]? = hpaValues.value(key: .defaultPlaybookArgs)
|
||||||
|
.flatMap { $0.split(separator: ",").map(String.init) }
|
||||||
|
|
||||||
|
return Configuration(
|
||||||
|
playbookDir: hpaValues.value(key: .playbookDir),
|
||||||
|
inventoryPath: hpaValues.value(key: .inventoryPath),
|
||||||
|
templateRepo: hpaValues.value(key: .templateRepo),
|
||||||
|
templateRepoVersion: hpaValues.value(key: .templateRepoVersion),
|
||||||
|
templateDir: hpaValues.value(key: .templateDir),
|
||||||
|
defaultPlaybookArgs: defaultArgs
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var mock: Self {
|
static var mock: Self {
|
||||||
@@ -48,7 +51,7 @@ public struct Configuration: Codable {
|
|||||||
templateRepo: "https://git.example.com/consult-template.git",
|
templateRepo: "https://git.example.com/consult-template.git",
|
||||||
templateRepoVersion: "main",
|
templateRepoVersion: "main",
|
||||||
templateDir: "/path/to/local/template",
|
templateDir: "/path/to/local/template",
|
||||||
defaultPlaybookArgs: "--vault-id=myId@$SCRIPTS/vault-gopass-client"
|
defaultPlaybookArgs: ["--vault-id=myId@$SCRIPTS/vault-gopass-client"]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,3 +80,9 @@ public struct Configuration: Codable {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension [String: String] {
|
||||||
|
fileprivate func value(key codingKey: Configuration.CodingKeys) -> String? {
|
||||||
|
self[codingKey.rawValue]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ public extension DependencyValues {
|
|||||||
@_spi(Internal)
|
@_spi(Internal)
|
||||||
@DependencyClient
|
@DependencyClient
|
||||||
public struct FileClient: Sendable {
|
public struct FileClient: Sendable {
|
||||||
public var contentsOfDirectory: @Sendable (URL) throws -> [URL]
|
|
||||||
public var loadFile: @Sendable (URL, inout [String: String], JSONDecoder) throws -> Void
|
public var loadFile: @Sendable (URL, inout [String: String], JSONDecoder) throws -> Void
|
||||||
public var homeDir: @Sendable () -> URL = { URL(string: "~/")! }
|
public var homeDir: @Sendable () -> URL = { URL(string: "~/")! }
|
||||||
public var isDirectory: @Sendable (URL) -> Bool = { _ in false }
|
public var isDirectory: @Sendable (URL) -> Bool = { _ in false }
|
||||||
@@ -29,7 +28,6 @@ extension FileClient: DependencyKey {
|
|||||||
public static func live(fileManager: FileManager = .default) -> Self {
|
public static func live(fileManager: FileManager = .default) -> Self {
|
||||||
let client = LiveFileClient(fileManager: fileManager)
|
let client = LiveFileClient(fileManager: fileManager)
|
||||||
return Self(
|
return Self(
|
||||||
contentsOfDirectory: { try client.contentsOfDirectory(url: $0) },
|
|
||||||
loadFile: { try client.loadFile(at: $0, into: &$1, decoder: $2) },
|
loadFile: { try client.loadFile(at: $0, into: &$1, decoder: $2) },
|
||||||
homeDir: { client.homeDir },
|
homeDir: { client.homeDir },
|
||||||
isDirectory: { client.isDirectory(url: $0) },
|
isDirectory: { client.isDirectory(url: $0) },
|
||||||
@@ -64,10 +62,6 @@ private struct LiveFileClient: @unchecked Sendable {
|
|||||||
fileManager.isReadableFile(atPath: path(for: url))
|
fileManager.isReadableFile(atPath: path(for: url))
|
||||||
}
|
}
|
||||||
|
|
||||||
func contentsOfDirectory(url: URL) throws -> [URL] {
|
|
||||||
try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileExists(at path: String) -> Bool {
|
func fileExists(at path: String) -> Bool {
|
||||||
fileManager.fileExists(atPath: path)
|
fileManager.fileExists(atPath: path)
|
||||||
}
|
}
|
||||||
@@ -76,7 +70,7 @@ 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.contains(".json") {
|
if url.absoluteString.hasSuffix(".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)) ?? [:]
|
||||||
|
|||||||
@@ -12,49 +12,45 @@ public func findConfigurationFiles(
|
|||||||
logger.debug("Begin find configuration files.")
|
logger.debug("Begin find configuration files.")
|
||||||
logger.trace("Env: \(env)")
|
logger.trace("Env: \(env)")
|
||||||
|
|
||||||
let homeDir = fileClient.homeDir()
|
|
||||||
|
|
||||||
// Check for environment variable pointing to a directory that the
|
// Check for environment variable pointing to a directory that the
|
||||||
// the configuration lives.
|
// the configuration lives.
|
||||||
if let configHome = env["HPA_CONFIG_HOME"] {
|
if let pwd = env["PWD"],
|
||||||
let url = URL(filePath: configHome).appending(path: "config")
|
let url = fileClient.checkUrl(.file(pwd, ".hparc"))
|
||||||
|
{
|
||||||
if fileClient.isReadable(url) {
|
logger.debug("Found configuration in current working directory.")
|
||||||
logger.debug("Found configuration from hpa config home env var.")
|
return url
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check home directory for a `.hparc` file.
|
// Check for environment variable pointing to a file that the
|
||||||
let url = homeDir.appending(path: ".hparc")
|
// the configuration lives.
|
||||||
if fileClient.isReadable(url) {
|
if let configFile = env["HPA_CONFIG_FILE"],
|
||||||
logger.debug("Found configuration in home directory")
|
let url = fileClient.checkUrl(.file(configFile))
|
||||||
|
{
|
||||||
|
logger.debug("Found configuration from hpa config file env var.")
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for environment variable pointing to a directory that the
|
// Check for environment variable pointing to a directory that the
|
||||||
// the configuration lives.
|
// the configuration lives.
|
||||||
if let pwd = env["PWD"] {
|
if let configHome = env["HPA_CONFIG_HOME"],
|
||||||
let url = URL(filePath: "\(pwd)").appending(path: ".hparc")
|
let url = fileClient.checkUrl(.directory(configHome))
|
||||||
if fileClient.isReadable(url) {
|
{
|
||||||
logger.debug("Found configuration in current working directory.")
|
logger.debug("Found configuration from hpa config home env var.")
|
||||||
return url
|
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.
|
// Check in xdg config home, under an hpa-playbook directory.
|
||||||
if let xdgConfigHome = env["XDG_CONFIG_HOME"] {
|
if let xdgConfigHome = env["XDG_CONFIG_HOME"],
|
||||||
logger.debug("XDG Config Home: \(xdgConfigHome)")
|
let url = fileClient.checkUrl(.directory(xdgConfigHome, "hpa-playbook"))
|
||||||
|
{
|
||||||
let url = URL(filePath: "\(xdgConfigHome)")
|
|
||||||
.appending(path: "hpa-playbook")
|
|
||||||
.appending(path: "config")
|
|
||||||
|
|
||||||
logger.debug("XDG Config url: \(url.absoluteString)")
|
logger.debug("XDG Config url: \(url.absoluteString)")
|
||||||
|
return url
|
||||||
if fileClient.isReadable(url) {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We could not find configuration in any usual places.
|
// We could not find configuration in any usual places.
|
||||||
@@ -68,3 +64,46 @@ func path(for url: URL) -> String {
|
|||||||
enum ConfigurationError: Error {
|
enum ConfigurationError: Error {
|
||||||
case configurationNotFound
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ struct Application: AsyncParsableCommand {
|
|||||||
|
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "hpa",
|
commandName: "hpa",
|
||||||
abstract: "A utility for working with ansible hpa playbook.",
|
abstract: "\("A utility for working with ansible hpa playbook.".blue)",
|
||||||
subcommands: [
|
subcommands: [
|
||||||
BuildCommand.self, CreateCommand.self, CreateProjectTemplateCommand.self,
|
BuildCommand.self, CreateCommand.self, UtilsCommand.self
|
||||||
GenerateConfigurationCommand.self
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import ArgumentParser
|
|
||||||
|
|
||||||
struct CreateProjectTemplateCommand: AsyncParsableCommand {
|
|
||||||
|
|
||||||
static let configuration = CommandConfiguration(
|
|
||||||
commandName: "create-project-template",
|
|
||||||
abstract: "Create a home performance assesment project template."
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptionGroup var globals: GlobalOptions
|
|
||||||
|
|
||||||
mutating func run() async throws {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
35
Sources/hpa/Helpers/CommandConfigurationExtensions.swift
Normal file
35
Sources/hpa/Helpers/CommandConfigurationExtensions.swift
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
|
||||||
|
extension CommandConfiguration {
|
||||||
|
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: """
|
||||||
|
\("NOTE:".yellow) Most options are not required if you have a configuration file setup.
|
||||||
|
|
||||||
|
\("Examples:".yellow)
|
||||||
|
|
||||||
|
\(examples.map { "\($0.label.green.italic)\n $ hpa \($0.example)" }.joined(separator: "\n"))
|
||||||
|
|
||||||
|
\("Passing extra args to the playbook.".green.italic)
|
||||||
|
$ hpa \(examples[0].example) -- --vault-id "myId@$SCRIPTS/vault-gopass-client"
|
||||||
|
|
||||||
|
\("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.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,40 +6,6 @@ import Logging
|
|||||||
import Rainbow
|
import Rainbow
|
||||||
import ShellClient
|
import ShellClient
|
||||||
|
|
||||||
extension CommandConfiguration {
|
|
||||||
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: """
|
|
||||||
\("NOTE:".yellow) Most options are not required if you have a configuration file setup.
|
|
||||||
|
|
||||||
\("Examples:".yellow)
|
|
||||||
|
|
||||||
\(examples.map { "\($0.label.green.italic)\n $ hpa \($0.example)" }.joined(separator: "\n"))
|
|
||||||
|
|
||||||
\("Passing extra args to the playbook.".green.italic)
|
|
||||||
$ hpa \(examples[0].example) -- --vault-id "myId@$SCRIPTS/vault-gopass-client"
|
|
||||||
|
|
||||||
\("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 BasicGlobalOptions {
|
extension BasicGlobalOptions {
|
||||||
|
|
||||||
var shellOrDefault: ShellCommand.Shell {
|
var shellOrDefault: ShellCommand.Shell {
|
||||||
@@ -167,19 +133,17 @@ func runPlaybook(
|
|||||||
configurationKeyPath: \.inventoryPath
|
configurationKeyPath: \.inventoryPath
|
||||||
)) ?? "\(playbookDir)/inventory.ini"
|
)) ?? "\(playbookDir)/inventory.ini"
|
||||||
|
|
||||||
var playbookArgs = [
|
let defaultArgs = configuration.defaultPlaybookArgs ?? []
|
||||||
"ansible-playbook", playbook,
|
|
||||||
"--inventory", inventory
|
|
||||||
]
|
|
||||||
|
|
||||||
if let defaultArgs = configuration.defaultPlaybookArgs {
|
|
||||||
playbookArgs.append(defaultArgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
try await cliClient.runCommand(
|
try await cliClient.runCommand(
|
||||||
quiet: globals.quietOnlyPlaybook ? true : globals.basic.quiet,
|
quiet: globals.quietOnlyPlaybook ? true : globals.basic.quiet,
|
||||||
shell: globals.shellOrDefault,
|
shell: globals.shellOrDefault,
|
||||||
playbookArgs + args + extraArgs
|
[
|
||||||
|
"ansible-playbook", playbook,
|
||||||
|
"--inventory", inventory
|
||||||
|
] + args
|
||||||
|
+ extraArgs
|
||||||
|
+ defaultArgs
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
56
Sources/hpa/UtilsCommands/CreateTemplateCommand.swift
Normal file
56
Sources/hpa/UtilsCommands/CreateTemplateCommand.swift
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
|
||||||
|
struct CreateProjectTemplateCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
|
static let commandName = "create-template"
|
||||||
|
|
||||||
|
static let configuration = CommandConfiguration.playbookCommandConfiguration(
|
||||||
|
commandName: commandName,
|
||||||
|
abstract: "Create a home performance assesment project template.",
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Sources/hpa/UtilsCommands/UtilsCommand.swift
Normal file
17
Sources/hpa/UtilsCommands/UtilsCommand.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
|
||||||
|
struct UtilsCommand: AsyncParsableCommand {
|
||||||
|
static let commandName = "utils"
|
||||||
|
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: commandName,
|
||||||
|
abstract: "\("Utility commands.".blue)",
|
||||||
|
discussion: """
|
||||||
|
These are commands that are generally only run on occasion / less frequently used.
|
||||||
|
""",
|
||||||
|
subcommands: [
|
||||||
|
CreateProjectTemplateCommand.self, GenerateConfigurationCommand.self
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user