Compare commits
27 Commits
56a0bca00c
...
0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
558054464c
|
|||
|
54c07886ad
|
|||
|
f8e89ed0fa
|
|||
|
f596975bbc
|
|||
|
805100fa43
|
|||
|
f7f168b7fd
|
|||
|
99459a0a71
|
|||
|
f89efc8c5e
|
|||
|
85b285347b
|
|||
|
8c402f3f5f
|
|||
|
1429c51821
|
|||
|
1302b15ee2
|
|||
|
da810d0a45
|
|||
|
35d9422f07
|
|||
|
601869d457
|
|||
|
6d0108da0c
|
|||
|
bc0b740f95
|
|||
|
303cdef84b
|
|||
|
b5afc77428
|
|||
|
3f56dda568
|
|||
|
d1b3379815
|
|||
|
b557a60fa3
|
|||
|
5f4ef3b5b5
|
|||
|
ba1e61d99e
|
|||
|
bd56660683
|
|||
|
a480e942bc
|
|||
|
2b265a4ca5
|
20
.gitea/workflows/ci.yaml
Normal file
20
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "dev"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: https://git.housh.dev/actions/setup-just@v1
|
||||||
|
- name: Setup QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Setup Docker buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Run Test
|
||||||
|
run: just test-docker
|
||||||
68
.gitea/workflows/release.yml
Executable file
68
.gitea/workflows/release.yml
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
#
|
||||||
|
name: Create and publish a Docker image
|
||||||
|
|
||||||
|
# Configures this workflow to run every time a change is pushed to the branch called `release`.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['release']
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
|
||||||
|
env:
|
||||||
|
REGISTRY: git.housh.dev
|
||||||
|
IMAGE_NAME: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
#
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.CONTAINER_TOKEN }}
|
||||||
|
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=sha
|
||||||
|
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
|
||||||
|
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
|
||||||
|
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: push
|
||||||
|
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
# This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)."
|
||||||
|
# - name: Generate artifact attestation
|
||||||
|
# uses: actions/attest-build-provenance@v1
|
||||||
|
# with:
|
||||||
|
# subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||||
|
# subject-digest: ${{ steps.push.outputs.digest }}
|
||||||
|
# push-to-registry: true
|
||||||
|
# github-token: ${{ secrets.CONTAINER_TOKEN }}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ DerivedData/
|
|||||||
.nvim/*
|
.nvim/*
|
||||||
.swiftpm/*
|
.swiftpm/*
|
||||||
./hpa.toml
|
./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
|
||||||
|
|||||||
@@ -18,6 +18,13 @@
|
|||||||
"identifier" : "CliClientTests",
|
"identifier" : "CliClientTests",
|
||||||
"name" : "CliClientTests"
|
"name" : "CliClientTests"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:",
|
||||||
|
"identifier" : "ConfigurationClientTests",
|
||||||
|
"name" : "ConfigurationClientTests"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 1
|
"version" : 1
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "57500b96c3cc5835f23b47b8fdf0a0f69711487d67b69c5ab659837d02308a93",
|
"originHash" : "bc31b11e5e7d488e0a9c1bf91cb572d29f782bfd8e43f44157036f8f3d282893",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "combine-schedulers",
|
"identity" : "combine-schedulers",
|
||||||
@@ -37,6 +37,15 @@
|
|||||||
"version" : "0.2.0"
|
"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",
|
||||||
@@ -55,6 +64,15 @@
|
|||||||
"version" : "1.3.0"
|
"version" : "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-custom-dump",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-custom-dump.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
|
||||||
|
"version" : "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-dependencies",
|
"identity" : "swift-dependencies",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -7,48 +7,39 @@ let package = Package(
|
|||||||
platforms: [.macOS(.v14)],
|
platforms: [.macOS(.v14)],
|
||||||
products: [
|
products: [
|
||||||
.executable(name: "hpa", targets: ["hpa"]),
|
.executable(name: "hpa", targets: ["hpa"]),
|
||||||
.library(name: "CliClient", targets: ["CliClient"]),
|
|
||||||
.library(name: "CodersClient", targets: ["CodersClient"]),
|
.library(name: "CodersClient", targets: ["CodersClient"]),
|
||||||
|
.library(name: "CommandClient", targets: ["CommandClient"]),
|
||||||
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"]),
|
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"]),
|
||||||
.library(name: "FileClient", targets: ["FileClient"])
|
.library(name: "FileClient", targets: ["FileClient"]),
|
||||||
|
.library(name: "PlaybookClient", targets: ["PlaybookClient"]),
|
||||||
|
.library(name: "PandocClient", targets: ["PandocClient"]),
|
||||||
|
.library(name: "VaultClient", targets: ["VaultClient"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
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-custom-dump.git", from: "1.3.3"),
|
||||||
.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://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")
|
.package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.5.0")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "hpa",
|
name: "hpa",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"CliClient",
|
|
||||||
"ConfigurationClient",
|
"ConfigurationClient",
|
||||||
"FileClient",
|
"FileClient",
|
||||||
|
"PandocClient",
|
||||||
|
"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: "CliDoc", package: "swift-cli-doc"),
|
||||||
|
.product(name: "CustomDump", package: "swift-custom-dump"),
|
||||||
.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")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.target(
|
|
||||||
name: "CliClient",
|
|
||||||
dependencies: [
|
|
||||||
"CodersClient",
|
|
||||||
"ConfigurationClient",
|
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
|
||||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
|
||||||
.product(name: "ShellClient", package: "swift-shell-client")
|
|
||||||
]
|
|
||||||
),
|
|
||||||
.testTarget(
|
|
||||||
name: "CliClientTests",
|
|
||||||
dependencies: [
|
|
||||||
"CliClient",
|
|
||||||
"TestSupport"
|
|
||||||
]
|
|
||||||
),
|
|
||||||
.target(
|
.target(
|
||||||
name: "CodersClient",
|
name: "CodersClient",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -57,9 +48,20 @@ let package = Package(
|
|||||||
.product(name: "TOMLKit", package: "TOMLKit")
|
.product(name: "TOMLKit", package: "TOMLKit")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "CommandClient",
|
||||||
|
dependencies: [
|
||||||
|
"Constants",
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
|
.product(name: "ShellClient", package: "swift-shell-client")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.target(name: "Constants"),
|
||||||
.target(
|
.target(
|
||||||
name: "TestSupport",
|
name: "TestSupport",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
"CommandClient",
|
||||||
.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")
|
||||||
]
|
]
|
||||||
@@ -90,6 +92,58 @@ let package = Package(
|
|||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
.product(name: "DependenciesMacros", package: "swift-dependencies")
|
.product(name: "DependenciesMacros", package: "swift-dependencies")
|
||||||
]
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "FileClientTests",
|
||||||
|
dependencies: ["FileClient", "TestSupport"]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "PandocClient",
|
||||||
|
dependencies: [
|
||||||
|
"CommandClient",
|
||||||
|
"ConfigurationClient",
|
||||||
|
"PlaybookClient",
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "DependenciesMacros", package: "swift-dependencies")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "PandocClientTests",
|
||||||
|
dependencies: ["PandocClient", "TestSupport"]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "PlaybookClient",
|
||||||
|
dependencies: [
|
||||||
|
"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"]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
import ConfigurationClient
|
|
||||||
import Dependencies
|
|
||||||
import DependenciesMacros
|
|
||||||
import FileClient
|
|
||||||
import Foundation
|
|
||||||
import ShellClient
|
|
||||||
|
|
||||||
public extension CliClient {
|
|
||||||
|
|
||||||
func runCommand(
|
|
||||||
quiet: Bool,
|
|
||||||
shell: ShellCommand.Shell,
|
|
||||||
_ args: [String]
|
|
||||||
) async throws {
|
|
||||||
try await runCommand(.init(arguments: args, quiet: quiet, shell: shell))
|
|
||||||
}
|
|
||||||
|
|
||||||
func runCommand(
|
|
||||||
quiet: Bool,
|
|
||||||
shell: ShellCommand.Shell,
|
|
||||||
_ args: String...
|
|
||||||
) async throws {
|
|
||||||
try await runCommand(quiet: quiet, shell: shell, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPlaybookCommand(
|
|
||||||
_ options: PlaybookOptions,
|
|
||||||
logging loggingOptions: LoggingOptions
|
|
||||||
) async throws {
|
|
||||||
try await withLogger(loggingOptions) {
|
|
||||||
@Dependency(\.configurationClient) var configurationClient
|
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
|
|
||||||
let configuration = try await configurationClient.ensuredConfiguration(options.configuration)
|
|
||||||
logger.trace("Configuration: \(configuration)")
|
|
||||||
|
|
||||||
let playbookDirectory = try configuration.ensuredPlaybookDirectory(options.playbookDirectory)
|
|
||||||
let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)"
|
|
||||||
logger.trace("Playbook path: \(playbookPath)")
|
|
||||||
|
|
||||||
let inventoryPath = ensuredInventoryPath(
|
|
||||||
options.inventoryFilePath,
|
|
||||||
configuration: configuration,
|
|
||||||
playbookDirectory: playbookDirectory
|
|
||||||
)
|
|
||||||
logger.trace("Inventory path: \(inventoryPath)")
|
|
||||||
|
|
||||||
var arguments = [
|
|
||||||
Constants.playbookCommand, playbookPath,
|
|
||||||
"--inventory", inventoryPath
|
|
||||||
] + options.arguments
|
|
||||||
|
|
||||||
if let defaultArgs = configuration.args {
|
|
||||||
arguments.append(contentsOf: defaultArgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if configuration.useVaultArgs, let vaultArgs = configuration.vault.args {
|
|
||||||
arguments.append(contentsOf: vaultArgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.trace("Running playbook command with arguments: \(arguments)")
|
|
||||||
|
|
||||||
try await runCommand(
|
|
||||||
quiet: options.quiet,
|
|
||||||
shell: options.shell.orDefault,
|
|
||||||
arguments
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runVaultCommand(
|
|
||||||
_ options: VaultOptions,
|
|
||||||
logging loggingOptions: LoggingOptions
|
|
||||||
) async throws {
|
|
||||||
try await withLogger(loggingOptions) {
|
|
||||||
@Dependency(\.configurationClient) var configurationClient
|
|
||||||
@Dependency(\.fileClient) var fileClient
|
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
|
|
||||||
let configuration = try await configurationClient.ensuredConfiguration(options.configuration)
|
|
||||||
logger.trace("Configuration: \(configuration)")
|
|
||||||
|
|
||||||
let vaultFilePath = try await fileClient.ensuredVaultFilePath(options.vaultFilePath)
|
|
||||||
logger.trace("Vault file: \(vaultFilePath)")
|
|
||||||
|
|
||||||
var arguments = [
|
|
||||||
Constants.vaultCommand
|
|
||||||
] + options.arguments
|
|
||||||
|
|
||||||
if let defaultArgs = configuration.vault.args {
|
|
||||||
arguments.append(contentsOf: defaultArgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if arguments.contains("encrypt"),
|
|
||||||
!arguments.contains("--encrypt-vault-id"),
|
|
||||||
let id = configuration.vault.encryptId
|
|
||||||
{
|
|
||||||
arguments.append(contentsOf: ["--encrypt-vault-id", id])
|
|
||||||
}
|
|
||||||
|
|
||||||
arguments.append(vaultFilePath)
|
|
||||||
|
|
||||||
logger.trace("Running vault command with arguments: \(arguments)")
|
|
||||||
|
|
||||||
try await runCommand(
|
|
||||||
quiet: options.quiet,
|
|
||||||
shell: options.shell.orDefault,
|
|
||||||
arguments
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@_spi(Internal)
|
|
||||||
public extension ConfigurationClient {
|
|
||||||
func ensuredConfiguration(_ optionalConfig: Configuration?) async throws -> Configuration {
|
|
||||||
guard let config = optionalConfig else {
|
|
||||||
return try await findAndLoad()
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@_spi(Internal)
|
|
||||||
public extension Configuration {
|
|
||||||
|
|
||||||
func ensuredPlaybookDirectory(_ optionalDirectory: String?) throws -> String {
|
|
||||||
guard let directory = optionalDirectory else {
|
|
||||||
guard let directory = playbook?.directory else {
|
|
||||||
throw CliClientError.playbookDirectoryNotFound
|
|
||||||
}
|
|
||||||
return directory
|
|
||||||
}
|
|
||||||
return directory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@_spi(Internal)
|
|
||||||
public extension Optional where Wrapped == String {
|
|
||||||
var orDefault: ShellCommand.Shell {
|
|
||||||
guard let shell = self else { return .zsh(useDashC: true) }
|
|
||||||
return .custom(path: shell, useDashC: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@_spi(Internal)
|
|
||||||
public func ensuredInventoryPath(
|
|
||||||
_ optionalInventoryPath: String?,
|
|
||||||
configuration: Configuration,
|
|
||||||
playbookDirectory: String
|
|
||||||
) -> String {
|
|
||||||
guard let path = optionalInventoryPath else {
|
|
||||||
guard let path = configuration.playbook?.inventory else {
|
|
||||||
return "\(playbookDirectory)/\(Constants.inventoryFileName)"
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
@_spi(Internal)
|
|
||||||
public extension FileClient {
|
|
||||||
|
|
||||||
func ensuredVaultFilePath(_ optionalPath: String?) async throws -> String {
|
|
||||||
guard let path = optionalPath else {
|
|
||||||
guard let url = try await findVaultFileInCurrentDirectory() else {
|
|
||||||
throw CliClientError.vaultFileNotFound
|
|
||||||
}
|
|
||||||
return url.cleanFilePath
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ShellCommand.Shell: @retroactive @unchecked Sendable {}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
enum Constants {
|
|
||||||
static let executableName = "hpa"
|
|
||||||
static let playbookCommand = "ansible-playbook"
|
|
||||||
static let playbookFileName = "main.yml"
|
|
||||||
static let inventoryFileName = "inventory.ini"
|
|
||||||
static let vaultCommand = "ansible-vault"
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import ConfigurationClient
|
|
||||||
import Dependencies
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// NOTE: We're not using the `Coders` client because we generally do not
|
|
||||||
// want the output to be `prettyPrinted` or anything, unless we're running
|
|
||||||
// tests, so we use a supplied json encoder.
|
|
||||||
|
|
||||||
func createJSONData(
|
|
||||||
_ options: CliClient.GenerateJsonOptions,
|
|
||||||
logging loggingOptions: CliClient.LoggingOptions,
|
|
||||||
encoder: JSONEncoder = .init()
|
|
||||||
) async throws -> Data {
|
|
||||||
try await CliClient.withLogger(loggingOptions) {
|
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
@Dependency(\.configurationClient) var configurationClient
|
|
||||||
|
|
||||||
let configuration = try await configurationClient.findAndLoad()
|
|
||||||
|
|
||||||
let templateDir = options.templateDirectory ?? configuration.template.directory
|
|
||||||
let templateRepo = options.templateRepo ?? configuration.template.url
|
|
||||||
let version = options.version ?? configuration.template.version
|
|
||||||
|
|
||||||
logger.debug("""
|
|
||||||
(\(options.useLocalTemplateDirectory), \(String(describing: templateDir)), \(String(describing: templateRepo)))
|
|
||||||
""")
|
|
||||||
|
|
||||||
switch (options.useLocalTemplateDirectory, templateDir, templateRepo) {
|
|
||||||
case (true, .none, _):
|
|
||||||
// User supplied they wanted to use a local template directory, but we could not find
|
|
||||||
// the path set from command line or in configuration.
|
|
||||||
throw CliClientError.templateDirectoryNotFound
|
|
||||||
case let (false, _, .some(repo)):
|
|
||||||
// User did not supply they wanted to use a local template directory, and we found a repo url that was
|
|
||||||
// either set by the command line or found in the configuration.
|
|
||||||
logger.debug("Using repo.")
|
|
||||||
return try encoder.encode(TemplateRepo(repo: repo, version: version))
|
|
||||||
case let (true, .some(templateDir), _):
|
|
||||||
// User supplied they wanted to use a local template directory, and we found the template directory
|
|
||||||
// either set by the command line or in the configuration.
|
|
||||||
logger.debug("Using template directory.")
|
|
||||||
return try encoder.encode(TemplateDirJson(path: templateDir))
|
|
||||||
case let (false, .some(templateDir), _):
|
|
||||||
// User supplied they did not wanted to use a local template directory, and we found the template directory
|
|
||||||
// either set by the command line or in the configuration, and no repo was found / handled previously.
|
|
||||||
logger.debug("Using template directory.")
|
|
||||||
return try encoder.encode(TemplateDirJson(path: templateDir))
|
|
||||||
case (_, .none, .none):
|
|
||||||
// We could not find a repo or template directory.
|
|
||||||
throw CliClientError.templateDirectoryOrRepoNotSpecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TemplateDirJson: Encodable {
|
|
||||||
|
|
||||||
let template: Template
|
|
||||||
|
|
||||||
init(path: String) {
|
|
||||||
self.template = .init(path: path)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Template: Encodable {
|
|
||||||
let path: String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TemplateRepo: Encodable {
|
|
||||||
|
|
||||||
let template: Template
|
|
||||||
|
|
||||||
init(repo: String, version: String?) {
|
|
||||||
self.template = .init(repo: repo, version: version ?? "main")
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Template: Encodable {
|
|
||||||
let repo: String
|
|
||||||
let version: String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
import ConfigurationClient
|
|
||||||
import Dependencies
|
|
||||||
import DependenciesMacros
|
|
||||||
import Foundation
|
|
||||||
import ShellClient
|
|
||||||
|
|
||||||
public extension DependencyValues {
|
|
||||||
var cliClient: CliClient {
|
|
||||||
get { self[CliClient.self] }
|
|
||||||
set { self[CliClient.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@DependencyClient
|
|
||||||
public struct CliClient: Sendable {
|
|
||||||
public var runCommand: @Sendable (RunCommandOptions) async throws -> Void
|
|
||||||
public var generateJSON: @Sendable (GenerateJsonOptions, LoggingOptions, JSONEncoder) async throws -> String
|
|
||||||
|
|
||||||
public func generateJSON(
|
|
||||||
_ options: GenerateJsonOptions,
|
|
||||||
logging loggingOptions: LoggingOptions,
|
|
||||||
encoder jsonEncoder: JSONEncoder = .init()
|
|
||||||
) async throws -> String {
|
|
||||||
try await generateJSON(options, loggingOptions, jsonEncoder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension CliClient {
|
|
||||||
|
|
||||||
struct GenerateJsonOptions: Equatable, Sendable {
|
|
||||||
let templateDirectory: String?
|
|
||||||
let templateRepo: String?
|
|
||||||
let version: String?
|
|
||||||
let useLocalTemplateDirectory: Bool
|
|
||||||
|
|
||||||
public init(
|
|
||||||
templateDirectory: String?,
|
|
||||||
templateRepo: String?,
|
|
||||||
version: String?,
|
|
||||||
useLocalTemplateDirectory: Bool
|
|
||||||
) {
|
|
||||||
self.templateDirectory = templateDirectory
|
|
||||||
self.templateRepo = templateRepo
|
|
||||||
self.version = version
|
|
||||||
self.useLocalTemplateDirectory = useLocalTemplateDirectory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LoggingOptions: Equatable, Sendable {
|
|
||||||
let commandName: String
|
|
||||||
let logLevel: Logger.Level
|
|
||||||
|
|
||||||
public init(commandName: String, logLevel: Logger.Level) {
|
|
||||||
self.commandName = commandName
|
|
||||||
self.logLevel = logLevel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PlaybookOptions: Sendable, Equatable {
|
|
||||||
let arguments: [String]
|
|
||||||
let configuration: Configuration?
|
|
||||||
let inventoryFilePath: String?
|
|
||||||
let playbookDirectory: String?
|
|
||||||
let quiet: Bool
|
|
||||||
let shell: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
arguments: [String],
|
|
||||||
configuration: Configuration? = nil,
|
|
||||||
inventoryFilePath: String? = nil,
|
|
||||||
playbookDirectory: String? = nil,
|
|
||||||
quiet: Bool,
|
|
||||||
shell: String? = nil
|
|
||||||
) {
|
|
||||||
self.arguments = arguments
|
|
||||||
self.configuration = configuration
|
|
||||||
self.inventoryFilePath = inventoryFilePath
|
|
||||||
self.playbookDirectory = playbookDirectory
|
|
||||||
self.quiet = quiet
|
|
||||||
self.shell = shell
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RunCommandOptions: Sendable, Equatable {
|
|
||||||
public let arguments: [String]
|
|
||||||
public let quiet: Bool
|
|
||||||
public let shell: ShellCommand.Shell
|
|
||||||
|
|
||||||
public init(
|
|
||||||
arguments: [String],
|
|
||||||
quiet: Bool,
|
|
||||||
shell: ShellCommand.Shell
|
|
||||||
) {
|
|
||||||
self.arguments = arguments
|
|
||||||
self.quiet = quiet
|
|
||||||
self.shell = shell
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VaultOptions: Equatable, Sendable {
|
|
||||||
let arguments: [String]
|
|
||||||
let configuration: Configuration?
|
|
||||||
let quiet: Bool
|
|
||||||
let shell: String?
|
|
||||||
let vaultFilePath: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
arguments: [String],
|
|
||||||
configuration: Configuration? = nil,
|
|
||||||
quiet: Bool,
|
|
||||||
shell: String?,
|
|
||||||
vaultFilePath: String? = nil
|
|
||||||
) {
|
|
||||||
self.arguments = arguments
|
|
||||||
self.configuration = configuration
|
|
||||||
self.quiet = quiet
|
|
||||||
self.shell = shell
|
|
||||||
self.vaultFilePath = vaultFilePath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CliClient: DependencyKey {
|
|
||||||
|
|
||||||
public static func live(
|
|
||||||
env: [String: String]
|
|
||||||
) -> Self {
|
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
|
|
||||||
return .init { options in
|
|
||||||
@Dependency(\.asyncShellClient) var shellClient
|
|
||||||
if !options.quiet {
|
|
||||||
try await shellClient.foreground(.init(
|
|
||||||
shell: options.shell,
|
|
||||||
environment: ProcessInfo.processInfo.environment,
|
|
||||||
in: nil,
|
|
||||||
options.arguments
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
try await shellClient.background(.init(
|
|
||||||
shell: options.shell,
|
|
||||||
environment: ProcessInfo.processInfo.environment,
|
|
||||||
in: nil,
|
|
||||||
options.arguments
|
|
||||||
))
|
|
||||||
}
|
|
||||||
} generateJSON: { options, loggingOptions, encoder in
|
|
||||||
let data = try await createJSONData(options, logging: loggingOptions, encoder: encoder)
|
|
||||||
guard let string = String(data: data, encoding: .utf8) else {
|
|
||||||
throw CliClientError.encodingError
|
|
||||||
}
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static var liveValue: CliClient {
|
|
||||||
.live(env: ProcessInfo.processInfo.environment)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static let testValue: CliClient = Self()
|
|
||||||
|
|
||||||
public static func capturing(_ client: CapturingClient) -> Self {
|
|
||||||
.init { options in
|
|
||||||
await client.set(options)
|
|
||||||
} generateJSON: {
|
|
||||||
try await Self().generateJSON($0, $1, $2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public actor CapturingClient: Sendable {
|
|
||||||
public private(set) var quiet: Bool?
|
|
||||||
public private(set) var shell: ShellCommand.Shell?
|
|
||||||
public private(set) var arguments: [String]?
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public func set(
|
|
||||||
_ options: RunCommandOptions
|
|
||||||
) {
|
|
||||||
quiet = options.quiet
|
|
||||||
shell = options.shell
|
|
||||||
arguments = options.arguments
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import Dependencies
|
|
||||||
import Logging
|
|
||||||
import ShellClient
|
|
||||||
|
|
||||||
public extension CliClient {
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func withLogger<T>(
|
|
||||||
_ options: LoggingOptions,
|
|
||||||
operation: @Sendable @escaping () async throws -> T
|
|
||||||
) async rethrows -> T {
|
|
||||||
try await Self.withLogger(options, operation: operation)
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
static func withLogger<T>(
|
|
||||||
_ options: LoggingOptions,
|
|
||||||
operation: @Sendable @escaping () async throws -> T
|
|
||||||
) async rethrows -> T {
|
|
||||||
try await withDependencies {
|
|
||||||
$0.logger = .init(label: "\(Constants.executableName)")
|
|
||||||
$0.logger.logLevel = options.logLevel
|
|
||||||
$0.logger[metadataKey: "command"] = "\(options.commandName.blue)"
|
|
||||||
} operation: {
|
|
||||||
try await operation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,8 @@ import Foundation
|
|||||||
import TOMLKit
|
import TOMLKit
|
||||||
|
|
||||||
public extension DependencyValues {
|
public extension DependencyValues {
|
||||||
|
|
||||||
|
/// Holds onto decoders and encoders for json and toml files.
|
||||||
var coders: Coders {
|
var coders: Coders {
|
||||||
get { self[Coders.self] }
|
get { self[Coders.self] }
|
||||||
set { self[Coders.self] = newValue }
|
set { self[Coders.self] = newValue }
|
||||||
|
|||||||
233
Sources/CommandClient/CommandClient.swift
Normal file
233
Sources/CommandClient/CommandClient.swift
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import Constants
|
||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Foundation
|
||||||
|
import ShellClient
|
||||||
|
|
||||||
|
public extension DependencyValues {
|
||||||
|
|
||||||
|
/// Runs shell commands.
|
||||||
|
var commandClient: CommandClient {
|
||||||
|
get { self[CommandClient.self] }
|
||||||
|
set { self[CommandClient.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct CommandClient: Sendable {
|
||||||
|
|
||||||
|
/// Runs a shell command.
|
||||||
|
public var runCommand: @Sendable (RunCommandOptions) async throws -> Void
|
||||||
|
|
||||||
|
/// Runs a shell command, sets up logging, and returns an output value.
|
||||||
|
///
|
||||||
|
/// This is useful when you need to give some output value to the caller,
|
||||||
|
/// generally for the ability of that output to be piped into other commands.
|
||||||
|
///
|
||||||
|
@discardableResult
|
||||||
|
public func run<T>(
|
||||||
|
logging logginOptions: LoggingOptions,
|
||||||
|
quiet: Bool = false,
|
||||||
|
shell: String? = nil,
|
||||||
|
in workingDirectory: String? = nil,
|
||||||
|
makeArguments: @Sendable @escaping () async throws -> ([String], T)
|
||||||
|
) async throws -> T {
|
||||||
|
try await logginOptions.withLogger {
|
||||||
|
let (arguments, returnValue) = try await makeArguments()
|
||||||
|
try await self.run(
|
||||||
|
quiet: quiet,
|
||||||
|
shell: shell,
|
||||||
|
arguments
|
||||||
|
)
|
||||||
|
return returnValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a shell command and sets up logging.
|
||||||
|
public func run(
|
||||||
|
logging logginOptions: LoggingOptions,
|
||||||
|
quiet: Bool = false,
|
||||||
|
shell: String? = nil,
|
||||||
|
in workingDirectory: String? = nil,
|
||||||
|
makeArguments: @Sendable @escaping () async throws -> [String]
|
||||||
|
) async throws {
|
||||||
|
try await run(
|
||||||
|
logging: logginOptions,
|
||||||
|
quiet: quiet,
|
||||||
|
shell: shell,
|
||||||
|
in: workingDirectory,
|
||||||
|
makeArguments: { try await (makeArguments(), ()) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a shell command.
|
||||||
|
public func run(
|
||||||
|
quiet: Bool = false,
|
||||||
|
shell: String? = nil,
|
||||||
|
in workingDirectory: String? = nil,
|
||||||
|
_ arguments: [String]
|
||||||
|
) async throws {
|
||||||
|
try await runCommand(.init(
|
||||||
|
arguments: arguments,
|
||||||
|
quiet: quiet,
|
||||||
|
shell: shell,
|
||||||
|
workingDirectory: workingDirectory
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a shell command.
|
||||||
|
public func run(
|
||||||
|
quiet: Bool = false,
|
||||||
|
shell: String? = nil,
|
||||||
|
in workingDirectory: String? = nil,
|
||||||
|
_ arguments: String...
|
||||||
|
) async throws {
|
||||||
|
try await run(
|
||||||
|
quiet: quiet,
|
||||||
|
shell: shell,
|
||||||
|
in: workingDirectory,
|
||||||
|
arguments
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RunCommandOptions: Sendable, Equatable {
|
||||||
|
public let arguments: [String]
|
||||||
|
public let quiet: Bool
|
||||||
|
public let shell: String?
|
||||||
|
public let workingDirectory: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
arguments: [String],
|
||||||
|
quiet: Bool,
|
||||||
|
shell: String? = nil,
|
||||||
|
workingDirectory: String? = nil
|
||||||
|
) {
|
||||||
|
self.arguments = arguments
|
||||||
|
self.quiet = quiet
|
||||||
|
self.shell = shell
|
||||||
|
self.workingDirectory = workingDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents logger setup options used when running commands.
|
||||||
|
///
|
||||||
|
/// This set's up metadata tags and keys appropriately for the command being run,
|
||||||
|
/// which can aid in debugging.
|
||||||
|
///
|
||||||
|
public struct LoggingOptions: Equatable, Sendable {
|
||||||
|
|
||||||
|
/// The command name / key that the logger is running for.
|
||||||
|
public let commandName: String
|
||||||
|
|
||||||
|
/// The log level.
|
||||||
|
public let logLevel: Logger.Level
|
||||||
|
|
||||||
|
/// Create the logging options.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - commandName: The command name / key that the logger is running for.
|
||||||
|
/// - logLevel: The log level.
|
||||||
|
public init(commandName: String, logLevel: Logger.Level) {
|
||||||
|
self.commandName = commandName
|
||||||
|
self.logLevel = logLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform an operation with a setup logger.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - operation: The operation to perform with the setup logger.
|
||||||
|
@discardableResult
|
||||||
|
public func withLogger<T>(
|
||||||
|
operation: @Sendable @escaping () async throws -> T
|
||||||
|
) async rethrows -> T {
|
||||||
|
try await withDependencies {
|
||||||
|
$0.logger = .init(label: "\(Constants.executableName)")
|
||||||
|
$0.logger.logLevel = logLevel
|
||||||
|
$0.logger[metadataKey: "command"] = "\(commandName.blue)"
|
||||||
|
} operation: {
|
||||||
|
try await operation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CommandClient: DependencyKey {
|
||||||
|
|
||||||
|
public static let testValue: CommandClient = Self()
|
||||||
|
|
||||||
|
public static func live(
|
||||||
|
environment: [String: String]
|
||||||
|
) -> CommandClient {
|
||||||
|
.init { options in
|
||||||
|
@Dependency(\.asyncShellClient) var shellClient
|
||||||
|
if !options.quiet {
|
||||||
|
try await shellClient.foreground(.init(
|
||||||
|
shell: .init(options.shell),
|
||||||
|
environment: environment,
|
||||||
|
in: options.workingDirectory,
|
||||||
|
options.arguments
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
try await shellClient.background(.init(
|
||||||
|
shell: .init(options.shell),
|
||||||
|
environment: environment,
|
||||||
|
in: options.workingDirectory,
|
||||||
|
options.arguments
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var liveValue: CommandClient {
|
||||||
|
.live(environment: ProcessInfo.processInfo.environment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public extension CommandClient {
|
||||||
|
|
||||||
|
/// Create a command client that can capture the arguments / options.
|
||||||
|
///
|
||||||
|
/// This is used for testing.
|
||||||
|
static func capturing(_ client: CapturingClient) -> Self {
|
||||||
|
.init { options in
|
||||||
|
await client.set(options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures the arguments / options passed into the command client's run commands.
|
||||||
|
///
|
||||||
|
@dynamicMemberLookup
|
||||||
|
actor CapturingClient: Sendable {
|
||||||
|
public private(set) var options: RunCommandOptions?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func set(
|
||||||
|
_ options: RunCommandOptions
|
||||||
|
) {
|
||||||
|
self.options = options
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<T>(dynamicMember keyPath: KeyPath<RunCommandOptions, T>) -> T? {
|
||||||
|
options?[keyPath: keyPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ShellCommand.Shell: @retroactive @unchecked Sendable {}
|
||||||
|
|
||||||
|
extension ShellCommand.Shell {
|
||||||
|
init(_ path: String?) {
|
||||||
|
if let path {
|
||||||
|
self = .custom(path: path, useDashC: true)
|
||||||
|
} else {
|
||||||
|
#if os(macOS)
|
||||||
|
self = .zsh(useDashC: true)
|
||||||
|
#else
|
||||||
|
// Generally we're in a docker container when this occurs, which does not have `zsh` installed.
|
||||||
|
self = .sh(useDashC: true)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,42 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Represents configurable settings for the command line tool.
|
// NOTE: When adding items, then the 'hpa.toml' resource file needs to be updated.
|
||||||
|
|
||||||
|
/// Represents configurable settings for the application.
|
||||||
public struct Configuration: Codable, Equatable, Sendable {
|
public struct Configuration: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// Default arguments / options that can get passed into
|
||||||
|
/// ansible-playbook commands.
|
||||||
public let args: [String]?
|
public let args: [String]?
|
||||||
|
|
||||||
|
/// Whether to use the vault arguments as defaults that get passed into
|
||||||
|
/// the ansible-playbook commands.
|
||||||
public let useVaultArgs: Bool
|
public let useVaultArgs: Bool
|
||||||
|
|
||||||
|
/// Configuration for when generating files from templated directories.
|
||||||
|
public let generate: Generate?
|
||||||
|
|
||||||
|
/// Configuration of the ansible-playbook, these are more for developing the
|
||||||
|
/// playbook locally and generally not needed by the user.
|
||||||
public let playbook: Playbook?
|
public let playbook: Playbook?
|
||||||
|
|
||||||
|
/// Template configuration options.
|
||||||
public let template: Template
|
public let template: Template
|
||||||
|
|
||||||
|
/// Ansible-vault configuration options.
|
||||||
public let vault: Vault
|
public let vault: Vault
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
args: [String]? = nil,
|
args: [String]? = nil,
|
||||||
useVaultArgs: Bool = true,
|
useVaultArgs: Bool = true,
|
||||||
|
generate: Generate? = nil,
|
||||||
playbook: Playbook? = nil,
|
playbook: Playbook? = nil,
|
||||||
template: Template = .init(),
|
template: Template = .init(),
|
||||||
vault: Vault = .init()
|
vault: Vault = .init()
|
||||||
) {
|
) {
|
||||||
self.args = args
|
self.args = args
|
||||||
self.useVaultArgs = useVaultArgs
|
self.useVaultArgs = useVaultArgs
|
||||||
|
self.generate = generate
|
||||||
self.playbook = playbook
|
self.playbook = playbook
|
||||||
self.template = template
|
self.template = template
|
||||||
self.vault = vault
|
self.vault = vault
|
||||||
@@ -32,24 +52,118 @@ public struct Configuration: Codable, Equatable, Sendable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration options for running `pandoc` commands that generate files from
|
||||||
|
/// a templated directory.
|
||||||
|
///
|
||||||
|
/// ## NOTE: Most of these can also be set by the template repository variables.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
public struct Generate: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// Specifiy the name of the build directory, generally `.build`.
|
||||||
|
public let buildDirectory: String?
|
||||||
|
|
||||||
|
/// Specifiy the files used in the `pandoc` command to generate a final output file.
|
||||||
|
///
|
||||||
|
/// - SeeAlso: `pandoc --help`
|
||||||
|
public let files: [String]?
|
||||||
|
|
||||||
|
/// Specifiy the files used in the `pandoc` command to include in the header.
|
||||||
|
///
|
||||||
|
/// - SeeAlso: `pandoc --help`
|
||||||
|
public let includeInHeader: [String]?
|
||||||
|
|
||||||
|
/// The default name of the output file, this does not require the file extension as we will
|
||||||
|
/// add it based on the command that is called. Generally this is 'Report'.
|
||||||
|
///
|
||||||
|
public let outputFileName: String?
|
||||||
|
|
||||||
|
/// The default pdf engine to use when generating pdf files using `pandoc`. Generally
|
||||||
|
/// this is 'xelatex'.
|
||||||
|
public let pdfEngine: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
buildDirectory: String? = nil,
|
||||||
|
files: [String]? = nil,
|
||||||
|
includeInHeader: [String]? = nil,
|
||||||
|
outputFileName: String? = nil,
|
||||||
|
pdfEngine: String? = nil
|
||||||
|
) {
|
||||||
|
self.buildDirectory = buildDirectory
|
||||||
|
self.files = files
|
||||||
|
self.includeInHeader = includeInHeader
|
||||||
|
self.outputFileName = outputFileName
|
||||||
|
self.pdfEngine = pdfEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the default configuration for generating files using `pandoc`.
|
||||||
|
public static let `default` = Self.mock
|
||||||
|
|
||||||
|
/// Represents mock configuration for generating files using `pandoc`.
|
||||||
|
public static var mock: Self {
|
||||||
|
.init(
|
||||||
|
buildDirectory: ".build",
|
||||||
|
files: ["Report.md", "Definitions.md"],
|
||||||
|
includeInHeader: ["head.tex", "footer.tex"],
|
||||||
|
outputFileName: "Report",
|
||||||
|
pdfEngine: "xelatex"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration options for the ansible-hpa-playbook. The playbook is
|
||||||
|
/// the primary driver of templating files, generating repository templates, and building
|
||||||
|
/// projects.
|
||||||
|
///
|
||||||
|
/// ## NOTE: These are generally only used for local development of the playbook.
|
||||||
|
///
|
||||||
|
///
|
||||||
public struct Playbook: Codable, Equatable, Sendable {
|
public struct Playbook: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// The directory location of the ansible playbook.
|
||||||
public let directory: String?
|
public let directory: String?
|
||||||
|
|
||||||
|
/// The inventory file name / location.
|
||||||
public let inventory: String?
|
public let inventory: String?
|
||||||
|
|
||||||
|
/// The playbook version, branch, or tag.
|
||||||
|
public let version: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
directory: String? = nil,
|
directory: String? = nil,
|
||||||
inventory: String? = nil
|
inventory: String? = nil,
|
||||||
|
version: String? = nil
|
||||||
) {
|
) {
|
||||||
self.directory = directory
|
self.directory = directory
|
||||||
self.inventory = inventory
|
self.inventory = inventory
|
||||||
|
self.version = version
|
||||||
}
|
}
|
||||||
|
|
||||||
public static var mock: Self { .init() }
|
public static var mock: Self { .init() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration settings for the user's template repository or directory.
|
||||||
|
///
|
||||||
|
/// A template is what is used to create projects for a user. Generally they setup
|
||||||
|
/// their own template by customizing the default template to their needs. This template
|
||||||
|
/// can then be stored as a git repository or on the local file system.
|
||||||
|
///
|
||||||
|
/// The template will hold variables and files that are used to generate the final output
|
||||||
|
/// files using `pandoc`. Generally the template will hold files that may only need setup once,
|
||||||
|
/// such as header files, footer files, and definitions.
|
||||||
|
///
|
||||||
|
/// The project directory contains dynamic variables / files that need edited in order
|
||||||
|
/// to generate the final output. This allows the project directory to remain smaller so the user
|
||||||
|
/// can more easily focus on the tasks required to generate the final output files.
|
||||||
public struct Template: Codable, Equatable, Sendable {
|
public struct Template: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// A url of the template when it is a remote git repository.
|
||||||
public let url: String?
|
public let url: String?
|
||||||
|
|
||||||
|
/// The version, branch, or tag of the remote repository.
|
||||||
public let version: String?
|
public let version: String?
|
||||||
|
|
||||||
|
/// The local directory that contains the template on the local file system.
|
||||||
public let directory: String?
|
public let directory: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -71,8 +185,23 @@ public struct Configuration: Codable, Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration for `ansible-vault` commands. Ansible vault is used to encrypt and
|
||||||
|
/// decrypt sensitive data. This allows for variables, such as customer name and address
|
||||||
|
/// to be stored along with the project in an encrypted file so that it is safer to store them.
|
||||||
|
///
|
||||||
|
/// These may also be used in general `ansible-playbook` commands if the `useVaultArgs` is set
|
||||||
|
/// to `true` in a users configuration.
|
||||||
|
///
|
||||||
public struct Vault: Codable, Equatable, Sendable {
|
public struct Vault: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// A list of arguments / options that get passed to the `ansible-vault` command.
|
||||||
|
///
|
||||||
|
/// - SeeAlso: `ansible-vault --help`
|
||||||
public let args: [String]?
|
public let args: [String]?
|
||||||
|
|
||||||
|
/// An id that is used during encrypting `ansible-vault` files.
|
||||||
|
///
|
||||||
|
/// - SeeAlso: `ansible-vault encrypt --help`
|
||||||
public let encryptId: String?
|
public let encryptId: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
|||||||
@@ -6,37 +6,119 @@ import Foundation
|
|||||||
import ShellClient
|
import ShellClient
|
||||||
|
|
||||||
public extension DependencyValues {
|
public extension DependencyValues {
|
||||||
|
|
||||||
|
/// Interacts with the user's configuration.
|
||||||
var configurationClient: ConfigurationClient {
|
var configurationClient: ConfigurationClient {
|
||||||
get { self[ConfigurationClient.self] }
|
get { self[ConfigurationClient.self] }
|
||||||
set { self[ConfigurationClient.self] = newValue }
|
set { self[ConfigurationClient.self] = newValue }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents actions that can be taken on user's configuration files.
|
||||||
|
///
|
||||||
|
///
|
||||||
@DependencyClient
|
@DependencyClient
|
||||||
public struct ConfigurationClient: Sendable {
|
public struct ConfigurationClient: Sendable {
|
||||||
public var find: @Sendable () async throws -> File
|
|
||||||
var generate: @Sendable (File, Bool) async throws -> Void
|
|
||||||
public var load: @Sendable (File?) async throws -> Configuration
|
|
||||||
var write: @Sendable (File, Configuration, Bool) async throws -> Void
|
|
||||||
|
|
||||||
|
/// Find the user's configuration, searches in the current directory and default
|
||||||
|
/// locations where configuration can be stored. An error is thrown if no configuration
|
||||||
|
/// is found.
|
||||||
|
public var find: @Sendable () async throws -> File
|
||||||
|
|
||||||
|
/// Generate a configuration file for the user.
|
||||||
|
public var generate: @Sendable (GenerateOptions) async throws -> String
|
||||||
|
|
||||||
|
/// Load a configuration file from the given file location. If the file is
|
||||||
|
/// not provided then we return an empty configuraion item.
|
||||||
|
public var load: @Sendable (File?) async throws -> Configuration
|
||||||
|
|
||||||
|
/// Write the configuration to the given file, optionally forcing an overwrite of
|
||||||
|
/// the file.
|
||||||
|
///
|
||||||
|
/// ## NOTE: This uses the `fileClient.write` under the hood, so if you need to control
|
||||||
|
/// what happens during tests, then you can customize the behavior of the fileClient.
|
||||||
|
///
|
||||||
|
public var write: @Sendable (WriteOptions) async throws -> Void
|
||||||
|
|
||||||
|
/// Find the user's configuration and load it.
|
||||||
public func findAndLoad() async throws -> Configuration {
|
public func findAndLoad() async throws -> Configuration {
|
||||||
let file = try? await find()
|
let file = try? await find()
|
||||||
return try await load(file)
|
return try await load(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func generate(
|
/// Write the configuration to the given file, optionally forcing an overwrite of
|
||||||
at file: File,
|
/// the file. If a file already exists and force is not supplied then we will create
|
||||||
force: Bool = false
|
/// a backup of the existing file.
|
||||||
) async throws {
|
///
|
||||||
try await generate(file, force)
|
/// ## NOTE: This uses the `fileClient.write` under the hood, so if you need to control
|
||||||
}
|
/// what happens during tests, then you can customize the behavior of the fileClient.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - configuration: The configuration to save.
|
||||||
|
/// - file: The file location and type to save.
|
||||||
|
/// - force: Force overwritting if a file already exists.
|
||||||
public func write(
|
public func write(
|
||||||
_ configuration: Configuration,
|
_ configuration: Configuration,
|
||||||
to file: File,
|
to file: File,
|
||||||
force: Bool = false
|
force: Bool = false
|
||||||
) async throws {
|
) async throws {
|
||||||
try await write(file, configuration, force)
|
try await write(.init(configuration, to: file, force: force))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the options to generate a configuration file for a user.
|
||||||
|
public struct GenerateOptions: Equatable, Sendable {
|
||||||
|
|
||||||
|
/// Force generation, overwritting existing file if it exists.
|
||||||
|
public let force: Bool
|
||||||
|
|
||||||
|
/// Generate a `json` file instead of the default `toml` file.
|
||||||
|
public let json: Bool
|
||||||
|
|
||||||
|
/// The path to generate a file in.
|
||||||
|
public let path: Path?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
force: Bool = false,
|
||||||
|
json: Bool = false,
|
||||||
|
path: Path? = nil
|
||||||
|
) {
|
||||||
|
self.force = force
|
||||||
|
self.json = json
|
||||||
|
self.path = path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the path option for generating a configuration file for a user.
|
||||||
|
///
|
||||||
|
/// This can either be a full file path or a directory. If a directory is supplied
|
||||||
|
/// then we will use the default file name of 'config' and add the extension dependning
|
||||||
|
/// on if the caller wants a `json` or `toml` file.
|
||||||
|
public enum Path: Equatable, Sendable {
|
||||||
|
case file(File)
|
||||||
|
case directory(String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the options required to write a configuration file.
|
||||||
|
public struct WriteOptions: Equatable, Sendable {
|
||||||
|
|
||||||
|
/// The configuration to wrtie to the file path.
|
||||||
|
public let configuration: Configuration
|
||||||
|
|
||||||
|
/// The file path to write the configuration to.
|
||||||
|
public let file: File
|
||||||
|
|
||||||
|
/// Force overwritting an existing file, if it exists.
|
||||||
|
public let force: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
_ configuration: Configuration,
|
||||||
|
to file: File,
|
||||||
|
force: Bool
|
||||||
|
) {
|
||||||
|
self.configuration = configuration
|
||||||
|
self.file = file
|
||||||
|
self.force = force
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,20 +127,27 @@ extension ConfigurationClient: DependencyKey {
|
|||||||
|
|
||||||
public static func live(environment: [String: String]) -> Self {
|
public static func live(environment: [String: String]) -> Self {
|
||||||
let liveClient = LiveConfigurationClient(environment: environment)
|
let liveClient = LiveConfigurationClient(environment: environment)
|
||||||
return .init {
|
return .init(
|
||||||
try await liveClient.find()
|
find: { try await liveClient.find() },
|
||||||
} generate: { file, force in
|
generate: { try await liveClient.generate($0) },
|
||||||
try await liveClient.generate(at: file, force: force)
|
load: { try await liveClient.load(file: $0) },
|
||||||
} load: { file in
|
write: { try await liveClient.write($0) }
|
||||||
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 {
|
public static var liveValue: Self {
|
||||||
.live(environment: ProcessInfo.processInfo.environment)
|
.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 {
|
struct LiveConfigurationClient {
|
||||||
@@ -116,10 +205,24 @@ struct LiveConfigurationClient {
|
|||||||
throw ConfigurationError.configurationNotFound
|
throw ConfigurationError.configurationNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func generate(at file: File, force: Bool) async throws {
|
func generate(_ options: ConfigurationClient.GenerateOptions) async throws -> String {
|
||||||
@Dependency(\.logger) var logger
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
logger.debug("Begin generating configuration: \(file.path), force: \(force)")
|
let file: File
|
||||||
|
|
||||||
|
if let path = options.path {
|
||||||
|
switch path {
|
||||||
|
case let .file(requestedFile):
|
||||||
|
file = requestedFile
|
||||||
|
case let .directory(directory):
|
||||||
|
file = .init("\(directory)/\(HPAKey.defaultFileNameWithoutExtension).\(options.json ? "json" : "toml")")!
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let configDir = "\(environment.xdgConfigHome)/\(HPAKey.configDirName)"
|
||||||
|
file = .init("\(configDir)/\(HPAKey.defaultFileName)")!
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Begin generating configuration: \(file.path), force: \(options.force)")
|
||||||
|
|
||||||
let expandedPath = file.path.replacingOccurrences(
|
let expandedPath = file.path.replacingOccurrences(
|
||||||
of: "~",
|
of: "~",
|
||||||
@@ -128,10 +231,10 @@ struct LiveConfigurationClient {
|
|||||||
|
|
||||||
let fileUrl = URL(filePath: expandedPath)
|
let fileUrl = URL(filePath: expandedPath)
|
||||||
|
|
||||||
if !force {
|
let exists = fileManager.fileExists(fileUrl)
|
||||||
guard !fileManager.fileExists(fileUrl) else {
|
|
||||||
throw ConfigurationError.fileExists(path: file.path)
|
if !options.force, exists {
|
||||||
}
|
try await createBackup(file.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileDirectory = fileUrl.deletingLastPathComponent()
|
let fileDirectory = fileUrl.deletingLastPathComponent()
|
||||||
@@ -142,6 +245,8 @@ struct LiveConfigurationClient {
|
|||||||
try await fileManager.createDirectory(fileDirectory)
|
try await fileManager.createDirectory(fileDirectory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: The hpa file needs to be copied somewhere on the system during install and
|
||||||
|
// not use bundle, as it only works if the tool was built on the users system.
|
||||||
if case .toml = file {
|
if case .toml = file {
|
||||||
// In the case of toml, we copy the internal resource that includes
|
// In the case of toml, we copy the internal resource that includes
|
||||||
// usage comments in the file.
|
// usage comments in the file.
|
||||||
@@ -156,8 +261,10 @@ struct LiveConfigurationClient {
|
|||||||
} else {
|
} else {
|
||||||
// Json does not allow comments, so we write the mock configuration
|
// Json does not allow comments, so we write the mock configuration
|
||||||
// to the file path.
|
// to the file path.
|
||||||
try await write(.mock, to: File(fileUrl)!, force: force)
|
try await write(.init(.mock, to: File(fileUrl)!, force: options.force))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return fileUrl.cleanFilePath
|
||||||
}
|
}
|
||||||
|
|
||||||
func load(file: File?) async throws -> Configuration {
|
func load(file: File?) async throws -> Configuration {
|
||||||
@@ -176,14 +283,16 @@ struct LiveConfigurationClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func write(
|
func write(
|
||||||
_ configuration: Configuration,
|
_ options: ConfigurationClient.WriteOptions
|
||||||
to file: File,
|
|
||||||
force: Bool
|
|
||||||
) async throws {
|
) async throws {
|
||||||
if !force {
|
let configuration = options.configuration
|
||||||
guard !fileManager.fileExists(file.url) else {
|
let file = options.file
|
||||||
throw ConfigurationError.fileExists(path: file.path)
|
let force = options.force
|
||||||
}
|
|
||||||
|
let exists = fileManager.fileExists(file.url)
|
||||||
|
|
||||||
|
if !force, exists {
|
||||||
|
try await createBackup(file.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: Data
|
let data: Data
|
||||||
@@ -199,6 +308,13 @@ struct LiveConfigurationClient {
|
|||||||
try await fileManager.write(data, file.url)
|
try await fileManager.write(data, file.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func createBackup(_ url: URL) async throws {
|
||||||
|
let backupUrl = url.appendingPathExtension("back")
|
||||||
|
logger.warning("File exists, creating a backup of the existing file at: \(backupUrl.cleanFilePath)")
|
||||||
|
try await fileManager.copy(url, backupUrl)
|
||||||
|
try await fileManager.delete(url)
|
||||||
|
}
|
||||||
|
|
||||||
private func findInDirectory(_ directory: URL) async -> File? {
|
private func findInDirectory(_ directory: URL) async -> File? {
|
||||||
for file in validFileNames {
|
for file in validFileNames {
|
||||||
let url = directory.appending(path: file)
|
let url = directory.appending(path: file)
|
||||||
@@ -229,3 +345,5 @@ enum ConfigurationError: Error {
|
|||||||
case decodingError
|
case decodingError
|
||||||
case fileExists(path: String)
|
case fileExists(path: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MockFindError: Error {}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/// Represents keys in the environment that can be used to locate a user's
|
||||||
|
/// configuration file.
|
||||||
@_spi(Internal)
|
@_spi(Internal)
|
||||||
public enum EnvironmentKey {
|
public enum EnvironmentKey {
|
||||||
static let xdgConfigHome = "XDG_CONFIG_HOME"
|
static let xdgConfigHome = "XDG_CONFIG_HOME"
|
||||||
@@ -5,12 +7,14 @@ public enum EnvironmentKey {
|
|||||||
static let hpaConfigFile = "HPA_CONFIG_FILE"
|
static let hpaConfigFile = "HPA_CONFIG_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents keys that are used internally for directory names, file names, etc.
|
||||||
@_spi(Internal)
|
@_spi(Internal)
|
||||||
public enum HPAKey {
|
public enum HPAKey {
|
||||||
public static let configDirName = "hpa"
|
public static let configDirName = "hpa"
|
||||||
public static let resourceFileName = "hpa"
|
public static let resourceFileName = "hpa"
|
||||||
public static let resourceFileExtension = "toml"
|
public static let resourceFileExtension = "toml"
|
||||||
public static let defaultFileName = "config.toml"
|
public static let defaultFileName = "config.toml"
|
||||||
|
public static let defaultFileNameWithoutExtension = "config"
|
||||||
}
|
}
|
||||||
|
|
||||||
extension [String: String] {
|
extension [String: String] {
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
|
import FileClient
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Represents a file location and type on disk for a configuration file.
|
/// Represents a file location and type on disk for a configuration file.
|
||||||
|
///
|
||||||
|
/// Currently the supported formats are `json` or `toml`. Toml is the default /
|
||||||
|
/// preferred format because it allows comments in the file and is easy to understand.
|
||||||
|
///
|
||||||
public enum File: Equatable, Sendable {
|
public enum File: Equatable, Sendable {
|
||||||
|
|
||||||
case json(URL)
|
case json(URL)
|
||||||
case toml(URL)
|
case toml(URL)
|
||||||
|
|
||||||
|
/// Attempts to create a file with the given url.
|
||||||
|
///
|
||||||
|
/// ## NOTE: There are no checks on if a file / path actually exists or not.
|
||||||
|
///
|
||||||
|
/// If the file does not have a suffix of `json` or `toml` then
|
||||||
|
/// we will return `nil`.
|
||||||
public init?(_ url: URL) {
|
public init?(_ url: URL) {
|
||||||
if url.cleanFilePath.hasSuffix("json") {
|
if url.cleanFilePath.hasSuffix("json") {
|
||||||
self = .json(url)
|
self = .json(url)
|
||||||
@@ -16,18 +27,17 @@ public enum File: Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempts to create a file with the given path.
|
||||||
|
///
|
||||||
|
/// ## NOTE: There are no checks on if a file / path actually exists or not.
|
||||||
|
///
|
||||||
|
/// If the file does not have a suffix of `json` or `toml` then
|
||||||
|
/// we will return `nil`.
|
||||||
public init?(_ path: String) {
|
public init?(_ path: String) {
|
||||||
self.init(URL(filePath: path))
|
self.init(URL(filePath: path))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func json(_ path: String) -> Self {
|
/// Get the url of the file.
|
||||||
.json(URL(filePath: path))
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func toml(_ path: String) -> Self {
|
|
||||||
.toml(URL(filePath: path))
|
|
||||||
}
|
|
||||||
|
|
||||||
public var url: URL {
|
public var url: URL {
|
||||||
switch self {
|
switch self {
|
||||||
case let .json(url): return url
|
case let .json(url): return url
|
||||||
@@ -35,10 +45,14 @@ public enum File: Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the path string of the file.
|
||||||
public var path: String {
|
public var path: String {
|
||||||
url.cleanFilePath
|
url.cleanFilePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents the default file path for a user's configuration.
|
||||||
|
///
|
||||||
|
/// Which is `~/.config/hpa/config.toml`
|
||||||
public static var `default`: Self {
|
public static var `default`: Self {
|
||||||
let fileUrl = FileManager.default
|
let fileUrl = FileManager.default
|
||||||
.homeDirectoryForCurrentUser
|
.homeDirectoryForCurrentUser
|
||||||
@@ -49,11 +63,3 @@ public enum File: Equatable, Sendable {
|
|||||||
return .toml(fileUrl)
|
return .toml(fileUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@_spi(Internal)
|
|
||||||
public extension URL {
|
|
||||||
|
|
||||||
var cleanFilePath: String {
|
|
||||||
absoluteString.replacing("file://", with: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,42 @@
|
|||||||
|
# NOTE:
|
||||||
# Configuration settings for the `hpa` command line tool.
|
# Configuration settings for the `hpa` command line tool.
|
||||||
#
|
# You can delete settings that are not applicable to your use case.
|
||||||
# Delete settings that are not applicable to your use case.
|
|
||||||
|
|
||||||
# Default arguments / options that get passed into `ansible-playbook` commands.
|
# 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 = []
|
args = []
|
||||||
|
|
||||||
# Set to true if you want to pass the vault args to `ansible-playbook` commands.
|
# Set to true if you want to pass the vault args to `ansible-playbook` commands.
|
||||||
useVaultArgs = true
|
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
|
# 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
|
# in most cases. Uncomment the lines if you want to customize the playbook and use it
|
||||||
# instead of the provided / default playbook.
|
# instead of the provided / default playbook.
|
||||||
@@ -15,7 +44,9 @@ useVaultArgs = true
|
|||||||
#[playbook]
|
#[playbook]
|
||||||
#directory = '/path/to/local/playbook-directory'
|
#directory = '/path/to/local/playbook-directory'
|
||||||
#inventory = '/path/to/local/inventory.ini'
|
#inventory = '/path/to/local/inventory.ini'
|
||||||
|
#version = 'main'
|
||||||
|
|
||||||
|
# NOTE:
|
||||||
# These are to declare where your template files are either on your local system or
|
# These are to declare where your template files are either on your local system or
|
||||||
# a remote git repository.
|
# a remote git repository.
|
||||||
[template]
|
[template]
|
||||||
@@ -30,6 +61,7 @@ url = 'https://git.example.com/consult-template.git'
|
|||||||
# branch.
|
# branch.
|
||||||
version = '1.0.0'
|
version = '1.0.0'
|
||||||
|
|
||||||
|
# NOTE:
|
||||||
# Holds settings for `ansible-vault` commands.
|
# Holds settings for `ansible-vault` commands.
|
||||||
[vault]
|
[vault]
|
||||||
# Arguments to pass to commands that use `ansible-vault`, such as encrypting or decrypting
|
# Arguments to pass to commands that use `ansible-vault`, such as encrypting or decrypting
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
@@ -3,22 +3,53 @@ import DependenciesMacros
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public extension DependencyValues {
|
public extension DependencyValues {
|
||||||
|
/// Represents interactions with the file system.
|
||||||
|
///
|
||||||
var fileClient: FileClient {
|
var fileClient: FileClient {
|
||||||
get { self[FileClient.self] }
|
get { self[FileClient.self] }
|
||||||
set { self[FileClient.self] = newValue }
|
set { self[FileClient.self] = newValue }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents interactions with the file system.
|
||||||
|
///
|
||||||
|
///
|
||||||
@DependencyClient
|
@DependencyClient
|
||||||
public struct FileClient: Sendable {
|
public struct FileClient: Sendable {
|
||||||
|
|
||||||
|
/// Copy an item from one location to another.
|
||||||
public var copy: @Sendable (URL, URL) async throws -> Void
|
public var copy: @Sendable (URL, URL) async throws -> Void
|
||||||
|
|
||||||
|
/// Create a directory at the given location.
|
||||||
public var createDirectory: @Sendable (URL) async throws -> Void
|
public var createDirectory: @Sendable (URL) async throws -> Void
|
||||||
|
|
||||||
|
/// Delete the item at the given location.
|
||||||
|
public var delete: @Sendable (URL) async throws -> Void
|
||||||
|
|
||||||
|
/// Check if a file exists at the given location.
|
||||||
public var fileExists: @Sendable (URL) -> Bool = { _ in true }
|
public var fileExists: @Sendable (URL) -> Bool = { _ in true }
|
||||||
public var findVaultFileInCurrentDirectory: @Sendable () async throws -> URL?
|
|
||||||
|
/// Find an ansible-vault file in the given location, checking up to 1 level deep
|
||||||
|
/// in subfolders.
|
||||||
|
public var findVaultFile: @Sendable (URL) async throws -> URL?
|
||||||
|
|
||||||
|
/// Return the user's home directory.
|
||||||
public var homeDirectory: @Sendable () -> URL = { URL(filePath: "~/") }
|
public var homeDirectory: @Sendable () -> URL = { URL(filePath: "~/") }
|
||||||
|
|
||||||
|
/// Check if an item is a directory or not.
|
||||||
public var isDirectory: @Sendable (URL) async throws -> Bool
|
public var isDirectory: @Sendable (URL) async throws -> Bool
|
||||||
|
|
||||||
|
/// Load a file from the given location.
|
||||||
public var load: @Sendable (URL) async throws -> Data
|
public var load: @Sendable (URL) async throws -> Data
|
||||||
|
|
||||||
|
/// Write data to a file at the given location.
|
||||||
public var write: @Sendable (Data, URL) async throws -> Void
|
public var write: @Sendable (Data, URL) async throws -> Void
|
||||||
|
|
||||||
|
/// Find an ansible-vault file in the current directory, checking up to 1 level
|
||||||
|
/// deep in subfolders.
|
||||||
|
public func findVaultFileInCurrentDirectory() async throws -> URL? {
|
||||||
|
try await findVaultFile(URL(filePath: "./"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension FileClient: DependencyKey {
|
extension FileClient: DependencyKey {
|
||||||
@@ -26,23 +57,17 @@ extension FileClient: DependencyKey {
|
|||||||
|
|
||||||
public static var liveValue: Self {
|
public static var liveValue: Self {
|
||||||
let manager = LiveFileClient()
|
let manager = LiveFileClient()
|
||||||
return .init {
|
return .init(
|
||||||
try await manager.copy($0, to: $1)
|
copy: { try await manager.copy($0, to: $1) },
|
||||||
} createDirectory: {
|
createDirectory: { try await manager.creatDirectory($0) },
|
||||||
try await manager.creatDirectory($0)
|
delete: { try await manager.delete($0) },
|
||||||
} fileExists: { url in
|
fileExists: { manager.fileExists(at: $0) },
|
||||||
manager.fileExists(at: url)
|
findVaultFile: { try await manager.findVaultFile(in: $0) },
|
||||||
} findVaultFileInCurrentDirectory: {
|
homeDirectory: { manager.homeDirectory() },
|
||||||
try await manager.findVaultFileInCurrentDirectory()
|
isDirectory: { manager.isDirectory($0) },
|
||||||
} homeDirectory: {
|
load: { try await manager.load(from: $0) },
|
||||||
manager.homeDirectory()
|
write: { try await manager.write($0, to: $1) }
|
||||||
} isDirectory: {
|
)
|
||||||
manager.isDirectory($0)
|
|
||||||
} load: { url in
|
|
||||||
try await manager.load(from: url)
|
|
||||||
} write: { data, url in
|
|
||||||
try await manager.write(data, to: url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,30 +83,35 @@ struct LiveFileClient: Sendable {
|
|||||||
try manager.createDirectory(at: url, withIntermediateDirectories: true)
|
try manager.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func delete(_ url: URL) async throws {
|
||||||
|
try manager.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
func fileExists(at url: URL) -> Bool {
|
func fileExists(at url: URL) -> Bool {
|
||||||
manager.fileExists(atPath: url.cleanFilePath)
|
manager.fileExists(atPath: url.cleanFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func findVaultFileInCurrentDirectory() async throws -> URL? {
|
func findVaultFile(in url: URL) async throws -> URL? {
|
||||||
let urls = try manager.contentsOfDirectory(
|
guard isDirectory(url) else { return nil }
|
||||||
at: URL(filePath: "./"),
|
let urls = try manager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
|
||||||
includingPropertiesForKeys: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check the current directory
|
|
||||||
if let vault = urls.firstVaultFile { return vault }
|
|
||||||
|
|
||||||
|
guard let vault = urls.firstVaultFile else {
|
||||||
|
// check subfolders, 1 layer deep.
|
||||||
let subfolders = urls.filter { isDirectory($0) }
|
let subfolders = urls.filter { isDirectory($0) }
|
||||||
|
|
||||||
for folder in subfolders {
|
for folder in subfolders {
|
||||||
let files = try manager.contentsOfDirectory(
|
let vault = try manager.contentsOfDirectory(
|
||||||
at: folder,
|
at: folder,
|
||||||
includingPropertiesForKeys: nil
|
includingPropertiesForKeys: nil
|
||||||
)
|
)
|
||||||
if let vault = files.firstVaultFile { return vault }
|
.firstVaultFile
|
||||||
|
|
||||||
|
if let vault { return vault }
|
||||||
|
}
|
||||||
|
// Didn't find a file.
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return vault
|
||||||
}
|
}
|
||||||
|
|
||||||
func homeDirectory() -> URL {
|
func homeDirectory() -> URL {
|
||||||
@@ -114,6 +144,8 @@ private extension Array where Element == URL {
|
|||||||
|
|
||||||
public extension URL {
|
public extension URL {
|
||||||
var cleanFilePath: String {
|
var cleanFilePath: String {
|
||||||
absoluteString.replacing("file://", with: "")
|
absoluteString
|
||||||
|
.replacing("file://", with: "")
|
||||||
|
.replacing("/private", with: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
Sources/PandocClient/Constants.swift
Normal file
9
Sources/PandocClient/Constants.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
extension PandocClient {
|
||||||
|
|
||||||
|
/// Represents constant string values needed internally.
|
||||||
|
enum Constants {
|
||||||
|
static let pandocCommand = "pandoc"
|
||||||
|
static let defaultOutputFileName = "Report"
|
||||||
|
static let defaultPdfEngine = "xelatex"
|
||||||
|
}
|
||||||
|
}
|
||||||
245
Sources/PandocClient/PandocClient+Run.swift
Normal file
245
Sources/PandocClient/PandocClient+Run.swift
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import CommandClient
|
||||||
|
import ConfigurationClient
|
||||||
|
import Dependencies
|
||||||
|
import Foundation
|
||||||
|
import PlaybookClient
|
||||||
|
|
||||||
|
extension PandocClient.RunOptions {
|
||||||
|
|
||||||
|
func run(
|
||||||
|
_ fileType: PandocClient.FileType,
|
||||||
|
_ environment: [String: String]
|
||||||
|
) async throws -> String {
|
||||||
|
@Dependency(\.commandClient) var commandClient
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
|
return try await commandClient.run(logging: loggingOptions, quiet: quiet, shell: shell) {
|
||||||
|
let ensuredOptions = try await self.ensuredOptions(fileType)
|
||||||
|
|
||||||
|
let projectDirectory = self.projectDirectory ?? environment["PWD"]
|
||||||
|
|
||||||
|
guard let projectDirectory else {
|
||||||
|
throw ProjectDirectoryNotSpecified()
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldBuildProject {
|
||||||
|
logger.debug("Building project...")
|
||||||
|
try await playbookClient.run.buildProject(.init(
|
||||||
|
projectDirectory: projectDirectory,
|
||||||
|
shared: .init(
|
||||||
|
extraOptions: nil,
|
||||||
|
inventoryFilePath: nil,
|
||||||
|
loggingOptions: loggingOptions,
|
||||||
|
quiet: quiet,
|
||||||
|
shell: shell
|
||||||
|
)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputDirectory = self.outputDirectory ?? projectDirectory
|
||||||
|
let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredExtensionFileName)"
|
||||||
|
|
||||||
|
let arguments = ensuredOptions.makeArguments(
|
||||||
|
outputPath: outputPath,
|
||||||
|
projectDirectory: projectDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("Pandoc arguments: \(arguments)")
|
||||||
|
|
||||||
|
return (arguments, outputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensuredOptions(
|
||||||
|
_ fileType: PandocClient.FileType
|
||||||
|
) async throws -> EnsuredPandocOptions {
|
||||||
|
@Dependency(\.configurationClient) var configurationClient
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
let configuration = try await configurationClient.findAndLoad()
|
||||||
|
logger.debug("Configuration: \(configuration)")
|
||||||
|
|
||||||
|
return try await ensurePandocOptions(
|
||||||
|
configuration: configuration,
|
||||||
|
fileType: fileType,
|
||||||
|
options: self
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PandocClient.FileType {
|
||||||
|
var fileExtension: String {
|
||||||
|
switch self {
|
||||||
|
case .html: return "html"
|
||||||
|
case .latex: return "tex"
|
||||||
|
case .pdf: return "pdf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public struct EnsuredPandocOptions: Equatable, Sendable {
|
||||||
|
public let buildDirectory: String
|
||||||
|
public let extraOptions: [String]?
|
||||||
|
public let files: [String]
|
||||||
|
public let includeInHeader: [String]
|
||||||
|
public let outputFileName: String
|
||||||
|
public let outputFileType: PandocClient.FileType
|
||||||
|
public let pdfEngine: String?
|
||||||
|
|
||||||
|
public var ensuredExtensionFileName: String {
|
||||||
|
let extensionString = ".\(outputFileType.fileExtension)"
|
||||||
|
|
||||||
|
if !outputFileName.hasSuffix(extensionString) {
|
||||||
|
return outputFileName + extensionString
|
||||||
|
}
|
||||||
|
return outputFileName
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeArguments(
|
||||||
|
outputPath: String,
|
||||||
|
projectDirectory: String
|
||||||
|
) -> [String] {
|
||||||
|
var arguments = [PandocClient.Constants.pandocCommand]
|
||||||
|
|
||||||
|
arguments += includeInHeader.map {
|
||||||
|
"--include-in-header=\(projectDirectory)/\(buildDirectory)/\($0)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if let pdfEngine {
|
||||||
|
arguments.append("--pdf-engine=\(pdfEngine)")
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments.append("--output=\(outputPath)")
|
||||||
|
|
||||||
|
if let extraOptions {
|
||||||
|
arguments.append(contentsOf: extraOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments += files.map {
|
||||||
|
"\(projectDirectory)/\(buildDirectory)/\($0)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public func ensurePandocOptions(
|
||||||
|
configuration: Configuration,
|
||||||
|
fileType: PandocClient.FileType,
|
||||||
|
options: PandocClient.RunOptions
|
||||||
|
) async throws -> EnsuredPandocOptions {
|
||||||
|
let defaults = Configuration.Generate.default
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
buildDirectory: options.parseBuildDirectory(configuration.generate, defaults),
|
||||||
|
extraOptions: options.extraOptions,
|
||||||
|
files: options.parseFiles(configuration.generate, defaults),
|
||||||
|
includeInHeader: options.parseIncludeInHeader(configuration.generate, defaults),
|
||||||
|
outputFileName: options.parseOutputFileName(configuration.generate, defaults),
|
||||||
|
outputFileType: fileType,
|
||||||
|
pdfEngine: fileType.parsePdfEngine(configuration.generate, defaults)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public extension PandocClient.FileType {
|
||||||
|
func parsePdfEngine(
|
||||||
|
_ configuration: Configuration.Generate?,
|
||||||
|
_ defaults: Configuration.Generate
|
||||||
|
) -> String? {
|
||||||
|
switch self {
|
||||||
|
case .html, .latex:
|
||||||
|
return nil
|
||||||
|
case let .pdf(engine: engine):
|
||||||
|
if let engine {
|
||||||
|
return engine
|
||||||
|
} else if let engine = configuration?.pdfEngine {
|
||||||
|
return engine
|
||||||
|
} else if let engine = defaults.pdfEngine {
|
||||||
|
return engine
|
||||||
|
} else {
|
||||||
|
return PandocClient.Constants.defaultPdfEngine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public extension PandocClient.RunOptions {
|
||||||
|
func parseFiles(
|
||||||
|
_ configuration: Configuration.Generate?,
|
||||||
|
_ defaults: Configuration.Generate
|
||||||
|
) -> [String] {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
if let files = files {
|
||||||
|
return files
|
||||||
|
} else if let files = configuration?.files {
|
||||||
|
return files
|
||||||
|
} else if let files = defaults.files {
|
||||||
|
return files
|
||||||
|
} else {
|
||||||
|
logger.warning("Files not specified, this could lead to errors.")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIncludeInHeader(
|
||||||
|
_ configuration: Configuration.Generate?,
|
||||||
|
_ defaults: Configuration.Generate
|
||||||
|
) -> [String] {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
if let files = includeInHeader {
|
||||||
|
return files
|
||||||
|
} else if let files = configuration?.includeInHeader {
|
||||||
|
return files
|
||||||
|
} else if let files = defaults.includeInHeader {
|
||||||
|
return files
|
||||||
|
} else {
|
||||||
|
logger.warning("Include in header files not specified, this could lead to errors.")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOutputFileName(
|
||||||
|
_ configuration: Configuration.Generate?,
|
||||||
|
_ defaults: Configuration.Generate
|
||||||
|
) -> String {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
if let output = outputFileName {
|
||||||
|
return output
|
||||||
|
} else if let output = configuration?.outputFileName {
|
||||||
|
return output
|
||||||
|
} else if let output = defaults.outputFileName {
|
||||||
|
return output
|
||||||
|
} else {
|
||||||
|
logger.warning("Output file name not specified, this could lead to errors.")
|
||||||
|
return PandocClient.Constants.defaultOutputFileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBuildDirectory(
|
||||||
|
_ configuration: Configuration.Generate?,
|
||||||
|
_ defaults: Configuration.Generate
|
||||||
|
) -> String {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
if let output = buildDirectory {
|
||||||
|
return output
|
||||||
|
} else if let output = configuration?.buildDirectory {
|
||||||
|
return output
|
||||||
|
} else if let output = defaults.buildDirectory {
|
||||||
|
return output
|
||||||
|
} else {
|
||||||
|
logger.warning("Output file name not specified, this could lead to errors.")
|
||||||
|
return ".build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectDirectoryNotSpecified: Error {}
|
||||||
141
Sources/PandocClient/PandocClient.swift
Normal file
141
Sources/PandocClient/PandocClient.swift
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import CommandClient
|
||||||
|
import ConfigurationClient
|
||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension DependencyValues {
|
||||||
|
|
||||||
|
/// Represents interactions with the `pandoc` command line application.
|
||||||
|
///
|
||||||
|
/// The `pandoc` command line application is used to generate the final output
|
||||||
|
/// documents from a home performance assessment project.
|
||||||
|
///
|
||||||
|
var pandocClient: PandocClient {
|
||||||
|
get { self[PandocClient.self] }
|
||||||
|
set { self[PandocClient.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct PandocClient: Sendable {
|
||||||
|
|
||||||
|
/// Run a pandoc command.
|
||||||
|
public var run: Run
|
||||||
|
|
||||||
|
/// Represents the pandoc commands that we can run.
|
||||||
|
@DependencyClient
|
||||||
|
public struct Run: Sendable {
|
||||||
|
|
||||||
|
/// Generate a latex file from the given options, returning the path the
|
||||||
|
/// generated file was written to.
|
||||||
|
public var generateLatex: @Sendable (RunOptions) async throws -> String
|
||||||
|
|
||||||
|
/// Generate an html file from the given options, returning the path the
|
||||||
|
/// generated file was written to.
|
||||||
|
public var generateHtml: @Sendable (RunOptions) async throws -> String
|
||||||
|
|
||||||
|
/// Generate a pdf file from the given options, returning the path the
|
||||||
|
/// generated file was written to.
|
||||||
|
public var generatePdf: @Sendable (RunOptions, String?) async throws -> String
|
||||||
|
|
||||||
|
/// Generate a pdf file from the given options, returning the path the
|
||||||
|
/// generated file was written to.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - options: The shared run options.
|
||||||
|
/// - pdfEngine: The pdf-engine to use to generate the pdf file.
|
||||||
|
public func generatePdf(
|
||||||
|
_ options: RunOptions,
|
||||||
|
pdfEngine: String? = nil
|
||||||
|
) async throws -> String {
|
||||||
|
try await generatePdf(options, pdfEngine)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the shared options used to run a `pandoc` command.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
public struct RunOptions: Equatable, Sendable {
|
||||||
|
|
||||||
|
let buildDirectory: String?
|
||||||
|
let extraOptions: [String]?
|
||||||
|
let files: [String]?
|
||||||
|
let loggingOptions: LoggingOptions
|
||||||
|
let includeInHeader: [String]?
|
||||||
|
let outputDirectory: String?
|
||||||
|
let outputFileName: String?
|
||||||
|
let projectDirectory: String?
|
||||||
|
let quiet: Bool
|
||||||
|
let shell: String?
|
||||||
|
let shouldBuildProject: Bool
|
||||||
|
|
||||||
|
/// Create the shared run options.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - buildDirectory: Specify the build directory of the project.
|
||||||
|
/// - extraOptions: Extra arguments / options passed to the `pandoc` command.
|
||||||
|
/// - file: Files used to build the final output.
|
||||||
|
/// - loggingOptions: The logging options used when running the command.
|
||||||
|
/// - includeInHeader: Files to include in the header of the final output document.
|
||||||
|
/// - outputDirectory: Optional output directory for the output file, defaults to current working directory.
|
||||||
|
/// - projectDirectory: Optional project directory, defaults to current working directory.
|
||||||
|
/// - outputFileName: Override the output file name.
|
||||||
|
/// - quiet: Don't log output from the command.
|
||||||
|
/// - shell: Optional shell to use when calling the pandoc command.
|
||||||
|
/// - shouldBuildProject: Build the project prior to generating the output file.
|
||||||
|
public init(
|
||||||
|
buildDirectory: String? = nil,
|
||||||
|
extraOptions: [String]? = nil,
|
||||||
|
files: [String]? = nil,
|
||||||
|
loggingOptions: LoggingOptions,
|
||||||
|
includeInHeader: [String]? = nil,
|
||||||
|
outputDirectory: String? = nil,
|
||||||
|
projectDirectory: String? = nil,
|
||||||
|
outputFileName: String? = nil,
|
||||||
|
quiet: Bool = false,
|
||||||
|
shell: String? = nil,
|
||||||
|
shouldBuild shouldBuildProject: Bool = true
|
||||||
|
) {
|
||||||
|
self.buildDirectory = buildDirectory
|
||||||
|
self.extraOptions = extraOptions
|
||||||
|
self.files = files
|
||||||
|
self.loggingOptions = loggingOptions
|
||||||
|
self.includeInHeader = includeInHeader
|
||||||
|
self.outputDirectory = outputDirectory
|
||||||
|
self.outputFileName = outputFileName
|
||||||
|
self.projectDirectory = projectDirectory
|
||||||
|
self.quiet = quiet
|
||||||
|
self.shell = shell
|
||||||
|
self.shouldBuildProject = shouldBuildProject
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the file types that we can generate output files for.
|
||||||
|
@_spi(Internal)
|
||||||
|
public enum FileType: Equatable, Sendable {
|
||||||
|
case html
|
||||||
|
case latex
|
||||||
|
case pdf(engine: String?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PandocClient: DependencyKey {
|
||||||
|
public static let testValue: PandocClient = Self(run: Run())
|
||||||
|
|
||||||
|
public static func live(environment: [String: String]) -> PandocClient {
|
||||||
|
.init(
|
||||||
|
run: Run(
|
||||||
|
generateLatex: { try await $0.run(.latex, environment) },
|
||||||
|
generateHtml: { try await $0.run(.html, environment) },
|
||||||
|
generatePdf: { try await $0.run(.pdf(engine: $1), environment) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var liveValue: PandocClient {
|
||||||
|
.live(environment: ProcessInfo.processInfo.environment)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Sources/PlaybookClient/Constants.swift
Normal file
14
Sources/PlaybookClient/Constants.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// TODO: Use an actual version tag for playbook repo.
|
||||||
|
// TODO: Use an externally public url for the playbook repo.
|
||||||
|
|
||||||
|
public extension PlaybookClient {
|
||||||
|
@_spi(Internal)
|
||||||
|
enum Constants {
|
||||||
|
public static let defaultInstallationPath = "~/.local/share/hpa/playbook"
|
||||||
|
public static let inventoryFileName = "inventory.ini"
|
||||||
|
public static let playbookCommand = "ansible-playbook"
|
||||||
|
public static let playbookFileName = "main.yml"
|
||||||
|
public static let playbookRepoUrl = "https://git.housh.dev/michael/ansible-hpa-playbook.git"
|
||||||
|
public static let playbookRepoVersion = "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}()
|
||||||
262
Sources/PlaybookClient/PlaybookClient.swift
Normal file
262
Sources/PlaybookClient/PlaybookClient.swift
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import CommandClient
|
||||||
|
import ConfigurationClient
|
||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import FileClient
|
||||||
|
import Foundation
|
||||||
|
import ShellClient
|
||||||
|
|
||||||
|
// TODO: Add update checks and pull for the playbook.
|
||||||
|
|
||||||
|
public extension DependencyValues {
|
||||||
|
|
||||||
|
/// Manages interactions with the playbook repository as well as `ansible-playbook`
|
||||||
|
/// command line application.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
var playbookClient: PlaybookClient {
|
||||||
|
get { self[PlaybookClient.self] }
|
||||||
|
set { self[PlaybookClient.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct PlaybookClient: Sendable {
|
||||||
|
|
||||||
|
/// Manages interactions with the playbook repository.
|
||||||
|
public var repository: Repository
|
||||||
|
|
||||||
|
/// Run the `ansible-playbook` command line application.
|
||||||
|
public var run: RunPlaybook
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension PlaybookClient {
|
||||||
|
|
||||||
|
/// Manages interactions with the `ansible-hpa-playbook` repository, which is
|
||||||
|
/// used to build and generate home performance assessment projects.
|
||||||
|
@DependencyClient
|
||||||
|
struct Repository: Sendable {
|
||||||
|
|
||||||
|
/// Install the repository based on the given configuration. If configuration is
|
||||||
|
/// not supplied then the default location for the playbook repository is
|
||||||
|
/// `~/.local/share/hpa/playbook`
|
||||||
|
public var install: @Sendable (Configuration?) async throws -> Void
|
||||||
|
|
||||||
|
/// Get the current directory of the playbook repository based on the given
|
||||||
|
/// configuration.
|
||||||
|
public var directory: @Sendable (Configuration?) async throws -> String
|
||||||
|
|
||||||
|
/// Install the playbook in the default location.
|
||||||
|
public func install() async throws {
|
||||||
|
try await install(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current directory path of the playbook repository.
|
||||||
|
public func directory() async throws -> String {
|
||||||
|
try await directory(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension PlaybookClient {
|
||||||
|
|
||||||
|
/// Runs the `ansible-playbook` command line application with the `ansible-hpa-playbook`.
|
||||||
|
///
|
||||||
|
/// This is used to build and create home performance projects. It can also generate a
|
||||||
|
/// template for a user to customize to their use case.
|
||||||
|
@DependencyClient
|
||||||
|
struct RunPlaybook: Sendable {
|
||||||
|
|
||||||
|
/// Build a home performance assesment project with the given options.
|
||||||
|
public var buildProject: @Sendable (BuildOptions) async throws -> Void
|
||||||
|
|
||||||
|
/// Create a new home performance assesment project with the given options.
|
||||||
|
public var createProject: @Sendable (CreateOptions, JSONEncoder?) async throws -> Void
|
||||||
|
|
||||||
|
/// Generate a user's template from the default home performance template.
|
||||||
|
public var generateTemplate: @Sendable (GenerateTemplateOptions) async throws -> String
|
||||||
|
|
||||||
|
/// Create a new home performance assesment project with the given options.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - options: The options used to create the project.
|
||||||
|
public func createProject(_ options: CreateOptions) async throws {
|
||||||
|
try await createProject(options, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents options that are shared for all the `ansible-playbook` commands.
|
||||||
|
public struct SharedRunOptions: Equatable, Sendable {
|
||||||
|
|
||||||
|
/// Extra arguments / options passed to the `ansible-playbook` command.
|
||||||
|
let extraOptions: [String]?
|
||||||
|
|
||||||
|
/// Specify the inventory file path.
|
||||||
|
let inventoryFilePath: String?
|
||||||
|
|
||||||
|
/// The logging options used when running the command.
|
||||||
|
let loggingOptions: LoggingOptions
|
||||||
|
|
||||||
|
/// Disables log output of the command.
|
||||||
|
let quiet: Bool
|
||||||
|
|
||||||
|
/// Optional shell to use when running the command.
|
||||||
|
let shell: String?
|
||||||
|
|
||||||
|
/// Create the shared options.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - extraOptions: The extra arguments / options to pass to the command.
|
||||||
|
/// - inventoryFilePath: Specify the inventory file path.
|
||||||
|
/// - loggingOptions: The logging options used when running the command.
|
||||||
|
/// - quiet: Disable log output from the command.
|
||||||
|
/// - shell: Specify a shell used when running the command.
|
||||||
|
public init(
|
||||||
|
extraOptions: [String]? = nil,
|
||||||
|
inventoryFilePath: String? = nil,
|
||||||
|
loggingOptions: LoggingOptions,
|
||||||
|
quiet: Bool = false,
|
||||||
|
shell: String? = nil
|
||||||
|
) {
|
||||||
|
self.extraOptions = extraOptions
|
||||||
|
self.inventoryFilePath = inventoryFilePath
|
||||||
|
self.loggingOptions = loggingOptions
|
||||||
|
self.quiet = quiet
|
||||||
|
self.shell = shell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options require when calling the build project command.
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public struct BuildOptions: Equatable, Sendable {
|
||||||
|
|
||||||
|
/// An optional project directory, if not supplied then we will use the current working directory.
|
||||||
|
let projectDirectory: String?
|
||||||
|
|
||||||
|
/// The shared run options.
|
||||||
|
let shared: SharedRunOptions
|
||||||
|
|
||||||
|
/// Create new build options.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - projectDirectory: The optional project directory to build, if not supplied then we'll use the current working directory.
|
||||||
|
/// - shared: The shared run options.
|
||||||
|
public init(
|
||||||
|
projectDirectory: String? = nil,
|
||||||
|
shared: SharedRunOptions
|
||||||
|
) {
|
||||||
|
self.projectDirectory = projectDirectory
|
||||||
|
self.shared = shared
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<T>(dynamicMember keyPath: KeyPath<SharedRunOptions, T>) -> T {
|
||||||
|
shared[keyPath: keyPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options required when creating a new home performance assessment project.
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public struct CreateOptions: Equatable, Sendable {
|
||||||
|
/// The directory to generate the new project in.
|
||||||
|
let projectDirectory: String
|
||||||
|
/// Shared run options.
|
||||||
|
let shared: SharedRunOptions
|
||||||
|
/// Custom template configuration to use.
|
||||||
|
let template: Configuration.Template?
|
||||||
|
/// Specify whether we should only use a local template directory to create the project from.
|
||||||
|
let useLocalTemplateDirectory: Bool
|
||||||
|
|
||||||
|
/// Create new create options.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - projectDirectory: The directory to generate the project in.
|
||||||
|
/// - shared: The shared run options.
|
||||||
|
/// - template: Custom template configuration used when generating the project.
|
||||||
|
/// - useLocalTemplateDirectory: Whether to use a local template directory, not a template repository.
|
||||||
|
public init(
|
||||||
|
projectDirectory: String,
|
||||||
|
shared: SharedRunOptions,
|
||||||
|
template: Configuration.Template? = nil,
|
||||||
|
useLocalTemplateDirectory: Bool
|
||||||
|
) {
|
||||||
|
self.projectDirectory = projectDirectory
|
||||||
|
self.shared = shared
|
||||||
|
self.template = template
|
||||||
|
self.useLocalTemplateDirectory = useLocalTemplateDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<T>(dynamicMember keyPath: KeyPath<SharedRunOptions, T>) -> T {
|
||||||
|
shared[keyPath: keyPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options required when generating a new template repository / directory.
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public struct GenerateTemplateOptions: Equatable, Sendable {
|
||||||
|
/// The shared run options.
|
||||||
|
let shared: SharedRunOptions
|
||||||
|
/// The path to generate the template in.
|
||||||
|
let templateDirectory: String
|
||||||
|
/// Specify the name of the directory for template variables.
|
||||||
|
let templateVarsDirectory: String?
|
||||||
|
/// Specify whether to use `ansible-vault` encrypted variables.
|
||||||
|
let useVault: Bool
|
||||||
|
|
||||||
|
/// Create new generate template options.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - shared: The shared run options
|
||||||
|
/// - templateDirectory: The path to generate the template in.
|
||||||
|
/// - templateVarsDirectory: Specify the name of the directory for template variables.
|
||||||
|
/// - useVault: Specify wheter to use `ansible-vault` encrypted variables.
|
||||||
|
public init(
|
||||||
|
shared: SharedRunOptions,
|
||||||
|
templateDirectory: String,
|
||||||
|
templateVarsDirectory: String? = nil,
|
||||||
|
useVault: Bool = true
|
||||||
|
) {
|
||||||
|
self.shared = shared
|
||||||
|
self.templateDirectory = templateDirectory
|
||||||
|
self.templateVarsDirectory = templateVarsDirectory
|
||||||
|
self.useVault = useVault
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<T>(dynamicMember keyPath: KeyPath<SharedRunOptions, T>) -> T {
|
||||||
|
shared[keyPath: keyPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PlaybookClient.Repository: DependencyKey {
|
||||||
|
public static var liveValue: Self {
|
||||||
|
.init {
|
||||||
|
try await installPlaybook(configuration: $0)
|
||||||
|
} directory: {
|
||||||
|
try await findDirectory(configuration: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PlaybookClient.RunPlaybook: DependencyKey {
|
||||||
|
public static var liveValue: PlaybookClient.RunPlaybook {
|
||||||
|
.init(
|
||||||
|
buildProject: { try await $0.run() },
|
||||||
|
createProject: { try await $0.run(encoder: $1) },
|
||||||
|
generateTemplate: { try await $0.run() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PlaybookClient: DependencyKey {
|
||||||
|
public static let testValue: PlaybookClient = Self(
|
||||||
|
repository: Repository(),
|
||||||
|
run: RunPlaybook()
|
||||||
|
)
|
||||||
|
|
||||||
|
public static var liveValue: PlaybookClient {
|
||||||
|
.init(
|
||||||
|
repository: .liveValue,
|
||||||
|
run: .liveValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum CliClientError: Error {
|
enum PlaybookClientError: Error {
|
||||||
case encodingError
|
case encodingError
|
||||||
case playbookDirectoryNotFound
|
case projectDirectoryNotFound
|
||||||
case templateDirectoryNotFound
|
case templateDirectoryNotFound
|
||||||
case templateDirectoryOrRepoNotSpecified
|
case templateDirectoryOrRepoNotSpecified
|
||||||
case vaultFileNotFound
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,42 @@
|
|||||||
|
@_spi(Internal) @_exported import CommandClient
|
||||||
@_exported import Dependencies
|
@_exported import Dependencies
|
||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
@_exported import ShellClient
|
@_exported import ShellClient
|
||||||
|
|
||||||
public protocol TestCase {}
|
public protocol TestCase {}
|
||||||
|
|
||||||
public extension 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(
|
func withTestLogger(
|
||||||
key: String,
|
key: String,
|
||||||
logLevel: Logger.Level = .debug,
|
logLevel: Logger.Level = .debug,
|
||||||
@@ -128,3 +161,7 @@ public func withTestLogger(
|
|||||||
try await 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
|
||||||
177
Sources/VaultClient/VaultClient.swift
Normal file
177
Sources/VaultClient/VaultClient.swift
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import CommandClient
|
||||||
|
import ConfigurationClient
|
||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import FileClient
|
||||||
|
|
||||||
|
public extension DependencyValues {
|
||||||
|
/// Manages interactions with `ansible-vault` command line application.
|
||||||
|
var vaultClient: VaultClient {
|
||||||
|
get { self[VaultClient.self] }
|
||||||
|
set { self[VaultClient.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct VaultClient: Sendable {
|
||||||
|
/// Run an `ansible-vault` command.
|
||||||
|
public var run: Run
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct Run: Sendable {
|
||||||
|
/// Decrypt an `ansible-vault` file.
|
||||||
|
public var decrypt: @Sendable (RunOptions) async throws -> String
|
||||||
|
/// Encrypt an `ansible-vault` file.
|
||||||
|
public var encrypt: @Sendable (RunOptions) async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RunOptions: Equatable, Sendable {
|
||||||
|
|
||||||
|
/// Extra arguments / options passed to the `ansible-vault` command.
|
||||||
|
let extraOptions: [String]?
|
||||||
|
/// Logging options to use while running the command.
|
||||||
|
let loggingOptions: LoggingOptions
|
||||||
|
/// The optional output file path.
|
||||||
|
let outputFilePath: String?
|
||||||
|
/// Disable logging output.
|
||||||
|
let quiet: Bool
|
||||||
|
/// Optional shell used to call the command.
|
||||||
|
let shell: String?
|
||||||
|
/// The path to the vault file, if not supplied we will search the current directory for it.
|
||||||
|
let vaultFilePath: String?
|
||||||
|
|
||||||
|
/// Create new run options.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - extraOptions: Extra arguments / options passed to the command.
|
||||||
|
/// - loggingOptions: The logging options used while running the command.
|
||||||
|
/// - outputFilePath: The optional output file path.
|
||||||
|
/// - quiet: Disable logging output of the command.
|
||||||
|
/// - shell: The shell used to call the command.
|
||||||
|
/// - vaultFilePath: The vault file, if not supplied we will search in the current working directory.
|
||||||
|
public init(
|
||||||
|
extraOptions: [String]? = nil,
|
||||||
|
loggingOptions: LoggingOptions,
|
||||||
|
outputFilePath: String? = nil,
|
||||||
|
quiet: Bool = false,
|
||||||
|
shell: String? = nil,
|
||||||
|
vaultFilePath: String? = nil
|
||||||
|
) {
|
||||||
|
self.extraOptions = extraOptions
|
||||||
|
self.loggingOptions = loggingOptions
|
||||||
|
self.outputFilePath = outputFilePath
|
||||||
|
self.quiet = quiet
|
||||||
|
self.shell = shell
|
||||||
|
self.vaultFilePath = vaultFilePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public enum Route: String, Equatable, Sendable {
|
||||||
|
case decrypt
|
||||||
|
case encrypt
|
||||||
|
|
||||||
|
public var verb: String { rawValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VaultClient: DependencyKey {
|
||||||
|
|
||||||
|
public static let testValue: VaultClient = Self(run: Run())
|
||||||
|
|
||||||
|
public static var liveValue: VaultClient {
|
||||||
|
.init(
|
||||||
|
run: .init(
|
||||||
|
decrypt: { try await $0.run(route: .decrypt) },
|
||||||
|
encrypt: { try await $0.run(route: .encrypt) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public extension VaultClient {
|
||||||
|
enum Constants {
|
||||||
|
public static let vaultCommand = "ansible-vault"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VaultClient.RunOptions {
|
||||||
|
|
||||||
|
// Sets up the default arguments and runs the `ansible-vault` command,
|
||||||
|
// returning the output file path, which is either supplied by the caller
|
||||||
|
// or found via the `fileClient.findVaultFileInCurrentDirectory`.
|
||||||
|
//
|
||||||
|
// This allows the output to be piped into other commands.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
@discardableResult
|
||||||
|
func run(route: VaultClient.Route) async throws -> String {
|
||||||
|
@Dependency(\.commandClient) var commandClient
|
||||||
|
|
||||||
|
return try await commandClient.run(
|
||||||
|
logging: loggingOptions,
|
||||||
|
quiet: quiet,
|
||||||
|
shell: shell
|
||||||
|
) {
|
||||||
|
@Dependency(\.configurationClient) var configurationClient
|
||||||
|
@Dependency(\.fileClient) var fileClient
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
|
var output: String?
|
||||||
|
|
||||||
|
let configuration = try await configurationClient.findAndLoad()
|
||||||
|
logger.trace("Configuration: \(configuration)")
|
||||||
|
|
||||||
|
var vaultFilePath: String? = vaultFilePath
|
||||||
|
|
||||||
|
if vaultFilePath == nil {
|
||||||
|
vaultFilePath = try await fileClient
|
||||||
|
.findVaultFileInCurrentDirectory()?
|
||||||
|
.cleanFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let vaultFilePath else {
|
||||||
|
throw VaultClientError.vaultFileNotFound
|
||||||
|
}
|
||||||
|
output = vaultFilePath
|
||||||
|
|
||||||
|
logger.trace("Vault file: \(vaultFilePath)")
|
||||||
|
|
||||||
|
var arguments = [
|
||||||
|
VaultClient.Constants.vaultCommand,
|
||||||
|
route.verb
|
||||||
|
]
|
||||||
|
|
||||||
|
if let outputFilePath {
|
||||||
|
arguments.append(contentsOf: ["--output", outputFilePath])
|
||||||
|
output = outputFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
if let extraOptions {
|
||||||
|
arguments.append(contentsOf: extraOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let vaultArgs = configuration.vault.args {
|
||||||
|
arguments.append(contentsOf: vaultArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if arguments.contains("encrypt"),
|
||||||
|
!arguments.contains("--encrypt-vault-id"),
|
||||||
|
let id = configuration.vault.encryptId
|
||||||
|
{
|
||||||
|
arguments.append(contentsOf: ["--encrypt-vault-id", id])
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments.append(vaultFilePath)
|
||||||
|
|
||||||
|
logger.trace("Arguments: \(arguments)")
|
||||||
|
|
||||||
|
return (arguments, output ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VaultClientError: Error {
|
||||||
|
case vaultFileNotFound
|
||||||
|
}
|
||||||
@@ -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,7 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import CliClient
|
|
||||||
import Dependencies
|
import Dependencies
|
||||||
|
import Foundation
|
||||||
|
import PlaybookClient
|
||||||
|
|
||||||
struct BuildCommand: AsyncParsableCommand {
|
struct BuildCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
@@ -9,46 +10,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 _run()
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
// try await runPlaybook(
|
try await playbookClient.run.buildProject(.init(
|
||||||
// commandName: Self.commandName,
|
projectDirectory: projectDirectory,
|
||||||
// globals: globals,
|
shared: globals.sharedPlaybookRunOptions(
|
||||||
// extraArgs: extraArgs,
|
commandName: Self.commandName,
|
||||||
// "--tags", "build-project",
|
extraOptions: extraOptions
|
||||||
// "--extra-vars", "project_dir=\(projectDir)"
|
|
||||||
// )
|
|
||||||
}
|
|
||||||
|
|
||||||
private func _run() async throws {
|
|
||||||
@Dependency(\.cliClient) var cliClient
|
|
||||||
|
|
||||||
try await cliClient.runPlaybookCommand(
|
|
||||||
globals.playbookOptions(
|
|
||||||
arguments: [
|
|
||||||
"--tags", "build-project",
|
|
||||||
"--extra-vars", "project_dir=\(projectDir)"
|
|
||||||
],
|
|
||||||
configuration: nil
|
|
||||||
),
|
|
||||||
logging: globals.loggingOptions(commandName: Self.commandName)
|
|
||||||
)
|
)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import CliClient
|
|
||||||
import ConfigurationClient
|
import ConfigurationClient
|
||||||
import Dependencies
|
import Dependencies
|
||||||
import Foundation
|
import Foundation
|
||||||
import Logging
|
import Logging
|
||||||
|
import PlaybookClient
|
||||||
|
|
||||||
struct CreateCommand: AsyncParsableCommand {
|
struct CreateCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
@@ -54,53 +54,19 @@ 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(
|
||||||
@Dependency(\.coders) var coders
|
commandName: Self.commandName,
|
||||||
@Dependency(\.cliClient) var cliClient
|
extraOptions: extraOptions
|
||||||
@Dependency(\.configurationClient) var configurationClient
|
|
||||||
|
|
||||||
let loggingOptions = globals.loggingOptions(commandName: Self.commandName)
|
|
||||||
|
|
||||||
try await cliClient.withLogger(loggingOptions) {
|
|
||||||
@Dependency(\.logger) var logger
|
|
||||||
|
|
||||||
let json = try await cliClient.generateJSON(
|
|
||||||
generateJsonOptions,
|
|
||||||
logging: loggingOptions
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug("JSON string: \(json)")
|
|
||||||
|
|
||||||
let arguments = [
|
|
||||||
"--tags", "setup-project",
|
|
||||||
"--extra-vars", "project_dir=\(self.projectDir)",
|
|
||||||
"--extra-vars", "'\(json)'"
|
|
||||||
] + extraArgs
|
|
||||||
|
|
||||||
try await cliClient.runPlaybookCommand(
|
|
||||||
globals.playbookOptions(
|
|
||||||
arguments: arguments,
|
|
||||||
configuration: nil
|
|
||||||
),
|
),
|
||||||
logging: loggingOptions
|
template: .init(directory: templateDir),
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension CreateCommand {
|
|
||||||
var generateJsonOptions: CliClient.GenerateJsonOptions {
|
|
||||||
.init(
|
|
||||||
templateDirectory: templateDir,
|
|
||||||
templateRepo: repo,
|
|
||||||
version: branch,
|
|
||||||
useLocalTemplateDirectory: localTemplateDir
|
useLocalTemplateDirectory: localTemplateDir
|
||||||
)
|
))
|
||||||
|
print(projectDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 Dependencies
|
||||||
|
import PandocClient
|
||||||
|
|
||||||
|
// TODO: Need to add a step to build prior to generating file.
|
||||||
|
struct GenerateHtmlCommand: AsyncParsableCommand {
|
||||||
|
static let commandName = "html"
|
||||||
|
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: commandName
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var globals: GenerateOptions
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
@Dependency(\.pandocClient) var pandocClient
|
||||||
|
|
||||||
|
let output = try await pandocClient.run.generateHtml(
|
||||||
|
globals.pandocRunOptions(commandName: Self.commandName)
|
||||||
|
)
|
||||||
|
print(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
22
Sources/hpa/GenerateCommands/GenerateLatexCommand.swift
Normal file
22
Sources/hpa/GenerateCommands/GenerateLatexCommand.swift
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Dependencies
|
||||||
|
|
||||||
|
// TODO: Need to add a step to build prior to generating file.
|
||||||
|
struct GenerateLatexCommand: AsyncParsableCommand {
|
||||||
|
static let commandName = "latex"
|
||||||
|
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: commandName
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var globals: GenerateOptions
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
@Dependency(\.pandocClient) var pandocClient
|
||||||
|
|
||||||
|
let output = try await pandocClient.run.generateLatex(
|
||||||
|
globals.pandocRunOptions(commandName: Self.commandName)
|
||||||
|
)
|
||||||
|
print(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
Sources/hpa/GenerateCommands/GenerateOptions.swift
Normal file
95
Sources/hpa/GenerateCommands/GenerateOptions.swift
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CommandClient
|
||||||
|
import PandocClient
|
||||||
|
|
||||||
|
@dynamicMemberLookup
|
||||||
|
struct GenerateOptions: ParsableArguments {
|
||||||
|
|
||||||
|
@OptionGroup var basic: BasicGlobalOptions
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: .shortAndLong,
|
||||||
|
help: "Custom build directory path.",
|
||||||
|
completion: .directory
|
||||||
|
)
|
||||||
|
var buildDirectory: String?
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: [.short, .customLong("file")],
|
||||||
|
help: "Files used to generate the output, can be specified multiple times.",
|
||||||
|
completion: .file()
|
||||||
|
)
|
||||||
|
var files: [String] = []
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: [.customShort("H"), .long],
|
||||||
|
help: "Files to include in the header, can be specified multiple times.",
|
||||||
|
completion: .file()
|
||||||
|
)
|
||||||
|
var includeInHeader: [String] = []
|
||||||
|
|
||||||
|
@Flag(
|
||||||
|
help: "Do not build the project prior to generating the output."
|
||||||
|
)
|
||||||
|
var noBuild: Bool = false
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: .shortAndLong,
|
||||||
|
help: "The project directory.",
|
||||||
|
completion: .directory
|
||||||
|
)
|
||||||
|
var projectDirectory: String?
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: .shortAndLong,
|
||||||
|
help: "The output directory",
|
||||||
|
completion: .directory
|
||||||
|
)
|
||||||
|
var outputDirectory: String?
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: [.customShort("n"), .customLong("name")],
|
||||||
|
help: "Name of the output file."
|
||||||
|
)
|
||||||
|
var outputFileName: String?
|
||||||
|
|
||||||
|
// NOTE: This must be last, both here and in the commands, so if the commands have options of their
|
||||||
|
// own, they must be declared ahead of using the global options.
|
||||||
|
|
||||||
|
@Argument(
|
||||||
|
help: "Extra arguments / options to pass to the underlying pandoc command."
|
||||||
|
)
|
||||||
|
var extraOptions: [String] = []
|
||||||
|
|
||||||
|
subscript<T>(dynamicMember keyPath: WritableKeyPath<BasicGlobalOptions, T>) -> T {
|
||||||
|
get { basic[keyPath: keyPath] }
|
||||||
|
set { basic[keyPath: keyPath] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
subscript<T>(dynamicMember keyPath: KeyPath<BasicGlobalOptions, T>) -> T {
|
||||||
|
basic[keyPath: keyPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension GenerateOptions {
|
||||||
|
|
||||||
|
func loggingOptions(commandName: String) -> LoggingOptions {
|
||||||
|
basic.loggingOptions(commandName: commandName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pandocRunOptions(commandName: String) -> PandocClient.RunOptions {
|
||||||
|
.init(
|
||||||
|
buildDirectory: buildDirectory,
|
||||||
|
extraOptions: extraOptions.count > 0 ? extraOptions : nil,
|
||||||
|
files: files.count > 0 ? files : nil,
|
||||||
|
loggingOptions: .init(commandName: commandName, logLevel: .init(globals: basic, quietOnlyPlaybook: false)),
|
||||||
|
includeInHeader: includeInHeader.count > 0 ? includeInHeader : nil,
|
||||||
|
outputDirectory: outputDirectory,
|
||||||
|
projectDirectory: projectDirectory,
|
||||||
|
outputFileName: outputFileName,
|
||||||
|
quiet: basic.quiet,
|
||||||
|
shell: basic.shell,
|
||||||
|
shouldBuild: !noBuild
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Sources/hpa/GenerateCommands/GeneratePdfCommand.swift
Normal file
32
Sources/hpa/GenerateCommands/GeneratePdfCommand.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Dependencies
|
||||||
|
import PandocClient
|
||||||
|
|
||||||
|
// TODO: Need to add a step to build prior to generating file.
|
||||||
|
|
||||||
|
struct GeneratePdfCommand: AsyncParsableCommand {
|
||||||
|
static let commandName = "pdf"
|
||||||
|
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: commandName
|
||||||
|
)
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: [.customShort("e"), .customLong("engine")],
|
||||||
|
help: "The pdf engine to use."
|
||||||
|
)
|
||||||
|
var pdfEngine: String?
|
||||||
|
|
||||||
|
@OptionGroup var globals: GenerateOptions
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
@Dependency(\.pandocClient) var pandocClient
|
||||||
|
|
||||||
|
let output = try await pandocClient.run.generatePdf(
|
||||||
|
globals.pandocRunOptions(commandName: Self.commandName),
|
||||||
|
pdfEngine: pdfEngine
|
||||||
|
)
|
||||||
|
|
||||||
|
print(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,13 @@ func createAbstract(_ string: String) -> String {
|
|||||||
|
|
||||||
extension Usage where Content == AnyTextNode {
|
extension Usage where Content == AnyTextNode {
|
||||||
|
|
||||||
static func `default`(commandName: String, parentCommand: String?) -> Self {
|
static func `default`(
|
||||||
|
commandName: String,
|
||||||
|
parentCommand: String?,
|
||||||
|
usesArguments: Bool = true,
|
||||||
|
usesOptions: Bool = true,
|
||||||
|
usesExtraArguments: Bool = true
|
||||||
|
) -> Self {
|
||||||
.init {
|
.init {
|
||||||
HStack {
|
HStack {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -37,11 +43,17 @@ extension Usage where Content == AnyTextNode {
|
|||||||
}
|
}
|
||||||
.color(.blue).bold()
|
.color(.blue).bold()
|
||||||
|
|
||||||
|
if usesOptions {
|
||||||
"[OPTIONS]".color(.green)
|
"[OPTIONS]".color(.green)
|
||||||
|
}
|
||||||
|
if usesArguments {
|
||||||
"[ARGUMENTS]".color(.cyan)
|
"[ARGUMENTS]".color(.cyan)
|
||||||
|
}
|
||||||
|
if usesExtraArguments {
|
||||||
"--"
|
"--"
|
||||||
"[EXTRA-OPTIONS]".color(.magenta)
|
"[EXTRA-OPTIONS]".color(.magenta)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.eraseToAnyTextNode()
|
.eraseToAnyTextNode()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import Rainbow
|
|||||||
// Constant string values.
|
// Constant string values.
|
||||||
enum Constants {
|
enum Constants {
|
||||||
static let appName = "hpa"
|
static let appName = "hpa"
|
||||||
|
static let brewPackages = [
|
||||||
|
"ansible", "imagemagick", "pandoc", "texLive"
|
||||||
|
]
|
||||||
static let playbookFileName = "main.yml"
|
static let playbookFileName = "main.yml"
|
||||||
static let inventoryFileName = "inventory.ini"
|
static let inventoryFileName = "inventory.ini"
|
||||||
static let importantExtraArgsNote = """
|
static let importantExtraArgsNote = """
|
||||||
|
|||||||
@@ -26,14 +26,33 @@ extension Array where Element == Example {
|
|||||||
))
|
))
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addingPipeToOtherCommand(command: String) -> Self {
|
||||||
|
var output = self
|
||||||
|
output.append((
|
||||||
|
label: "Piping output to other command.",
|
||||||
|
example: "\(command) --quiet | xargs open"
|
||||||
|
))
|
||||||
|
return output
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ExampleSection where Header == String, Label == String {
|
extension ExampleSection where Header == String, Label == String {
|
||||||
static func `default`(examples: [Example], usesPassingExtraOptions: Bool = true) -> some TextNode {
|
static func `default`(
|
||||||
|
examples: [Example],
|
||||||
|
usesPassingExtraOptions: Bool = true,
|
||||||
|
usesPipeToOtherCommand: Bool = true
|
||||||
|
) -> some TextNode {
|
||||||
var examples = examples
|
var examples = examples
|
||||||
if usesPassingExtraOptions, let first = examples.first {
|
|
||||||
|
if let first = examples.first {
|
||||||
|
if usesPassingExtraOptions {
|
||||||
examples = examples.addingPassingOptions(command: first.example)
|
examples = examples.addingPassingOptions(command: first.example)
|
||||||
}
|
}
|
||||||
|
if usesPipeToOtherCommand {
|
||||||
|
examples = examples.addingPipeToOtherCommand(command: first.example)
|
||||||
|
}
|
||||||
|
}
|
||||||
return Self(examples: examples) {
|
return Self(examples: examples) {
|
||||||
"EXAMPLES:"
|
"EXAMPLES:"
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import CliClient
|
import CommandClient
|
||||||
|
import ConfigurationClient
|
||||||
|
import PlaybookClient
|
||||||
|
|
||||||
struct BasicGlobalOptions: ParsableArguments {
|
struct BasicGlobalOptions: ParsableArguments {
|
||||||
@Flag(
|
@Flag(
|
||||||
@@ -27,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,
|
||||||
@@ -58,7 +60,7 @@ struct GlobalOptions: ParsableArguments {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension GlobalOptions {
|
extension GlobalOptions {
|
||||||
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
|
func loggingOptions(commandName: String) -> LoggingOptions {
|
||||||
.init(
|
.init(
|
||||||
commandName: commandName,
|
commandName: commandName,
|
||||||
logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook)
|
logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook)
|
||||||
@@ -67,10 +69,28 @@ extension GlobalOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension BasicGlobalOptions {
|
extension BasicGlobalOptions {
|
||||||
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
|
func loggingOptions(commandName: String) -> LoggingOptions {
|
||||||
.init(
|
.init(
|
||||||
commandName: commandName,
|
commandName: commandName,
|
||||||
logLevel: .init(globals: self, quietOnlyPlaybook: false)
|
logLevel: .init(globals: self, quietOnlyPlaybook: false)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension GlobalOptions {
|
||||||
|
func sharedPlaybookRunOptions(
|
||||||
|
commandName: String,
|
||||||
|
extraOptions: [String]?
|
||||||
|
) -> PlaybookClient.RunPlaybook.SharedRunOptions {
|
||||||
|
return .init(
|
||||||
|
extraOptions: extraOptions,
|
||||||
|
inventoryFilePath: inventoryPath,
|
||||||
|
loggingOptions: .init(
|
||||||
|
commandName: commandName,
|
||||||
|
logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook)
|
||||||
|
),
|
||||||
|
quiet: basic.quiet,
|
||||||
|
shell: basic.shell
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import CliClient
|
|
||||||
import ConfigurationClient
|
|
||||||
|
|
||||||
extension GlobalOptions {
|
|
||||||
|
|
||||||
func playbookOptions(
|
|
||||||
arguments: [String],
|
|
||||||
configuration: Configuration?
|
|
||||||
) -> CliClient.PlaybookOptions {
|
|
||||||
.init(
|
|
||||||
arguments: arguments,
|
|
||||||
configuration: configuration,
|
|
||||||
inventoryFilePath: inventoryPath,
|
|
||||||
playbookDirectory: playbookDir,
|
|
||||||
quiet: quietOnlyPlaybook ? true : basic.quiet,
|
|
||||||
shell: basic.shell
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import CliClient
|
|
||||||
import ConfigurationClient
|
|
||||||
|
|
||||||
extension VaultOptions {
|
|
||||||
|
|
||||||
func vaultOptions(
|
|
||||||
arguments: [String],
|
|
||||||
configuration: Configuration?
|
|
||||||
) -> CliClient.VaultOptions {
|
|
||||||
.init(
|
|
||||||
arguments: arguments,
|
|
||||||
configuration: configuration,
|
|
||||||
quiet: globals.quiet,
|
|
||||||
shell: globals.shell,
|
|
||||||
vaultFilePath: file
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
Sources/hpa/UtilsCommands/DumpConfigCommand.swift
Normal file
27
Sources/hpa/UtilsCommands/DumpConfigCommand.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliDoc
|
||||||
|
import ConfigurationClient
|
||||||
|
import CustomDump
|
||||||
|
import Dependencies
|
||||||
|
|
||||||
|
struct DumpConfigCommand: AsyncParsableCommand {
|
||||||
|
static let commandName = "dump-config"
|
||||||
|
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: commandName,
|
||||||
|
abstract: createAbstract("Show the current configuration."),
|
||||||
|
usage: .default(commandName: commandName, parentCommand: "utils", usesArguments: false, usesExtraArguments: false),
|
||||||
|
discussion: Discussion {
|
||||||
|
"Useful to debug your configuration settings / make sure they load properly."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
@Dependency(\.configurationClient) var configurationClient
|
||||||
|
|
||||||
|
let configuration = try await configurationClient.findAndLoad()
|
||||||
|
|
||||||
|
customDump(configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import CliClient
|
|
||||||
import CliDoc
|
import CliDoc
|
||||||
|
import CommandClient
|
||||||
import ConfigurationClient
|
import ConfigurationClient
|
||||||
import Dependencies
|
import Dependencies
|
||||||
|
|
||||||
@@ -16,14 +16,14 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
|
|||||||
Note {
|
Note {
|
||||||
"""
|
"""
|
||||||
If a directory is not supplied then a configuration file will be created
|
If a directory is not supplied then a configuration file will be created
|
||||||
at \("'~/.config/hpa-playbook/config'".yellow).
|
at \("'~/.config/hpa/config.toml'".yellow).
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
VStack {
|
VStack {
|
||||||
"EXAMPLE:".yellow.bold
|
"EXAMPLE:".yellow.bold
|
||||||
"Create a directory and generate the configuration".green
|
"Create a directory and generate the configuration".green
|
||||||
ShellCommand("mkdir -p ~/.config/hpa-playbook")
|
ShellCommand("mkdir -p ~/.config/hpa")
|
||||||
ShellCommand("hpa generate-config --path ~/.config/hpa-playbook")
|
ShellCommand("hpa generate-config --path ~/.config/hpa")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.separator(.newLine(count: 2))
|
.separator(.newLine(count: 2))
|
||||||
@@ -39,7 +39,7 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
|
|||||||
|
|
||||||
@Flag(
|
@Flag(
|
||||||
name: .shortAndLong,
|
name: .shortAndLong,
|
||||||
help: "Generate a json file, instead of default env style"
|
help: "Generate a json file, instead of the default toml style"
|
||||||
)
|
)
|
||||||
var json: Bool = false
|
var json: Bool = false
|
||||||
|
|
||||||
@@ -56,26 +56,18 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _run() async throws {
|
private func _run() async throws {
|
||||||
@Dependency(\.cliClient) var cliClient
|
|
||||||
@Dependency(\.configurationClient) var configurationClient
|
@Dependency(\.configurationClient) var configurationClient
|
||||||
|
|
||||||
try await cliClient.withLogger(globals.loggingOptions(commandName: Self.commandName)) {
|
try await globals.loggingOptions(commandName: Self.commandName).withLogger {
|
||||||
@Dependency(\.logger) var logger
|
@Dependency(\.logger) var logger
|
||||||
|
|
||||||
let actualPath: File
|
let output = try await configurationClient.generate(.init(
|
||||||
|
force: force,
|
||||||
|
json: json,
|
||||||
|
path: path != nil ? .directory(path!) : nil
|
||||||
|
))
|
||||||
|
|
||||||
if let path, let file = File("\(path)/config.\(json ? "json" : "toml")") {
|
print(output)
|
||||||
actualPath = file
|
|
||||||
} else {
|
|
||||||
actualPath = .default
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Generating config at path: \(actualPath.path)")
|
|
||||||
|
|
||||||
try await configurationClient.generate(
|
|
||||||
at: actualPath,
|
|
||||||
force: force
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import CliClient
|
|
||||||
import Dependencies
|
import Dependencies
|
||||||
|
import PlaybookClient
|
||||||
|
|
||||||
struct GenerateProjectTemplateCommand: AsyncParsableCommand {
|
struct GenerateProjectTemplateCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
@@ -34,29 +34,23 @@ struct GenerateProjectTemplateCommand: AsyncParsableCommand {
|
|||||||
var path: String
|
var path: 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 {
|
||||||
@Dependency(\.cliClient) var cliClient
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
var arguments = [
|
let output = try await playbookClient.run.generateTemplate(.init(
|
||||||
"--tags", "repo-template",
|
shared: globals.sharedPlaybookRunOptions(
|
||||||
"--extra-vars", "output_dir=\(path)"
|
commandName: Self.commandName,
|
||||||
]
|
extraOptions: extraOptions
|
||||||
|
),
|
||||||
|
templateDirectory: path,
|
||||||
|
templateVarsDirectory: templateVars,
|
||||||
|
useVault: !noVault
|
||||||
|
))
|
||||||
|
|
||||||
if let varsDir = templateVars {
|
print(output)
|
||||||
arguments.append(contentsOf: ["--extra-vars", "repo_vars_dir=\(varsDir)"])
|
|
||||||
}
|
|
||||||
|
|
||||||
if noVault {
|
|
||||||
arguments.append(contentsOf: ["--extra-vars", "use_vault=false"])
|
|
||||||
}
|
|
||||||
|
|
||||||
try await cliClient.runPlaybookCommand(
|
|
||||||
globals.playbookOptions(arguments: arguments, configuration: nil),
|
|
||||||
logging: globals.loggingOptions(commandName: Self.commandName)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
52
Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift
Normal file
52
Sources/hpa/UtilsCommands/InstallDependenciesCommand.swift
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import CliDoc
|
||||||
|
import CommandClient
|
||||||
|
import Dependencies
|
||||||
|
|
||||||
|
struct InstallDependenciesCommand: AsyncParsableCommand {
|
||||||
|
static let commandName: String = "install-dependencies"
|
||||||
|
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: commandName,
|
||||||
|
abstract: createAbstract("Ensure required dependencies are installed"),
|
||||||
|
usage: .default(commandName: commandName, parentCommand: "utils", usesArguments: false),
|
||||||
|
discussion: Discussion {
|
||||||
|
VStack {
|
||||||
|
Note {
|
||||||
|
"Homebrew is required to install dependencies."
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
"See Also:".yellow.bold.underline
|
||||||
|
"https://brew.sh"
|
||||||
|
}
|
||||||
|
ImportantNote.passingExtraArgs
|
||||||
|
}
|
||||||
|
.separator(.newLine(count: 2))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup var globals: BasicGlobalOptions
|
||||||
|
|
||||||
|
@Argument(
|
||||||
|
help: "Extra arguments / options to pass to the homebrew command."
|
||||||
|
)
|
||||||
|
var extraOptions: [String] = []
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
@Dependency(\.commandClient) var commandClient
|
||||||
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
|
let arguments = [
|
||||||
|
"brew", "install"
|
||||||
|
] + Constants.brewPackages
|
||||||
|
+ extraOptions
|
||||||
|
|
||||||
|
try await commandClient.run(
|
||||||
|
quiet: globals.quiet,
|
||||||
|
shell: globals.shell,
|
||||||
|
arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
try await playbookClient.repository.install()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,10 @@ struct UtilsCommand: AsyncParsableCommand {
|
|||||||
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: [
|
||||||
|
DumpConfigCommand.self,
|
||||||
GenerateProjectTemplateCommand.self,
|
GenerateProjectTemplateCommand.self,
|
||||||
GenerateConfigurationCommand.self
|
GenerateConfigurationCommand.self,
|
||||||
|
InstallDependenciesCommand.self
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import CliClient
|
|
||||||
import Dependencies
|
import Dependencies
|
||||||
|
import VaultClient
|
||||||
|
|
||||||
struct DecryptCommand: AsyncParsableCommand {
|
struct DecryptCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
@@ -11,32 +11,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?
|
||||||
|
|
||||||
// FIX:
|
@OptionGroup var options: VaultOptions
|
||||||
|
|
||||||
mutating func run() async throws {
|
mutating func run() async throws {
|
||||||
@Dependency(\.cliClient) var cliClient
|
@Dependency(\.vaultClient) var vaultClient
|
||||||
|
|
||||||
var args = ["decrypt"]
|
let output = try await vaultClient.run.decrypt(options.runOptions(
|
||||||
if let output {
|
commandName: Self.commandName,
|
||||||
args.append(contentsOf: ["--output", output])
|
outputFilePath: output
|
||||||
}
|
))
|
||||||
|
|
||||||
try await cliClient.runVaultCommand(
|
print(output)
|
||||||
options.vaultOptions(arguments: args, configuration: nil),
|
|
||||||
logging: options.loggingOptions(commandName: Self.commandName)
|
|
||||||
)
|
|
||||||
|
|
||||||
// try await runVault(
|
|
||||||
// commandName: Self.commandName,
|
|
||||||
// options: options,
|
|
||||||
// args
|
|
||||||
// )
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
Sources/hpa/VaultCommands/EditCommand.swift
Normal file
1
Sources/hpa/VaultCommands/EditCommand.swift
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import CliClient
|
|
||||||
import Dependencies
|
import Dependencies
|
||||||
|
import VaultClient
|
||||||
|
|
||||||
struct EncryptCommand: AsyncParsableCommand {
|
struct EncryptCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
@@ -11,31 +11,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?
|
||||||
|
|
||||||
// FIX:
|
@OptionGroup var options: VaultOptions
|
||||||
|
|
||||||
mutating func run() async throws {
|
mutating func run() async throws {
|
||||||
@Dependency(\.cliClient) var cliClient
|
@Dependency(\.vaultClient) var vaultClient
|
||||||
|
|
||||||
var args = ["encrypt"]
|
let output = try await vaultClient.run.encrypt(options.runOptions(
|
||||||
if let output {
|
commandName: Self.commandName,
|
||||||
args.append(contentsOf: ["--output", output])
|
outputFilePath: output
|
||||||
}
|
))
|
||||||
try await cliClient.runVaultCommand(
|
|
||||||
options.vaultOptions(arguments: args, configuration: nil),
|
|
||||||
logging: options.loggingOptions(commandName: Self.commandName)
|
|
||||||
)
|
|
||||||
|
|
||||||
// try await runVault(
|
print(output)
|
||||||
// commandName: Self.commandName,
|
|
||||||
// options: options,
|
|
||||||
// args
|
|
||||||
// )
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ struct VaultCommand: AsyncParsableCommand {
|
|||||||
.separator(.newLine(count: 2))
|
.separator(.newLine(count: 2))
|
||||||
},
|
},
|
||||||
subcommands: [
|
subcommands: [
|
||||||
EncryptCommand.self, DecryptCommand.self
|
DecryptCommand.self, EncryptCommand.self
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import CliClient
|
import CommandClient
|
||||||
|
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.
|
||||||
@@ -18,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] }
|
||||||
@@ -32,7 +33,21 @@ struct VaultOptions: ParsableArguments {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension VaultOptions {
|
extension VaultOptions {
|
||||||
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
|
|
||||||
globals.loggingOptions(commandName: commandName)
|
func runOptions(
|
||||||
|
commandName: String,
|
||||||
|
outputFilePath: String? = nil
|
||||||
|
) -> VaultClient.RunOptions {
|
||||||
|
.init(
|
||||||
|
extraOptions: extraOptions,
|
||||||
|
loggingOptions: .init(
|
||||||
|
commandName: commandName,
|
||||||
|
logLevel: .init(globals: globals, quietOnlyPlaybook: false)
|
||||||
|
),
|
||||||
|
outputFilePath: outputFilePath,
|
||||||
|
quiet: globals.quiet,
|
||||||
|
shell: globals.shell,
|
||||||
|
vaultFilePath: file
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,327 +0,0 @@
|
|||||||
@_spi(Internal) import CliClient
|
|
||||||
import ConfigurationClient
|
|
||||||
import Dependencies
|
|
||||||
import FileClient
|
|
||||||
import Foundation
|
|
||||||
import ShellClient
|
|
||||||
import Testing
|
|
||||||
import TestSupport
|
|
||||||
|
|
||||||
@Suite("CliClientTests")
|
|
||||||
struct CliClientTests: TestCase {
|
|
||||||
|
|
||||||
static let loggingOptions: CliClient.LoggingOptions = {
|
|
||||||
let levelString = ProcessInfo.processInfo.environment["LOGGING_LEVEL"] ?? "debug"
|
|
||||||
let logLevel = Logger.Level(rawValue: levelString) ?? .debug
|
|
||||||
return .init(commandName: "CliClientTests", logLevel: logLevel)
|
|
||||||
}()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func capturingClient() async throws {
|
|
||||||
let captured = CliClient.CapturingClient()
|
|
||||||
let client = CliClient.capturing(captured)
|
|
||||||
try await client.runCommand(quiet: false, shell: .zsh(), "foo", "bar")
|
|
||||||
|
|
||||||
let quiet = await captured.quiet!
|
|
||||||
#expect(quiet == false)
|
|
||||||
|
|
||||||
let shell = await captured.shell
|
|
||||||
#expect(shell == .zsh())
|
|
||||||
|
|
||||||
let arguments = await captured.arguments!
|
|
||||||
#expect(arguments == ["foo", "bar"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test(arguments: ["encrypt", "decrypt"])
|
|
||||||
func runVault(argument: String) async throws {
|
|
||||||
let captured = CliClient.CapturingClient()
|
|
||||||
try await withMockConfiguration(captured, key: "runVault") {
|
|
||||||
$0.fileClient.findVaultFileInCurrentDirectory = { URL(filePath: "vault.yml") }
|
|
||||||
} operation: {
|
|
||||||
@Dependency(\.cliClient) var cliClient
|
|
||||||
let configuration = Configuration.mock
|
|
||||||
|
|
||||||
try await cliClient.runVaultCommand(
|
|
||||||
.init(arguments: [argument], quiet: false, shell: nil),
|
|
||||||
logging: Self.loggingOptions
|
|
||||||
)
|
|
||||||
|
|
||||||
let shell = await captured.shell
|
|
||||||
#expect(shell == .zsh(useDashC: true))
|
|
||||||
|
|
||||||
let vaultPath = URL(filePath: #file)
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
.appending(path: "vault.yml")
|
|
||||||
|
|
||||||
var encryptArgs: [String] = []
|
|
||||||
if argument == "encrypt", let id = configuration.vault.encryptId {
|
|
||||||
encryptArgs = ["--encrypt-vault-id", id]
|
|
||||||
}
|
|
||||||
|
|
||||||
let expectedArguments = [
|
|
||||||
"ansible-vault", argument
|
|
||||||
] + configuration.vault.args!
|
|
||||||
+ encryptArgs
|
|
||||||
+ [vaultPath.cleanFilePath]
|
|
||||||
|
|
||||||
let arguments = await captured.arguments
|
|
||||||
#expect(arguments == expectedArguments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test(arguments: [
|
|
||||||
Configuration(
|
|
||||||
args: ["--tags", "debug"],
|
|
||||||
useVaultArgs: true,
|
|
||||||
playbook: .init(directory: "playbook", inventory: nil),
|
|
||||||
vault: .mock
|
|
||||||
)
|
|
||||||
])
|
|
||||||
func runPlaybook(configuration: Configuration) async throws {
|
|
||||||
let captured = CliClient.CapturingClient()
|
|
||||||
try await withMockConfiguration(captured, configuration: configuration, key: "runPlaybook") {
|
|
||||||
@Dependency(\.cliClient) var cliClient
|
|
||||||
|
|
||||||
try await cliClient.runPlaybookCommand(
|
|
||||||
.init(
|
|
||||||
arguments: [],
|
|
||||||
quiet: false,
|
|
||||||
shell: nil
|
|
||||||
),
|
|
||||||
logging: Self.loggingOptions
|
|
||||||
)
|
|
||||||
|
|
||||||
let expectedArguments = [
|
|
||||||
"ansible-playbook", "playbook/main.yml",
|
|
||||||
"--inventory", "playbook/inventory.ini",
|
|
||||||
"--tags", "debug",
|
|
||||||
"--vault-id=myId@$SCRIPTS/vault-gopass-client"
|
|
||||||
]
|
|
||||||
|
|
||||||
let arguments = await captured.arguments
|
|
||||||
#expect(arguments == expectedArguments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func ensuredPlaybookDirectory() throws {
|
|
||||||
let configuration = Configuration.mock
|
|
||||||
let playbookDir = try configuration.ensuredPlaybookDirectory("playbook")
|
|
||||||
#expect(playbookDir == "playbook")
|
|
||||||
|
|
||||||
do {
|
|
||||||
_ = try configuration.ensuredPlaybookDirectory(nil)
|
|
||||||
#expect(Bool(false))
|
|
||||||
} catch {
|
|
||||||
#expect(Bool(true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func shellOrDefault() {
|
|
||||||
var shell: String? = "/bin/bash"
|
|
||||||
#expect(shell.orDefault == .custom(path: "/bin/bash", useDashC: true))
|
|
||||||
|
|
||||||
shell = nil
|
|
||||||
#expect(shell.orDefault == .zsh(useDashC: true))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func testEnsuredInventoryPath() {
|
|
||||||
let configuration = Configuration(playbook: .init(inventory: "inventory.ini"))
|
|
||||||
let playbookDir = "playbook"
|
|
||||||
let inventoryPath = "inventory.ini"
|
|
||||||
|
|
||||||
var output = ensuredInventoryPath(
|
|
||||||
inventoryPath,
|
|
||||||
configuration: configuration,
|
|
||||||
playbookDirectory: playbookDir
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(output == "inventory.ini")
|
|
||||||
|
|
||||||
output = ensuredInventoryPath(
|
|
||||||
nil,
|
|
||||||
configuration: configuration,
|
|
||||||
playbookDirectory: playbookDir
|
|
||||||
)
|
|
||||||
#expect(output == "inventory.ini")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func vaultFilePath() async throws {
|
|
||||||
var fileClient = FileClient.testValue
|
|
||||||
fileClient.findVaultFileInCurrentDirectory = { URL(string: "vault.yml") }
|
|
||||||
|
|
||||||
var output = try await fileClient.ensuredVaultFilePath("vault.yml")
|
|
||||||
#expect(output == "vault.yml")
|
|
||||||
|
|
||||||
output = try await fileClient.ensuredVaultFilePath(nil)
|
|
||||||
|
|
||||||
fileClient.findVaultFileInCurrentDirectory = { nil }
|
|
||||||
do {
|
|
||||||
_ = try await fileClient.ensuredVaultFilePath(nil)
|
|
||||||
#expect(Bool(false))
|
|
||||||
} catch {
|
|
||||||
#expect(Bool(true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test(
|
|
||||||
arguments: [
|
|
||||||
GenerateJsonTestOption(
|
|
||||||
options: .init(
|
|
||||||
templateDirectory: nil,
|
|
||||||
templateRepo: nil,
|
|
||||||
version: nil,
|
|
||||||
useLocalTemplateDirectory: true
|
|
||||||
),
|
|
||||||
configuration: nil,
|
|
||||||
expectation: .failing
|
|
||||||
),
|
|
||||||
GenerateJsonTestOption(
|
|
||||||
options: .init(
|
|
||||||
templateDirectory: nil,
|
|
||||||
templateRepo: nil,
|
|
||||||
version: nil,
|
|
||||||
useLocalTemplateDirectory: false
|
|
||||||
),
|
|
||||||
configuration: nil,
|
|
||||||
expectation: .failing
|
|
||||||
),
|
|
||||||
GenerateJsonTestOption(
|
|
||||||
options: .init(
|
|
||||||
templateDirectory: "template",
|
|
||||||
templateRepo: nil,
|
|
||||||
version: nil,
|
|
||||||
useLocalTemplateDirectory: true
|
|
||||||
),
|
|
||||||
configuration: nil,
|
|
||||||
expectation: .success("""
|
|
||||||
{
|
|
||||||
"template" : {
|
|
||||||
"path" : "template"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
),
|
|
||||||
GenerateJsonTestOption(
|
|
||||||
options: .init(
|
|
||||||
templateDirectory: nil,
|
|
||||||
templateRepo: nil,
|
|
||||||
version: nil,
|
|
||||||
useLocalTemplateDirectory: false
|
|
||||||
),
|
|
||||||
configuration: .init(template: .init(directory: "template")),
|
|
||||||
expectation: .success("""
|
|
||||||
{
|
|
||||||
"template" : {
|
|
||||||
"path" : "template"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
),
|
|
||||||
GenerateJsonTestOption(
|
|
||||||
options: .init(
|
|
||||||
templateDirectory: nil,
|
|
||||||
templateRepo: "https://git.example.com/template.git",
|
|
||||||
version: nil,
|
|
||||||
useLocalTemplateDirectory: false
|
|
||||||
),
|
|
||||||
configuration: nil,
|
|
||||||
expectation: .success("""
|
|
||||||
{
|
|
||||||
"template" : {
|
|
||||||
"repo" : "https://git.example.com/template.git",
|
|
||||||
"version" : "main"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
),
|
|
||||||
GenerateJsonTestOption(
|
|
||||||
options: .init(
|
|
||||||
templateDirectory: nil,
|
|
||||||
templateRepo: nil,
|
|
||||||
version: nil,
|
|
||||||
useLocalTemplateDirectory: false
|
|
||||||
),
|
|
||||||
configuration: .init(template: .init(url: "https://git.example.com/template.git", version: "v0.1.0")),
|
|
||||||
expectation: .success("""
|
|
||||||
{
|
|
||||||
"template" : {
|
|
||||||
"repo" : "https://git.example.com/template.git",
|
|
||||||
"version" : "v0.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
func generateJson(input: GenerateJsonTestOption) async {
|
|
||||||
await withTestLogger(key: "generateJson") {
|
|
||||||
$0.configurationClient = .mock(input.configuration ?? .init())
|
|
||||||
$0.cliClient = .liveValue
|
|
||||||
} operation: {
|
|
||||||
@Dependency(\.cliClient) var cliClient
|
|
||||||
|
|
||||||
let json = try? await cliClient.generateJSON(
|
|
||||||
input.options,
|
|
||||||
logging: Self.loggingOptions,
|
|
||||||
encoder: jsonEncoder
|
|
||||||
)
|
|
||||||
|
|
||||||
switch input.expectation {
|
|
||||||
case let .success(expected):
|
|
||||||
#expect(json == expected)
|
|
||||||
case .failing:
|
|
||||||
#expect(json == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func withMockConfiguration(
|
|
||||||
_ capturing: CliClient.CapturingClient,
|
|
||||||
configuration: Configuration = .mock,
|
|
||||||
key: String,
|
|
||||||
logLevel: Logger.Level = .trace,
|
|
||||||
depednencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
|
|
||||||
operation: @Sendable @escaping () async throws -> Void
|
|
||||||
) async rethrows {
|
|
||||||
try await withTestLogger(key: key, logLevel: logLevel) {
|
|
||||||
$0.configurationClient = .mock(configuration)
|
|
||||||
$0.cliClient = .capturing(capturing)
|
|
||||||
setupDependencies(&$0)
|
|
||||||
} operation: {
|
|
||||||
try await operation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GenerateJsonTestOption: Sendable {
|
|
||||||
let options: CliClient.GenerateJsonOptions
|
|
||||||
let configuration: Configuration?
|
|
||||||
let expectation: GenerateJsonExpectation
|
|
||||||
}
|
|
||||||
|
|
||||||
enum GenerateJsonExpectation: Sendable {
|
|
||||||
case failing
|
|
||||||
case success(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ConfigurationClient {
|
|
||||||
static func mock(_ configuration: Configuration) -> Self {
|
|
||||||
var mock = Self.testValue
|
|
||||||
mock.find = { throw TestError() }
|
|
||||||
mock.load = { _ in configuration }
|
|
||||||
return mock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TestError: Error {}
|
|
||||||
|
|
||||||
let jsonEncoder: JSONEncoder = {
|
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys]
|
|
||||||
return encoder
|
|
||||||
}()
|
|
||||||
@@ -18,6 +18,7 @@ struct ConfigurationClientTests: TestCase {
|
|||||||
@Test(arguments: ["config.toml", "config.json"])
|
@Test(arguments: ["config.toml", "config.json"])
|
||||||
func generateConfigFile(fileName: String) async throws {
|
func generateConfigFile(fileName: String) async throws {
|
||||||
try await withTestLogger(key: "generateConfigFile") {
|
try await withTestLogger(key: "generateConfigFile") {
|
||||||
|
$0.coders = .liveValue
|
||||||
$0.fileClient = .liveValue
|
$0.fileClient = .liveValue
|
||||||
} operation: {
|
} operation: {
|
||||||
@Dependency(\.logger) var logger
|
@Dependency(\.logger) var logger
|
||||||
@@ -26,13 +27,22 @@ struct ConfigurationClientTests: TestCase {
|
|||||||
|
|
||||||
try await withTemporaryDirectory { tempDir in
|
try await withTemporaryDirectory { tempDir in
|
||||||
let tempFile = tempDir.appending(path: fileName)
|
let tempFile = tempDir.appending(path: fileName)
|
||||||
try await configuration.generate(at: File(tempFile)!, force: false)
|
let output = try await configuration.generate(.init(
|
||||||
|
force: false,
|
||||||
|
json: fileName.hasSuffix("json"),
|
||||||
|
path: .file(File(tempFile)!)
|
||||||
|
))
|
||||||
#expect(FileManager.default.fileExists(atPath: tempFile.cleanFilePath))
|
#expect(FileManager.default.fileExists(atPath: tempFile.cleanFilePath))
|
||||||
#expect(fileClient.fileExists(tempFile))
|
#expect(fileClient.fileExists(tempFile))
|
||||||
|
#expect(output == tempFile.cleanFilePath)
|
||||||
|
|
||||||
// Ensure that we do not overwrite files if they exist.
|
// Ensure that we do not overwrite files if they exist.
|
||||||
do {
|
do {
|
||||||
try await configuration.generate(at: File(tempFile)!, force: false)
|
_ = try await configuration.generate(.init(
|
||||||
|
force: false,
|
||||||
|
json: fileName.hasSuffix("json"),
|
||||||
|
path: .file(File(tempFile)!)
|
||||||
|
))
|
||||||
#expect(Bool(false))
|
#expect(Bool(false))
|
||||||
} catch {
|
} catch {
|
||||||
#expect(Bool(true))
|
#expect(Bool(true))
|
||||||
@@ -44,6 +54,7 @@ struct ConfigurationClientTests: TestCase {
|
|||||||
@Test(arguments: ["config.toml", "config.json", nil])
|
@Test(arguments: ["config.toml", "config.json", nil])
|
||||||
func loadConfigFile(fileName: String?) async throws {
|
func loadConfigFile(fileName: String?) async throws {
|
||||||
try await withTestLogger(key: "generateConfigFile") {
|
try await withTestLogger(key: "generateConfigFile") {
|
||||||
|
$0.coders = .liveValue
|
||||||
$0.fileClient = .liveValue
|
$0.fileClient = .liveValue
|
||||||
} operation: {
|
} operation: {
|
||||||
@Dependency(\.logger) var logger
|
@Dependency(\.logger) var logger
|
||||||
@@ -130,6 +141,27 @@ struct ConfigurationClientTests: TestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func writeCreatesBackupFile() async throws {
|
||||||
|
try await withDependencies {
|
||||||
|
$0.fileClient = .liveValue
|
||||||
|
} operation: {
|
||||||
|
let client = ConfigurationClient.liveValue
|
||||||
|
|
||||||
|
try await withGeneratedConfigFile(named: "config.toml", client: client) { configFile in
|
||||||
|
@Dependency(\.fileClient) var fileClient
|
||||||
|
|
||||||
|
let backupUrl = configFile.url.appendingPathExtension(".back")
|
||||||
|
#expect(fileClient.fileExists(backupUrl) == false)
|
||||||
|
|
||||||
|
let config = Configuration()
|
||||||
|
try await client.write(config, to: configFile)
|
||||||
|
|
||||||
|
#expect(fileClient.fileExists(backupUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateFindEnvironments(file: File) -> [[String: String]] {
|
func generateFindEnvironments(file: File) -> [[String: String]] {
|
||||||
@@ -143,23 +175,6 @@ func generateFindEnvironments(file: File) -> [[String: String]] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// swiftlint:disable force_try
|
|
||||||
func withTemporaryDirectory(
|
|
||||||
_ operation: @Sendable (URL) async throws -> Void
|
|
||||||
) async rethrows {
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
|
||||||
let tempDir = dir.appending(path: UUID().uuidString)
|
|
||||||
|
|
||||||
try! FileManager.default.createDirectory(
|
|
||||||
atPath: tempDir.cleanFilePath,
|
|
||||||
withIntermediateDirectories: false
|
|
||||||
)
|
|
||||||
try await operation(tempDir)
|
|
||||||
try! FileManager.default.removeItem(at: tempDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// swiftlint:enable force_try
|
|
||||||
|
|
||||||
func withGeneratedConfigFile(
|
func withGeneratedConfigFile(
|
||||||
named fileName: String,
|
named fileName: String,
|
||||||
client: ConfigurationClient,
|
client: ConfigurationClient,
|
||||||
@@ -167,7 +182,11 @@ func withGeneratedConfigFile(
|
|||||||
) async rethrows {
|
) async rethrows {
|
||||||
try await withTemporaryDirectory { tempDir in
|
try await withTemporaryDirectory { tempDir in
|
||||||
let file = File(tempDir.appending(path: fileName))!
|
let file = File(tempDir.appending(path: fileName))!
|
||||||
try await client.generate(at: file)
|
_ = try await client.generate(.init(
|
||||||
|
force: true,
|
||||||
|
json: fileName.hasSuffix("json"),
|
||||||
|
path: .file(file)
|
||||||
|
))
|
||||||
try await operation(file)
|
try await operation(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +203,11 @@ func withGeneratedXDGConfigFile(
|
|||||||
withIntermediateDirectories: false
|
withIntermediateDirectories: false
|
||||||
)
|
)
|
||||||
let file = File(xdgDir.appending(path: fileName))!
|
let file = File(xdgDir.appending(path: fileName))!
|
||||||
try await client.generate(at: file)
|
_ = try await client.generate(.init(
|
||||||
|
force: true,
|
||||||
|
json: fileName.hasSuffix("json"),
|
||||||
|
path: .file(file)
|
||||||
|
))
|
||||||
try await operation(file, tempDir)
|
try await operation(file, tempDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
Tests/FileClientTests/FileClientTests.swift
Normal file
67
Tests/FileClientTests/FileClientTests.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import FileClient
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import TestSupport
|
||||||
|
|
||||||
|
@Suite("FileClientTests")
|
||||||
|
struct FileClientTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func createDirectory() async throws {
|
||||||
|
try await withTemporaryDirectory { url in
|
||||||
|
let fileClient = FileClient.liveValue
|
||||||
|
let tempDir = url.appending(path: "temp")
|
||||||
|
try await fileClient.createDirectory(tempDir)
|
||||||
|
let isDirectory = try await fileClient.isDirectory(tempDir)
|
||||||
|
#expect(isDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: ["vault.yml", "vault.yaml"])
|
||||||
|
func findVaultFile(fileName: String) async throws {
|
||||||
|
try await withTemporaryDirectory { url in
|
||||||
|
let fileClient = FileClient.liveValue
|
||||||
|
|
||||||
|
let vaultFilePath = url.appending(path: fileName)
|
||||||
|
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
|
||||||
|
let output = try await fileClient.findVaultFile(url)!
|
||||||
|
|
||||||
|
#expect(output.cleanFilePath == vaultFilePath.cleanFilePath)
|
||||||
|
|
||||||
|
let nilWhenFileNotDirectory = try await fileClient.findVaultFile(vaultFilePath)
|
||||||
|
#expect(nilWhenFileNotDirectory == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: ["vault.yml", "vault.yaml"])
|
||||||
|
func findVaultFileNestedInSubfolder(fileName: String) async throws {
|
||||||
|
try await withTemporaryDirectory { url in
|
||||||
|
|
||||||
|
let fileClient = FileClient.liveValue
|
||||||
|
let subDir = url.appending(path: "sub")
|
||||||
|
|
||||||
|
try await fileClient.createDirectory(subDir)
|
||||||
|
|
||||||
|
let vaultFilePath = subDir.appending(path: fileName)
|
||||||
|
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
|
||||||
|
let output = try await fileClient.findVaultFile(url)!
|
||||||
|
|
||||||
|
#expect(output.cleanFilePath == vaultFilePath.cleanFilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func findVaultFileReturnsNil() async throws {
|
||||||
|
try await withTemporaryDirectory { url in
|
||||||
|
|
||||||
|
let fileClient = FileClient.liveValue
|
||||||
|
let subDir = url.appending(path: "sub")
|
||||||
|
|
||||||
|
try await fileClient.createDirectory(subDir)
|
||||||
|
|
||||||
|
let output = try await fileClient.findVaultFile(url)
|
||||||
|
|
||||||
|
#expect(output == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
333
Tests/PandocClientTests/PandocClientTests.swift
Normal file
333
Tests/PandocClientTests/PandocClientTests.swift
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
@_spi(Internal) import ConfigurationClient
|
||||||
|
@_spi(Internal) import PandocClient
|
||||||
|
import PlaybookClient
|
||||||
|
import Testing
|
||||||
|
import TestSupport
|
||||||
|
|
||||||
|
@Suite("PandocClientTests")
|
||||||
|
struct PandocClientTests: TestCase {
|
||||||
|
|
||||||
|
static let outputDirectory = "/output"
|
||||||
|
static let projectDirectory = "/project"
|
||||||
|
static let defaultFileName = "Report"
|
||||||
|
|
||||||
|
static let expectedIncludeInHeaders = [
|
||||||
|
"--include-in-header=/project/.build/head.tex",
|
||||||
|
"--include-in-header=/project/.build/footer.tex"
|
||||||
|
]
|
||||||
|
|
||||||
|
static let expectedFiles = [
|
||||||
|
"/project/.build/Report.md",
|
||||||
|
"/project/.build/Definitions.md"
|
||||||
|
]
|
||||||
|
|
||||||
|
static var sharedRunOptions: PandocClient.RunOptions {
|
||||||
|
.init(
|
||||||
|
buildDirectory: nil,
|
||||||
|
files: nil,
|
||||||
|
loggingOptions: loggingOptions,
|
||||||
|
includeInHeader: nil,
|
||||||
|
outputDirectory: outputDirectory,
|
||||||
|
projectDirectory: projectDirectory,
|
||||||
|
outputFileName: nil,
|
||||||
|
quiet: false,
|
||||||
|
shell: nil,
|
||||||
|
shouldBuild: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func generateLatex() async throws {
|
||||||
|
try await withCapturingCommandClient("generateLatex") {
|
||||||
|
$0.configurationClient = .mock()
|
||||||
|
$0.playbookClient.run.buildProject = { _ in }
|
||||||
|
$0.pandocClient = .liveValue
|
||||||
|
} run: {
|
||||||
|
@Dependency(\.pandocClient) var pandocClient
|
||||||
|
|
||||||
|
let output = try await pandocClient.run.generateLatex(Self.sharedRunOptions)
|
||||||
|
#expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).tex")
|
||||||
|
|
||||||
|
} assert: { output in
|
||||||
|
let expected = ["pandoc"]
|
||||||
|
+ Self.expectedIncludeInHeaders
|
||||||
|
+ ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).tex"]
|
||||||
|
+ Self.expectedFiles
|
||||||
|
|
||||||
|
#expect(output.arguments == expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func generateHtml() async throws {
|
||||||
|
try await withCapturingCommandClient("generateHtml") {
|
||||||
|
$0.configurationClient = .mock()
|
||||||
|
$0.playbookClient.run.buildProject = { _ in }
|
||||||
|
$0.pandocClient = .liveValue
|
||||||
|
} run: {
|
||||||
|
@Dependency(\.pandocClient) var pandocClient
|
||||||
|
|
||||||
|
let output = try await pandocClient.run.generateHtml(Self.sharedRunOptions)
|
||||||
|
#expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).html")
|
||||||
|
|
||||||
|
} assert: { output in
|
||||||
|
let expected = ["pandoc"]
|
||||||
|
+ Self.expectedIncludeInHeaders
|
||||||
|
+ ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).html"]
|
||||||
|
+ Self.expectedFiles
|
||||||
|
|
||||||
|
#expect(output.arguments == expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(
|
||||||
|
arguments: [
|
||||||
|
nil,
|
||||||
|
"lualatex"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
func generatePdf(pdfEngine: String?) async throws {
|
||||||
|
try await withCapturingCommandClient("generatePdf") {
|
||||||
|
$0.configurationClient = .mock()
|
||||||
|
$0.playbookClient.run.buildProject = { _ in }
|
||||||
|
$0.pandocClient = .liveValue
|
||||||
|
} run: {
|
||||||
|
@Dependency(\.pandocClient) var pandocClient
|
||||||
|
|
||||||
|
let output = try await pandocClient.run.generatePdf(Self.sharedRunOptions, pdfEngine: pdfEngine)
|
||||||
|
#expect(output == "\(Self.outputDirectory)/\(Self.defaultFileName).pdf")
|
||||||
|
|
||||||
|
} assert: { output in
|
||||||
|
let expected = ["pandoc"]
|
||||||
|
+ Self.expectedIncludeInHeaders
|
||||||
|
+ ["--pdf-engine=\(pdfEngine ?? "xelatex")"]
|
||||||
|
+ ["--output=\(Self.outputDirectory)/\(Self.defaultFileName).pdf"]
|
||||||
|
+ Self.expectedFiles
|
||||||
|
|
||||||
|
#expect(output.arguments == expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: TestPdfEngine.testCases)
|
||||||
|
func parsePdfEngine(input: TestPdfEngine) {
|
||||||
|
#expect(input.engine == input.expectedEngine)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: TestParseFiles.testCases)
|
||||||
|
func parseFiles(input: TestParseFiles) {
|
||||||
|
#expect(input.parsedFiles == input.expectedFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: TestParseIncludeInHeaderFiles.testCases)
|
||||||
|
func parseInclueInHeaderFiles(input: TestParseIncludeInHeaderFiles) {
|
||||||
|
#expect(input.parsedFiles == input.expectedHeaderFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: TestParseOutputFileName.testCases)
|
||||||
|
func parseOutputFileName(input: TestParseOutputFileName) {
|
||||||
|
#expect(input.parsedFileName == input.expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: TestParseBuildDirectory.testCases)
|
||||||
|
func parseBuildDirectory(input: TestParseBuildDirectory) {
|
||||||
|
#expect(input.parsedBuildDirectory == input.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestPdfEngine: Sendable {
|
||||||
|
let fileType: PandocClient.FileType
|
||||||
|
let expectedEngine: String?
|
||||||
|
let configuration: Configuration
|
||||||
|
let defaults: Configuration.Generate
|
||||||
|
|
||||||
|
var engine: String? {
|
||||||
|
fileType.parsePdfEngine(configuration.generate, defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let testCases: [Self] = [
|
||||||
|
.init(fileType: .html, expectedEngine: nil, configuration: .init(), defaults: .default),
|
||||||
|
.init(fileType: .latex, expectedEngine: nil, configuration: .init(), defaults: .default),
|
||||||
|
.init(fileType: .pdf(engine: "lualatex"), expectedEngine: "lualatex", configuration: .init(), defaults: .default),
|
||||||
|
.init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .default),
|
||||||
|
.init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(), defaults: .init()),
|
||||||
|
.init(fileType: .pdf(engine: nil), expectedEngine: "xelatex", configuration: .init(generate: .default), defaults: .init())
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestParseFiles: Sendable {
|
||||||
|
|
||||||
|
let expectedFiles: [String]
|
||||||
|
let configuration: Configuration
|
||||||
|
let defaults: Configuration.Generate
|
||||||
|
let runOptions: PandocClient.RunOptions?
|
||||||
|
|
||||||
|
init(
|
||||||
|
expectedFiles: [String],
|
||||||
|
configuration: Configuration = .init(),
|
||||||
|
defaults: Configuration.Generate = .default,
|
||||||
|
runOptions: PandocClient.RunOptions? = nil
|
||||||
|
) {
|
||||||
|
self.expectedFiles = expectedFiles
|
||||||
|
self.configuration = configuration
|
||||||
|
self.defaults = defaults
|
||||||
|
self.runOptions = runOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsedFiles: [String] {
|
||||||
|
let runOptions = self.runOptions ?? PandocClient.RunOptions(
|
||||||
|
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
|
||||||
|
projectDirectory: nil,
|
||||||
|
quiet: true,
|
||||||
|
shouldBuild: false
|
||||||
|
)
|
||||||
|
|
||||||
|
return runOptions.parseFiles(configuration.generate, defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let testCases: [Self] = [
|
||||||
|
.init(expectedFiles: ["Report.md", "Definitions.md"]),
|
||||||
|
.init(expectedFiles: ["Report.md", "Definitions.md"], configuration: .init(generate: .default), defaults: .init()),
|
||||||
|
.init(expectedFiles: [], defaults: .init()),
|
||||||
|
.init(
|
||||||
|
expectedFiles: ["custom.md"],
|
||||||
|
configuration: .init(),
|
||||||
|
defaults: .init(),
|
||||||
|
runOptions: .init(
|
||||||
|
files: ["custom.md"],
|
||||||
|
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
|
||||||
|
projectDirectory: nil,
|
||||||
|
quiet: true,
|
||||||
|
shouldBuild: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestParseIncludeInHeaderFiles: Sendable {
|
||||||
|
|
||||||
|
let expectedHeaderFiles: [String]
|
||||||
|
let configuration: Configuration
|
||||||
|
let defaults: Configuration.Generate
|
||||||
|
let runOptions: PandocClient.RunOptions?
|
||||||
|
|
||||||
|
init(
|
||||||
|
expectedHeaderFiles: [String],
|
||||||
|
configuration: Configuration = .init(),
|
||||||
|
defaults: Configuration.Generate = .default,
|
||||||
|
runOptions: PandocClient.RunOptions? = nil
|
||||||
|
) {
|
||||||
|
self.expectedHeaderFiles = expectedHeaderFiles
|
||||||
|
self.configuration = configuration
|
||||||
|
self.defaults = defaults
|
||||||
|
self.runOptions = runOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsedFiles: [String] {
|
||||||
|
let runOptions = self.runOptions ?? PandocClient.RunOptions(
|
||||||
|
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
|
||||||
|
)
|
||||||
|
|
||||||
|
return runOptions.parseIncludeInHeader(configuration.generate, defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let testCases: [Self] = [
|
||||||
|
.init(expectedHeaderFiles: ["head.tex", "footer.tex"]),
|
||||||
|
.init(expectedHeaderFiles: ["head.tex", "footer.tex"], configuration: .init(generate: .default), defaults: .init()),
|
||||||
|
.init(expectedHeaderFiles: [], defaults: .init()),
|
||||||
|
.init(
|
||||||
|
expectedHeaderFiles: ["custom.tex"],
|
||||||
|
configuration: .init(),
|
||||||
|
defaults: .init(),
|
||||||
|
runOptions: .init(
|
||||||
|
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
|
||||||
|
includeInHeader: ["custom.tex"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestParseOutputFileName: Sendable {
|
||||||
|
|
||||||
|
let expected: String
|
||||||
|
let configuration: Configuration
|
||||||
|
let defaults: Configuration.Generate
|
||||||
|
let runOptions: PandocClient.RunOptions?
|
||||||
|
|
||||||
|
init(
|
||||||
|
expected: String,
|
||||||
|
configuration: Configuration = .init(),
|
||||||
|
defaults: Configuration.Generate = .default,
|
||||||
|
runOptions: PandocClient.RunOptions? = nil
|
||||||
|
) {
|
||||||
|
self.expected = expected
|
||||||
|
self.configuration = configuration
|
||||||
|
self.defaults = defaults
|
||||||
|
self.runOptions = runOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsedFileName: String {
|
||||||
|
let runOptions = self.runOptions ?? PandocClient.RunOptions(
|
||||||
|
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
|
||||||
|
)
|
||||||
|
|
||||||
|
return runOptions.parseOutputFileName(configuration.generate, defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let testCases: [Self] = [
|
||||||
|
.init(expected: "Report"),
|
||||||
|
.init(expected: "Report", configuration: .init(generate: .default), defaults: .init()),
|
||||||
|
.init(expected: "Report", defaults: .init()),
|
||||||
|
.init(
|
||||||
|
expected: "custom",
|
||||||
|
configuration: .init(),
|
||||||
|
defaults: .init(),
|
||||||
|
runOptions: .init(
|
||||||
|
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug),
|
||||||
|
outputFileName: "custom"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestParseBuildDirectory: Sendable {
|
||||||
|
|
||||||
|
let expected: String
|
||||||
|
let configuration: Configuration
|
||||||
|
let defaults: Configuration.Generate
|
||||||
|
let runOptions: PandocClient.RunOptions?
|
||||||
|
|
||||||
|
init(
|
||||||
|
expected: String = ".build",
|
||||||
|
configuration: Configuration = .init(),
|
||||||
|
defaults: Configuration.Generate = .default,
|
||||||
|
runOptions: PandocClient.RunOptions? = nil
|
||||||
|
) {
|
||||||
|
self.expected = expected
|
||||||
|
self.configuration = configuration
|
||||||
|
self.defaults = defaults
|
||||||
|
self.runOptions = runOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsedBuildDirectory: String {
|
||||||
|
let runOptions = self.runOptions ?? PandocClient.RunOptions(
|
||||||
|
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
|
||||||
|
)
|
||||||
|
|
||||||
|
return runOptions.parseBuildDirectory(configuration.generate, defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let testCases: [Self] = [
|
||||||
|
.init(),
|
||||||
|
.init(configuration: .init(generate: .default), defaults: .init()),
|
||||||
|
.init(defaults: .init()),
|
||||||
|
.init(
|
||||||
|
expected: "custom",
|
||||||
|
configuration: .init(),
|
||||||
|
defaults: .init(),
|
||||||
|
runOptions: .init(
|
||||||
|
buildDirectory: "custom",
|
||||||
|
loggingOptions: .init(commandName: "parseFiles", logLevel: .debug)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
308
Tests/PlaybookClientTests/PlaybookClientTests.swift
Normal file
308
Tests/PlaybookClientTests/PlaybookClientTests.swift
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import CodersClient
|
||||||
|
@_spi(Internal) import CommandClient
|
||||||
|
@_spi(Internal) import ConfigurationClient
|
||||||
|
import Dependencies
|
||||||
|
import FileClient
|
||||||
|
import Foundation
|
||||||
|
@_spi(Internal) import PlaybookClient
|
||||||
|
import ShellClient
|
||||||
|
import Testing
|
||||||
|
import TestSupport
|
||||||
|
|
||||||
|
@Suite("PlaybookClientTests")
|
||||||
|
struct PlaybookClientTests: TestCase {
|
||||||
|
|
||||||
|
static var sharedRunOptions: PlaybookClient.RunPlaybook.SharedRunOptions {
|
||||||
|
.init(loggingOptions: loggingOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let defaultPlaybookPath = "~/.local/share/hpa/playbook/main.yml"
|
||||||
|
static let defaultInventoryPath = "~/.local/share/hpa/playbook/inventory.ini"
|
||||||
|
static let mockVaultArg = Configuration.mock.vault.args![0]
|
||||||
|
|
||||||
|
@Test(.tags(.repository))
|
||||||
|
func repositoryInstallation() async throws {
|
||||||
|
try await withTestLogger(key: "repositoryInstallation") {
|
||||||
|
$0.fileClient = .liveValue
|
||||||
|
$0.asyncShellClient = .liveValue
|
||||||
|
$0.commandClient = .liveValue
|
||||||
|
} operation: {
|
||||||
|
try await withTemporaryDirectory { tempDirectory in
|
||||||
|
@Dependency(\.fileClient) var fileClient
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
let pathUrl = tempDirectory.appending(path: "playbook")
|
||||||
|
let playbookClient = PlaybookClient.liveValue
|
||||||
|
|
||||||
|
let configuration = Configuration(playbook: .init(directory: pathUrl.cleanFilePath))
|
||||||
|
|
||||||
|
try? FileManager.default.removeItem(at: pathUrl)
|
||||||
|
try await playbookClient.repository.install(configuration)
|
||||||
|
logger.debug("Done cloning playbook")
|
||||||
|
let exists = try await fileClient.isDirectory(pathUrl)
|
||||||
|
#expect(exists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(
|
||||||
|
.tags(.repository),
|
||||||
|
arguments: [
|
||||||
|
(Configuration(), PlaybookClient.Constants.defaultInstallationPath),
|
||||||
|
(Configuration(playbook: .init(directory: "playbook")), "playbook")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
func repositoryDirectory(configuration: Configuration, expected: String) async throws {
|
||||||
|
let client = PlaybookClient.liveValue
|
||||||
|
let result = try await client.repository.directory(configuration)
|
||||||
|
#expect(result == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(.tags(.run))
|
||||||
|
func runBuildProject() async throws {
|
||||||
|
let captured = CommandClient.CapturingClient()
|
||||||
|
|
||||||
|
try await withMockConfiguration(captured, key: "runBuildProject") {
|
||||||
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
|
try await playbookClient.run.buildProject(.init(projectDirectory: "/foo", shared: Self.sharedRunOptions))
|
||||||
|
|
||||||
|
let arguments = await captured.options!.arguments
|
||||||
|
print(arguments)
|
||||||
|
|
||||||
|
#expect(arguments == [
|
||||||
|
"ansible-playbook", Self.defaultPlaybookPath,
|
||||||
|
"--inventory", Self.defaultInventoryPath,
|
||||||
|
Self.mockVaultArg,
|
||||||
|
"--tags", "build-project",
|
||||||
|
"--extra-vars", "project_dir=/foo"
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(
|
||||||
|
.tags(.run),
|
||||||
|
arguments: [
|
||||||
|
(true, "\'{\"template\":{\"path\":\"\(Configuration.mock.template.directory!)\"}}\'"),
|
||||||
|
(false, "\'{\"template\":{\"repo\":{\"url\":\"\(Configuration.mock.template.url!)\",\"version\":\"\(Configuration.mock.template.version!)\"}}}\'")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
func runCreateProject(useLocalTemplateDirectory: Bool, json: String) async throws {
|
||||||
|
let captured = CommandClient.CapturingClient()
|
||||||
|
|
||||||
|
try await withMockConfiguration(captured, key: "runBuildProject") {
|
||||||
|
@Dependency(\.logger) var logger
|
||||||
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
|
try await playbookClient.run.createProject(
|
||||||
|
.init(
|
||||||
|
projectDirectory: "/project",
|
||||||
|
shared: Self.sharedRunOptions,
|
||||||
|
useLocalTemplateDirectory: useLocalTemplateDirectory
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let arguments = await captured.options!.arguments
|
||||||
|
logger.debug("\(arguments)")
|
||||||
|
|
||||||
|
#expect(arguments == [
|
||||||
|
"ansible-playbook", Self.defaultPlaybookPath,
|
||||||
|
"--inventory", Self.defaultInventoryPath,
|
||||||
|
Self.mockVaultArg,
|
||||||
|
"--tags", "setup-project",
|
||||||
|
"--extra-vars", "project_dir=/project",
|
||||||
|
"--extra-vars", json
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: CreateJsonTestOption.testCases)
|
||||||
|
func createJson(input: CreateJsonTestOption) {
|
||||||
|
withTestLogger(key: "generateJson") {
|
||||||
|
$0.coders.jsonEncoder = { jsonEncoder }
|
||||||
|
$0.configurationClient = .mock(input.configuration)
|
||||||
|
} operation: {
|
||||||
|
@Dependency(\.coders) var coders
|
||||||
|
|
||||||
|
let jsonData = try? input.options.createJSONData(
|
||||||
|
configuration: input.configuration,
|
||||||
|
encoder: coders.jsonEncoder()
|
||||||
|
)
|
||||||
|
|
||||||
|
switch input.expectation {
|
||||||
|
case let .success(expected):
|
||||||
|
let json = String(data: jsonData!, encoding: .utf8)!
|
||||||
|
if json != expected {
|
||||||
|
print("json:", json)
|
||||||
|
print("expected:", expected)
|
||||||
|
}
|
||||||
|
#expect(json == expected)
|
||||||
|
case .failure:
|
||||||
|
#expect(jsonData == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func generateTemplate() async throws {
|
||||||
|
try await withCapturingCommandClient("generateTemplate") {
|
||||||
|
$0.configurationClient = .mock()
|
||||||
|
$0.playbookClient = .liveValue
|
||||||
|
} run: {
|
||||||
|
@Dependency(\.playbookClient) var playbookClient
|
||||||
|
|
||||||
|
let output = try await playbookClient.run.generateTemplate(.init(
|
||||||
|
shared: Self.sharedRunOptions,
|
||||||
|
templateDirectory: "/template"
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(output == "/template")
|
||||||
|
|
||||||
|
} assert: { output in
|
||||||
|
|
||||||
|
let expected = [
|
||||||
|
"ansible-playbook", Self.defaultPlaybookPath,
|
||||||
|
"--inventory", Self.defaultInventoryPath,
|
||||||
|
Self.mockVaultArg,
|
||||||
|
"--tags", "repo-template",
|
||||||
|
"--extra-vars", "output_dir=/template"
|
||||||
|
]
|
||||||
|
|
||||||
|
#expect(output.arguments == expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withMockConfiguration(
|
||||||
|
_ capturing: CommandClient.CapturingClient,
|
||||||
|
configuration: Configuration = .mock,
|
||||||
|
key: String,
|
||||||
|
logLevel: Logger.Level = .trace,
|
||||||
|
depednencies setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
|
||||||
|
operation: @Sendable @escaping () async throws -> Void
|
||||||
|
) async rethrows {
|
||||||
|
try await withDependencies {
|
||||||
|
$0.configurationClient = .mock(configuration)
|
||||||
|
$0.commandClient = .capturing(capturing)
|
||||||
|
$0.playbookClient = .liveValue
|
||||||
|
setupDependencies(&$0)
|
||||||
|
} operation: {
|
||||||
|
try await operation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateJsonTestOption: Sendable {
|
||||||
|
let options: PlaybookClient.RunPlaybook.CreateOptions
|
||||||
|
let configuration: Configuration
|
||||||
|
let expectation: Result<String, TestError>
|
||||||
|
|
||||||
|
static let testCases: [Self] = [
|
||||||
|
CreateJsonTestOption(
|
||||||
|
options: .init(
|
||||||
|
projectDirectory: "/project",
|
||||||
|
shared: PlaybookClientTests.sharedRunOptions,
|
||||||
|
template: .init(url: nil, version: nil, directory: nil),
|
||||||
|
useLocalTemplateDirectory: true
|
||||||
|
),
|
||||||
|
configuration: .init(),
|
||||||
|
expectation: .failing
|
||||||
|
),
|
||||||
|
CreateJsonTestOption(
|
||||||
|
options: .init(
|
||||||
|
projectDirectory: "/project",
|
||||||
|
shared: PlaybookClientTests.sharedRunOptions,
|
||||||
|
template: .init(url: nil, version: nil, directory: nil),
|
||||||
|
useLocalTemplateDirectory: false
|
||||||
|
),
|
||||||
|
configuration: .init(),
|
||||||
|
expectation: .failing
|
||||||
|
),
|
||||||
|
CreateJsonTestOption(
|
||||||
|
options: .init(
|
||||||
|
projectDirectory: "/project",
|
||||||
|
shared: PlaybookClientTests.sharedRunOptions,
|
||||||
|
template: .init(url: nil, version: nil, directory: "/template"),
|
||||||
|
useLocalTemplateDirectory: true
|
||||||
|
),
|
||||||
|
configuration: .init(template: .init(directory: "/template")),
|
||||||
|
expectation: .success("""
|
||||||
|
{
|
||||||
|
"template" : {
|
||||||
|
"path" : "/template"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
),
|
||||||
|
CreateJsonTestOption(
|
||||||
|
options: .init(
|
||||||
|
projectDirectory: "/project",
|
||||||
|
shared: PlaybookClientTests.sharedRunOptions,
|
||||||
|
template: .init(url: nil, version: nil, directory: "/template"),
|
||||||
|
useLocalTemplateDirectory: true
|
||||||
|
),
|
||||||
|
configuration: .init(template: .init(directory: "/template")),
|
||||||
|
expectation: .success("""
|
||||||
|
{
|
||||||
|
"template" : {
|
||||||
|
"path" : "/template"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
),
|
||||||
|
CreateJsonTestOption(
|
||||||
|
options: .init(
|
||||||
|
projectDirectory: "/project",
|
||||||
|
shared: PlaybookClientTests.sharedRunOptions,
|
||||||
|
template: .init(url: "https://git.example.com/template.git", version: "main", directory: nil),
|
||||||
|
useLocalTemplateDirectory: false
|
||||||
|
),
|
||||||
|
configuration: .init(),
|
||||||
|
expectation: .success("""
|
||||||
|
{
|
||||||
|
"template" : {
|
||||||
|
"repo" : {
|
||||||
|
"url" : "https://git.example.com/template.git",
|
||||||
|
"version" : "main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
),
|
||||||
|
CreateJsonTestOption(
|
||||||
|
options: .init(
|
||||||
|
projectDirectory: "/project",
|
||||||
|
shared: PlaybookClientTests.sharedRunOptions,
|
||||||
|
template: .init(url: "https://git.example.com/template.git", version: "v0.1.0", directory: nil),
|
||||||
|
useLocalTemplateDirectory: false
|
||||||
|
),
|
||||||
|
configuration: .init(template: .init(url: "https://git.example.com/template.git", version: "v0.1.0")),
|
||||||
|
expectation: .success("""
|
||||||
|
{
|
||||||
|
"template" : {
|
||||||
|
"repo" : {
|
||||||
|
"url" : "https://git.example.com/template.git",
|
||||||
|
"version" : "v0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Result where Failure == TestError {
|
||||||
|
static var failing: Self { .failure(TestError()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestError: Error {}
|
||||||
|
|
||||||
|
extension Tag {
|
||||||
|
@Tag static var repository: Self
|
||||||
|
@Tag static var run: Self
|
||||||
|
}
|
||||||
|
|
||||||
|
let jsonEncoder: JSONEncoder = {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys]
|
||||||
|
return encoder
|
||||||
|
}()
|
||||||
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.findVaultFile = { _ in URL(filePath: "/vault.yml") }
|
||||||
|
$0.vaultClient = .liveValue
|
||||||
|
} run: {
|
||||||
|
@Dependency(\.vaultClient) var vaultClient
|
||||||
|
|
||||||
|
let output = try await vaultClient.run.decrypt(.init(
|
||||||
|
extraOptions: input.extraOptions,
|
||||||
|
loggingOptions: Self.loggingOptions,
|
||||||
|
outputFilePath: input.outputFilePath,
|
||||||
|
vaultFilePath: input.vaultFilePath
|
||||||
|
))
|
||||||
|
|
||||||
|
if let outputFilePath = input.outputFilePath {
|
||||||
|
#expect(output == outputFilePath)
|
||||||
|
} else if let vaultFilePath = input.vaultFilePath {
|
||||||
|
#expect(output == vaultFilePath)
|
||||||
|
} else {
|
||||||
|
#expect(output == "/vault.yml")
|
||||||
|
}
|
||||||
|
} assert: { options in
|
||||||
|
|
||||||
|
#expect(options.arguments == input.expected(.decrypt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(
|
||||||
|
arguments: TestOptions.testCases
|
||||||
|
)
|
||||||
|
func encrypt(input: TestOptions) async throws {
|
||||||
|
try await withCapturingCommandClient("decrypt") {
|
||||||
|
$0.configurationClient = .mock(input.configuration)
|
||||||
|
$0.fileClient.findVaultFile = { _ in URL(filePath: "/vault.yml") }
|
||||||
|
$0.vaultClient = .liveValue
|
||||||
|
} run: {
|
||||||
|
@Dependency(\.vaultClient) var vaultClient
|
||||||
|
|
||||||
|
let output = try await vaultClient.run.encrypt(.init(
|
||||||
|
extraOptions: input.extraOptions,
|
||||||
|
loggingOptions: Self.loggingOptions,
|
||||||
|
outputFilePath: input.outputFilePath,
|
||||||
|
vaultFilePath: input.vaultFilePath
|
||||||
|
))
|
||||||
|
|
||||||
|
if let outputFilePath = input.outputFilePath {
|
||||||
|
#expect(output == outputFilePath)
|
||||||
|
} else if let vaultFilePath = input.vaultFilePath {
|
||||||
|
#expect(output == vaultFilePath)
|
||||||
|
} else {
|
||||||
|
#expect(output == "/vault.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
} assert: { options in
|
||||||
|
#expect(options.arguments == input.expected(.encrypt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestOptions: Sendable {
|
||||||
|
let configuration: Configuration
|
||||||
|
let extraOptions: [String]?
|
||||||
|
let outputFilePath: String?
|
||||||
|
let vaultFilePath: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
configuration: Configuration = .init(),
|
||||||
|
extraOptions: [String]? = nil,
|
||||||
|
outputFilePath: String? = nil,
|
||||||
|
vaultFilePath: String? = nil
|
||||||
|
) {
|
||||||
|
self.configuration = configuration
|
||||||
|
self.extraOptions = extraOptions
|
||||||
|
self.outputFilePath = outputFilePath
|
||||||
|
self.vaultFilePath = vaultFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func expected(_ route: VaultClient.Route) -> [String] {
|
||||||
|
var expected = [
|
||||||
|
"ansible-vault", "\(route.verb)"
|
||||||
|
]
|
||||||
|
|
||||||
|
if let outputFilePath {
|
||||||
|
expected.append(contentsOf: ["--output", outputFilePath])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let extraOptions {
|
||||||
|
expected.append(contentsOf: extraOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let vaultArgs = configuration.vault.args {
|
||||||
|
expected.append(contentsOf: vaultArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if route == .encrypt,
|
||||||
|
let id = configuration.vault.encryptId
|
||||||
|
{
|
||||||
|
expected.append(contentsOf: ["--encrypt-vault-id", id])
|
||||||
|
}
|
||||||
|
|
||||||
|
expected.append(vaultFilePath ?? "/vault.yml")
|
||||||
|
|
||||||
|
return expected
|
||||||
|
}
|
||||||
|
|
||||||
|
static let testCases: [Self] = [
|
||||||
|
TestOptions(vaultFilePath: "/vault.yml"),
|
||||||
|
TestOptions(extraOptions: ["--verbose"]),
|
||||||
|
TestOptions(configuration: .mock),
|
||||||
|
TestOptions(outputFilePath: "/output.yml")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestError: Error {}
|
||||||
23
docker/Dockerfile
Executable file
23
docker/Dockerfile
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
# Used this to build the release version of the image.
|
||||||
|
# Build the executable
|
||||||
|
ARG SWIFT_IMAGE_VERSION="6.0.3"
|
||||||
|
|
||||||
|
FROM swift:${SWIFT_IMAGE_VERSION} AS build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY ./Package.* ./
|
||||||
|
RUN swift package resolve
|
||||||
|
COPY . .
|
||||||
|
RUN swift build -c release -Xswiftc -g
|
||||||
|
|
||||||
|
# Run image
|
||||||
|
FROM swift:${SWIFT_IMAGE_VERSION}-slim
|
||||||
|
|
||||||
|
RUN export DEBIAN_FRONTEND=nointeractive DEBCONF_NOINTERACTIVE_SEEN=true && apt-get -q update && \
|
||||||
|
apt-get -q install -y \
|
||||||
|
ansible \
|
||||||
|
pandoc \
|
||||||
|
texlive \
|
||||||
|
&& rm -r /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=build /build/.build/release/hpa /usr/local/bin
|
||||||
|
CMD ["/bin/bash", "-xc", "/usr/local/bin/hpa"]
|
||||||
10
docker/Dockerfile.test
Normal file
10
docker/Dockerfile.test
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Used to build a test image.
|
||||||
|
ARG SWIFT_IMAGE_VERSION="6.0.3"
|
||||||
|
FROM swift:${SWIFT_IMAGE_VERSION}
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ./Package.* ./
|
||||||
|
RUN swift package resolve
|
||||||
|
COPY . .
|
||||||
|
RUN swift build
|
||||||
|
CMD ["/bin/bash", "-xc", "swift", "test"]
|
||||||
27
justfile
27
justfile
@@ -1,14 +1,30 @@
|
|||||||
|
docker_image_name := "swift-hpa"
|
||||||
|
install_path := "~/.local/share/bin/hpa"
|
||||||
|
completion_path := "~/.local/share/zsh/completions/_hpa"
|
||||||
|
|
||||||
build mode="debug":
|
build mode="debug":
|
||||||
swift build -c {{mode}}
|
swift build -c {{mode}}
|
||||||
|
|
||||||
alias b := build
|
alias b := build
|
||||||
|
|
||||||
|
build-docker file="Dockerfile" tag="latest":
|
||||||
|
@docker build \
|
||||||
|
--file docker/{{file}} \
|
||||||
|
--tag {{docker_image_name}}:{{tag}} .
|
||||||
|
|
||||||
|
build-docker-test: (build-docker "Dockerfile.test" "test")
|
||||||
|
|
||||||
test *ARGS:
|
test *ARGS:
|
||||||
swift test {{ARGS}}
|
swift test {{ARGS}}
|
||||||
|
|
||||||
alias t := test
|
alias t := test
|
||||||
|
|
||||||
|
test-docker *ARGS: (build-docker-test)
|
||||||
|
@docker run --rm \
|
||||||
|
--network host \
|
||||||
|
{{docker_image_name}}:test \
|
||||||
|
swift test {{ARGS}}
|
||||||
|
|
||||||
run *ARGS:
|
run *ARGS:
|
||||||
swift run hpa {{ARGS}}
|
swift run hpa {{ARGS}}
|
||||||
|
|
||||||
@@ -16,3 +32,14 @@ alias r := run
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf .build
|
rm -rf .build
|
||||||
|
|
||||||
|
update-version:
|
||||||
|
@swift package \
|
||||||
|
--disable-sandbox \
|
||||||
|
--allow-writing-to-package-directory \
|
||||||
|
update-version \
|
||||||
|
hpa
|
||||||
|
|
||||||
|
install: (build "release")
|
||||||
|
@cp .build/release/hpa {{install_path}}
|
||||||
|
@{{install_path}} --generate-completion-script zsh > {{completion_path}}
|
||||||
|
|||||||
Reference in New Issue
Block a user