35 Commits

Author SHA1 Message Date
8ee4e436aa feat: Adds /root/project directory and sets it as the working directory in docker container.
All checks were successful
CI / Run Tests (push) Successful in 1m25s
Build docker images / docker (push) Successful in 7m22s
Release / release (push) Successful in 9s
2025-11-13 15:22:48 -05:00
064976ed6e feat: Fixes entrypoint script that was not appropriately handling arguments to hpa script.
All checks were successful
CI / Run Tests (push) Successful in 2m31s
Build docker images / docker (push) Successful in 7m26s
2025-11-13 14:59:35 -05:00
45a1520a2b feat: Adds xelatex package to docker image.
All checks were successful
CI / Run Tests (push) Successful in 1m25s
Build docker images / docker (push) Successful in 7m21s
2025-11-07 17:02:50 -05:00
f7f3ac5dc7 feat: Updates Dockerfile, WIP
All checks were successful
CI / Run Tests (push) Successful in 1m27s
Build docker images / docker (push) Successful in 6m45s
2025-11-07 15:16:05 -05:00
15454e3686 feat: Adds entrypoint.sh script to docker to allow attaching to a shell inside the container.
All checks were successful
CI / Run Tests (push) Successful in 2m33s
Build docker images / docker (push) Successful in 6m49s
2025-11-07 13:18:57 -05:00
63012131d4 feat: Removes image creation on dev branch, only create for main branch.
All checks were successful
CI / Run Tests (pull_request) Successful in 1m25s
Build docker images / docker (pull_request) Successful in 6m44s
CI / Run Tests (push) Successful in 1m26s
Build docker images / docker (push) Successful in 6m48s
2025-11-04 14:09:30 -05:00
a013e6fe81 feat: Adds docker ci workflow. 2025-11-04 14:09:30 -05:00
9d8f3368ec feat: Adds todos, updates Dockerfile to use hpa as the entrypoint. 2025-11-04 14:09:30 -05:00
88a1f181cb feat: Updates Dockerfile, WIP 2025-11-04 14:09:30 -05:00
a6227a80db feat: Updates release notes. 2025-11-04 14:09:29 -05:00
d1a47e2ac6 feat: Updates to check if playbook is installed prior to running any of the commands, updates tests. 2025-11-04 14:09:29 -05:00
86dc084e7d feat: Documenting release workflow. 2025-11-04 14:09:29 -05:00
d3a9aa2f00 feat: More readme, also removes ci from dev branch and only runs for pulls and pushes to main. 2025-11-04 14:08:29 -05:00
1820988894 feat: Fix merge conflicts. 2025-11-04 14:06:33 -05:00
daeeffa995 README & License (#3)
All checks were successful
CI / Run Tests (push) Successful in 2m25s
Reviewed-on: #3
2024-12-19 14:41:35 +00:00
1e72dbcfc2 feat: Bumps version
All checks were successful
CI / Run Tests (push) Successful in 2m36s
Release / release (push) Successful in 7s
2024-12-18 11:42:36 -05:00
a5b9280a46 feat: Adds release workflow to gitea
Some checks failed
CI / Run Tests (push) Has been cancelled
2024-12-18 11:41:39 -05:00
4b9c45dd5f fix: Fixes docker tests now that we're using curl to download configuration template file.
All checks were successful
CI / Run Tests (push) Successful in 3m2s
2024-12-18 10:54:09 -05:00
529aa80c47 feat: Updates to test homebrew
Some checks failed
CI / Run Tests (push) Failing after 2m34s
2024-12-18 10:18:28 -05:00
66e286f267 fix: Fixes merge conflicts
Some checks failed
CI / Run Tests (push) Failing after 41s
2024-12-18 08:22:18 -05:00
cb25dba7de feat: Updating just file
All checks were successful
CI / Run Tests (push) Successful in 2m52s
2024-12-18 08:15:26 -05:00
9b99b35436 feat: Uses curl to download toml config to allow for pre-built binaries.
Some checks failed
CI / Run Tests (push) Failing after 2m44s
2024-12-17 19:03:16 -05:00
0c6e9e1228 feat: Bumps version
All checks were successful
CI / Run Tests (push) Successful in 2m19s
2024-12-17 16:02:56 -05:00
faa28749bc feat: Merges dev
All checks were successful
CI / Run Tests (push) Successful in 2m43s
2024-12-17 15:55:36 -05:00
fb246df01a fix: Fixes failing tests since we create backups now during configuration generation.
All checks were successful
CI / Run Tests (push) Successful in 2m43s
CI / Run Tests (pull_request) Successful in 2m22s
2024-12-17 15:44:51 -05:00
0b153d7990 fix: Removes unused release workflow
Some checks failed
CI / Run Tests (push) Failing after 2m52s
CI / Run Tests (pull_request) Failing after 2m15s
2024-12-17 15:32:15 -05:00
558054464c feat: Create backups of configuration when the force option is not used.
Some checks failed
CI / Run Tests (push) Failing after 2m16s
Create and publish a Docker image / build-and-push-image (push) Has been cancelled
2024-12-17 14:38:52 -05:00
54c07886ad feat: Adding documentation comments.
All checks were successful
CI / Run Tests (push) Successful in 2m28s
2024-12-17 13:32:05 -05:00
f8e89ed0fa feat: Adding documentation comments.
All checks were successful
CI / Run Tests (push) Successful in 2m17s
2024-12-17 11:51:03 -05:00
f596975bbc feat: Fix test-docker command to not use TTY for CI tests.
All checks were successful
CI / Run Tests (push) Successful in 2m28s
2024-12-17 10:25:24 -05:00
805100fa43 feat: Adds ci
Some checks failed
CI / Run Tests (push) Failing after 3m47s
2024-12-17 10:17:46 -05:00
f7f168b7fd feat: Begins adding docker containers 2024-12-17 10:15:07 -05:00
99459a0a71 feat: Adds dump-configuration command. 2024-12-16 20:44:30 -05:00
f89efc8c5e feat: Adds file-client tests. 2024-12-16 19:00:42 -05:00
85b285347b feat: Removes cli-client 2024-12-16 17:14:25 -05:00
62 changed files with 2004 additions and 1713 deletions

20
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,20 @@
---
name: CI
on:
push:
branches: ["main"]
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

View File

@@ -0,0 +1,55 @@
name: Build docker images
on:
push:
branches:
- main
pull_request: {}
workflow_dispatch: {}
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup docker buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registery
uses: docker/login-action@v3
with:
registry: git.housh.dev
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: git.housh.dev/michael/swift-hpa
tags: |
type=schedule
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -0,0 +1,17 @@
---
name: Release
on:
push:
tags:
- "*.*.*"
- "v*.*.*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Release
uses: softprops/action-gh-release@v2

2
.gitignore vendored
View File

@@ -10,3 +10,5 @@ DerivedData/
.swiftpm/*
./hpa.toml
./Version.*
/*.json
hpa

20
LICENSE Normal file
View File

@@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2024 Michael Housh
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,5 +1,5 @@
{
"originHash" : "a3b90cdeec7e11e73a0a47c1f8dffa472c22f0c6385282eb11096210e3e7ad8b",
"originHash" : "f1d1e27e3b3b21d41b872325e0174196a323dc438bf3c9d9f99858856b457c96",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -33,8 +33,8 @@
"kind" : "remoteSourceControl",
"location" : "https://git.housh.dev/michael/swift-cli-doc.git",
"state" : {
"revision" : "e524056dc65c5ce7a6a77bdea4e5fa0bf724019b",
"version" : "0.2.0"
"revision" : "bbace73d974fd3e6985461431692bea773c7c5d8",
"version" : "0.2.1"
}
},
{
@@ -64,6 +64,15 @@
"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",
"kind" : "remoteSourceControl",

View File

@@ -7,19 +7,20 @@ let package = Package(
platforms: [.macOS(.v14)],
products: [
.executable(name: "hpa", targets: ["hpa"]),
.library(name: "CliClient", targets: ["CliClient"]),
.library(name: "CodersClient", targets: ["CodersClient"]),
.library(name: "CommandClient", targets: ["CommandClient"]),
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"]),
.library(name: "FileClient", targets: ["FileClient"]),
.library(name: "PlaybookClient", targets: ["PlaybookClient"]),
.library(name: "PandocClient", targets: ["PandocClient"]),
.library(name: "VaultClient", targets: ["VaultClient"])
],
dependencies: [
.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/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.1"),
.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")
],
@@ -27,36 +28,18 @@ let package = Package(
.executableTarget(
name: "hpa",
dependencies: [
"CliClient",
"ConfigurationClient",
"FileClient",
"PandocClient",
"PlaybookClient",
"VaultClient",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "CliDoc", package: "swift-cli-doc"),
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.target(
name: "CliClient",
dependencies: [
"CommandClient",
"CodersClient",
"ConfigurationClient",
"PlaybookClient",
.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(
name: "CodersClient",
dependencies: [
@@ -87,6 +70,7 @@ let package = Package(
name: "ConfigurationClient",
dependencies: [
"CodersClient",
"CommandClient",
"FileClient",
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
@@ -110,11 +94,29 @@ let package = Package(
.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",
"CodersClient",
"ConfigurationClient",
"FileClient",
.product(name: "Dependencies", package: "swift-dependencies"),

179
README.md Normal file
View File

@@ -0,0 +1,179 @@
# swift-hpa
A command-line application for managing home performance assessment projects from user defined
template repositories.
This tool is a wrapper around several other command line applications, the primary ones being:
1. `ansible-playbook`
1. `ansible-vault`
1. `pandoc`
## Installation
You can install the application using homebrew.
```bash
brew tap michael/formula https://git.housh.dev/michael/homebrew-formula
brew install michael/formula/hpa
```
Installation on platforms other than `macOS` are currently being worked on, along with support for
running in a `docker` container.
### Ensuring dependencies are installed
This application requires some dependencies to be installed on your system, you can install the
dependencies with the following command.
```bash
hpa utils install-dependencies
```
The dependencies installed are:
1. ansible
1. imagemagick
1. pandoc
1. texLive
It will also download an ansible-playbook that is used to generate output files, template
repositories, and encrypt / decrypt variable files. The playbook get's installed to
`~/.local/share/hpa/playbook`.
> NOTE: All commands accept a `--help` option which will display the arguments and options a command
> can use, along with example usage of the commands.
### Configure the application
When you first download the application you can setup the configuration file for your use case.
```bash
hpa utils generate-config
```
This will create a configuration file in the default location: `~/.config/hpa/config.toml`, which
can be edited to suit your needs.
## Getting Started
The first step to getting started is creating your template. This is used to create projects. The
template defines the structure of a project and defines variables which are used to generate the
final output files of a project.
You can generate the template using following command:
```bash
hpa utils generate-template --path ~/projects/my-template
```
Where the `--path` is where you would like the template to be on your local system.
It is recommended that after you get your template setup to your liking that you turn it into a
`git` repository. Therefore your projects can be pinned to specific version of the template. This
allows your template to expand over time.
Once your template is setup, make sure that your configuration file is setup to point to your
customized template.
## Creating a project
The first step after having your template defined is to create a project that uses it. The below
command will create a template in the `~/consults/my-first-consult` directory.
```bash
hpa create ~/consults/my-first-consult
```
The above assumes that your template is a `git` repository and that your configuration is setup
properly. If you want to experiment with a local template that is on your system then you can you
can use one of the following command options.
```bash
hpa create --template-dir ~/projects/my-template ~/consults/my-first-consult
```
Or if your configuration has `directory` set in the `template` section.
```bash
hpa create --use-local-template ~/consults/my-first-consult
```
## Generating output files
Once you have created a project and edited the contents to your liking. You can then generate the
final output file (typically a pdf) that can be sent to your customer.
```bash
hpa generate pdf
```
The above _assumes_ that you are inside your project directory, if you would like to generate an
output file from outside of your project directory you can specify the path to the project you would
like to generate output for.
```bash
hpa generate pdf --project-directory ~/consults/my-first-consult
```
Currently the supported output file types are:
1. PDF
1. LaTeX
1. HTML
## Build command
The command line tool goes through an intermediate step when generating output, which is called
`build`. The build step generates the final output files using defined variables that are located in
your project directory or in your template directory. It will decrypt any sensitive data stored in
`vault` files as well.
These files get placed inside a directory in the project, default location is `.build`. The generate
commands by default build the project for you, unless you specify the `--no-build` option.
You can explore the contents of the `.build` directory or if you'd like to separate the build and
generate steps, you can build a project using the following command:
```bash
hpa build
```
The above _assumes_ that you are inside your project directory, if you would like to generate an
output file from outside of your project directory you can specify the path to the project you would
like to generate output for.
```bash
hpa build --project-directory ~/consults/my-first-consult
```
## Some General Usage Notes
There is often a lot of output to the console when running commands, which can be problematic if you
want to pipe the output into other command line tools, so all options accept a `-q | --quiet` flag
which will suppress logging output and allow piping into other commands.
Along the similar line, if you would like to increase the logging output then all commands accept
`-v | --verbose` that will increase the logging output. This can be passed multiple times, so for
the highest log output you can do `-vvv`.
## Uninstalling
You can uninstall the application using:
```bash
brew uninstall hpa
```
Also remove the configuration and playbook directories.
```bash
rm -rf ~/.config/hpa
rm -rf ~/.local/share/hpa
```
## LICENSE
This project is licensed under the `MIT` license.
[See license](https://git.housh.dev/michael/swift-hpa/LICENSE)

24
Release.md Normal file
View File

@@ -0,0 +1,24 @@
# Release Workflow Steps
This is a reminder of the steps used to create a release and update the homebrew formula.
> Note: These steps apply to the version hosted on `gitea`, on `github` more of these steps can be
> automated in `ci`, but there are no `macOS` host runners currently in `gitea`, so the bottles need
> built on `macOS`.
1. Update the version in `Sources/hpa/Version.swift`.
1. Tag the commit with the next version tag.
1. Push the tagged commit, this will initiate the release being created.
1. Get the `sha` of the `*.tar.gz` in the release.
1. `just get-release-sha`
1. Update the homebrew formula url, sha256, and version at top of the homebrew formula.
1. `cd $(brew --repo michael/formula)`
1. Build and generate a homebrew bottle.
1. `just bottle`
1. Update the `bottle do` section of homebrew formula with output during previous step.
1. Also make sure the `root_url` in the bottle section points to the new release.
1. Upload the bottle `*.tar.gz` file that was created to the release.
1. Generate a pull-request to the formula repo.
1. Generate a pull-request to this repo to merge into main.
1. Remove the bottle from current directory.
1. `just remove-bottles`

View File

@@ -1,189 +0,0 @@
import ConfigurationClient
import Dependencies
import DependenciesMacros
import FileClient
import Foundation
import PlaybookClient
import ShellClient
public extension CliClient {
func runCommand(
quiet: Bool,
shell: ShellCommand.Shell,
_ args: [String]
) async throws {
try await runCommand(.init(arguments: args, quiet: quiet, shell: shell))
}
func runCommand(
quiet: Bool,
shell: ShellCommand.Shell,
_ args: String...
) async throws {
try await runCommand(quiet: quiet, shell: shell, args)
}
func installDependencies(
quiet: Bool = false,
shell: String? = nil,
extraArgs: [String]? = nil
) async throws {
@Dependency(\.playbookClient) var playbookClient
@Dependency(\.configurationClient) var configurationClient
var arguments = [
"brew", "install"
] + Constants.brewPackages
if let extraArgs {
arguments.append(contentsOf: extraArgs)
}
try await runCommand(
quiet: quiet,
shell: shell.orDefault,
arguments
)
let configuration = try await configurationClient.findAndLoad()
try await playbookClient.repository.install(configuration)
}
func runPlaybookCommand(
_ options: PlaybookOptions,
logging loggingOptions: LoggingOptions
) async throws {
try await withLogger(loggingOptions) {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.logger) var logger
@Dependency(\.playbookClient) var playbookClient
let configuration = try await configurationClient.ensuredConfiguration(options.configuration)
logger.trace("Configuration: \(configuration)")
let playbookDirectory = try await playbookClient.repository.directory(configuration)
let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)"
logger.trace("Playbook path: \(playbookPath)")
let inventoryPath = ensuredInventoryPath(
options.inventoryFilePath,
configuration: configuration,
playbookDirectory: playbookDirectory
)
logger.trace("Inventory path: \(inventoryPath)")
var arguments = [
Constants.playbookCommand, playbookPath,
"--inventory", inventoryPath
] + options.arguments
if let defaultArgs = configuration.args {
arguments.append(contentsOf: defaultArgs)
}
if configuration.useVaultArgs, let vaultArgs = configuration.vault.args {
arguments.append(contentsOf: vaultArgs)
}
logger.trace("Running playbook command with arguments: \(arguments)")
try await runCommand(
quiet: options.quiet,
shell: options.shell.orDefault,
arguments
)
}
}
func runVaultCommand(
_ options: VaultOptions,
logging loggingOptions: LoggingOptions
) async throws {
try await withLogger(loggingOptions) {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
let configuration = try await configurationClient.ensuredConfiguration(options.configuration)
logger.trace("Configuration: \(configuration)")
let vaultFilePath = try await fileClient.ensuredVaultFilePath(options.vaultFilePath)
logger.trace("Vault file: \(vaultFilePath)")
var arguments = [
Constants.vaultCommand
] + options.arguments
if let defaultArgs = configuration.vault.args {
arguments.append(contentsOf: defaultArgs)
}
if arguments.contains("encrypt"),
!arguments.contains("--encrypt-vault-id"),
let id = configuration.vault.encryptId
{
arguments.append(contentsOf: ["--encrypt-vault-id", id])
}
arguments.append(vaultFilePath)
logger.trace("Running vault command with arguments: \(arguments)")
try await runCommand(
quiet: options.quiet,
shell: options.shell.orDefault,
arguments
)
}
}
}
@_spi(Internal)
public extension ConfigurationClient {
func ensuredConfiguration(_ optionalConfig: Configuration?) async throws -> Configuration {
guard let config = optionalConfig else {
return try await findAndLoad()
}
return config
}
}
@_spi(Internal)
public extension Optional where Wrapped == String {
var orDefault: ShellCommand.Shell {
guard let shell = self else { return .zsh(useDashC: true) }
return .custom(path: shell, useDashC: true)
}
}
@_spi(Internal)
public func ensuredInventoryPath(
_ optionalInventoryPath: String?,
configuration: Configuration,
playbookDirectory: String
) -> String {
guard let path = optionalInventoryPath else {
guard let path = configuration.playbook?.inventory else {
return "\(playbookDirectory)/\(Constants.inventoryFileName)"
}
return path
}
return path
}
@_spi(Internal)
public extension FileClient {
func ensuredVaultFilePath(_ optionalPath: String?) async throws -> String {
guard let path = optionalPath else {
guard let url = try await findVaultFileInCurrentDirectory() else {
throw CliClientError.vaultFileNotFound
}
return url.cleanFilePath
}
return path
}
}
extension ShellCommand.Shell: @retroactive @unchecked Sendable {}

View File

@@ -1,223 +0,0 @@
import ConfigurationClient
import Dependencies
import Foundation
import ShellClient
// TODO: Need to parse / ensure that header includes and files are in the build directory, not sure
// exactly how to handle if they're not, but it seems reasonable to potentially allow files that are
// outside of the build directory to be included.
public extension CliClient {
@discardableResult
func runPandocCommand(
_ options: PandocOptions,
logging loggingOptions: LoggingOptions
) async throws -> String {
try await withLogger(loggingOptions) {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.logger) var logger
let configuration = try await configurationClient.findAndLoad()
logger.trace("Configuration: \(configuration)")
let ensuredOptions = try await ensurePandocOptions(
configuration: configuration,
options: options
)
let projectDirectory = options.projectDirectory ?? ProcessInfo.processInfo.environment["PWD"]
guard let projectDirectory else {
throw CliClientError.generate(.projectDirectoryNotSpecified)
}
let outputDirectory = options.outputDirectory ?? projectDirectory
let outputPath = "\(outputDirectory)/\(ensuredOptions.ensuredExtensionFileName)"
var arguments = [
"pandoc"
]
arguments += ensuredOptions.includeInHeader.map {
"--include-in-header=\(projectDirectory)/\(ensuredOptions.buildDirectory)/\($0)"
}
if let pdfEngine = ensuredOptions.pdfEngine {
arguments.append("--pdf-engine=\(pdfEngine)")
}
arguments.append("--output=\(outputPath)")
arguments += ensuredOptions.files.map {
"\(projectDirectory)/\(ensuredOptions.buildDirectory)/\($0)"
}
if options.shouldBuild {
logger.trace("Building project...")
try await runPlaybookCommand(
.init(
arguments: [
"--tags", "build-project",
"--extra-vars", "project_dir=\(projectDirectory)"
],
configuration: configuration,
quiet: options.quiet,
shell: options.shell
),
logging: loggingOptions
)
}
logger.trace("Running pandoc with arguments: \(arguments)")
try await runCommand(
quiet: options.quiet,
shell: options.shell.orDefault,
arguments
)
return outputPath
}
}
}
@_spi(Internal)
public struct EnsuredPandocOptions: Equatable, Sendable {
public let buildDirectory: String
public let files: [String]
public let includeInHeader: [String]
public let outputFileName: String
public let outputFileType: CliClient.PandocOptions.FileType
public let pdfEngine: String?
public var ensuredExtensionFileName: String {
let extensionString: String
switch outputFileType {
case .html:
extensionString = ".html"
case .latex:
extensionString = ".tex"
case .pdf:
extensionString = ".pdf"
}
if !outputFileName.hasSuffix(extensionString) {
return outputFileName + extensionString
}
return outputFileName
}
}
@_spi(Internal)
public func ensurePandocOptions(
configuration: Configuration,
options: CliClient.PandocOptions
) async throws -> EnsuredPandocOptions {
let defaults = Configuration.Generate.default
let pdfEngine = parsePdfEngine(configuration.generate, defaults, options)
return .init(
buildDirectory: parseBuildDirectory(configuration.generate, defaults, options),
files: parseFiles(configuration.generate, defaults, options),
includeInHeader: parseIncludeInHeader(configuration.generate, defaults, options),
outputFileName: parseOutputFileName(configuration.generate, defaults, options),
outputFileType: options.outputFileType,
pdfEngine: pdfEngine
)
}
private func parsePdfEngine(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate,
_ options: CliClient.PandocOptions
) -> String? {
switch options.outputFileType {
case .html, .latex:
return nil
case let .pdf(engine: engine):
if let engine {
return engine
} else if let engine = configuration?.pdfEngine {
return engine
} else if let engine = defaults.pdfEngine {
return engine
} else {
return "xelatex"
}
}
}
private func parseFiles(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate,
_ options: CliClient.PandocOptions
) -> [String] {
@Dependency(\.logger) var logger
if let files = options.files {
return files
} else if let files = configuration?.files {
return files
} else if let files = defaults.files {
return files
} else {
logger.warning("Files not specified, this could lead to errors.")
return []
}
}
private func parseIncludeInHeader(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate,
_ options: CliClient.PandocOptions
) -> [String] {
@Dependency(\.logger) var logger
if let files = options.includeInHeader {
return files
} else if let files = configuration?.includeInHeader {
return files
} else if let files = defaults.includeInHeader {
return files
} else {
logger.warning("Include in header files not specified, this could lead to errors.")
return []
}
}
private func parseOutputFileName(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate,
_ options: CliClient.PandocOptions
) -> String {
@Dependency(\.logger) var logger
if let output = options.outputFileName {
return output
} else if let output = configuration?.outputFileName {
return output
} else if let output = defaults.outputFileName {
return output
} else {
logger.warning("Output file name not specified, this could lead to errors.")
return "Report"
}
}
private func parseBuildDirectory(
_ configuration: Configuration.Generate?,
_ defaults: Configuration.Generate,
_ options: CliClient.PandocOptions
) -> String {
@Dependency(\.logger) var logger
if let output = options.buildDirectory {
return output
} else if let output = configuration?.buildDirectory {
return output
} else if let output = defaults.buildDirectory {
return output
} else {
logger.warning("Output file name not specified, this could lead to errors.")
return ".build"
}
}

View File

@@ -1,216 +0,0 @@
import ConfigurationClient
import Dependencies
import Foundation
import PlaybookClient
extension CliClient.RunPlaybook {
static func makeCommonArguments(
configuration: Configuration,
inventoryFilePath: String?
) async throws -> [String] {
@Dependency(\.logger) var logger
@Dependency(\.playbookClient) var playbookClient
let playbookDirectory = try await playbookClient.repository.directory(configuration)
let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)"
logger.trace("Playbook path: \(playbookPath)")
let inventoryPath = ensuredInventoryPath(
inventoryFilePath,
configuration: configuration,
playbookDirectory: playbookDirectory
)
logger.trace("Inventory path: \(inventoryPath)")
var arguments = [
Constants.playbookCommand, playbookPath,
"--inventory", inventoryPath
]
if let defaultArgs = configuration.args {
arguments.append(contentsOf: defaultArgs)
}
if configuration.useVaultArgs, let vaultArgs = configuration.vault.args {
arguments.append(contentsOf: vaultArgs)
}
logger.trace("Common arguments: \(inventoryPath)")
return arguments
}
}
extension CliClient.RunPlaybook.BuildOptions {
private func applyArguments(
to arguments: inout [String],
configuration: Configuration
) throws {
let projectDirectory = projectDirectory
?? ProcessInfo.processInfo.environment["PWD"]
guard let projectDirectory else {
throw CliClientError.projectDirectoryNotFound
}
arguments.append(contentsOf: [
"--tags", "build-project",
"--extra-vars", "project_dir=\(projectDirectory)"
])
if let extraOptions = extraOptions {
arguments.append(contentsOf: extraOptions)
}
}
}
extension CliClient.RunPlaybook.CreateOptions {
private func applyArguments(
to arguments: inout [String],
configuration: Configuration
) throws {
let json = try createJSONData(configuration: configuration)
arguments.append(contentsOf: [
"--tags", "setup-project",
"--extra-vars", "project_dir=\(projectDirectory)",
"--extra-vars", "'\(json)'"
])
if let extraOptions {
arguments.append(contentsOf: extraOptions)
}
}
}
extension CliClient.PlaybookOptions.Route {
private func parseInventoryPath(
_ configuration: Configuration,
_ playbookDirectory: String
) -> String {
let inventoryFilePath: String?
switch self {
case let .build(options):
inventoryFilePath = options.inventoryFilePath
case let .create(options):
inventoryFilePath = options.inventoryFilePath
}
return ensuredInventoryPath(
inventoryFilePath,
configuration: configuration,
playbookDirectory: playbookDirectory
)
}
func makeArguments(configuration: Configuration) async throws -> [String] {
@Dependency(\.logger) var logger
@Dependency(\.playbookClient) var playbookClient
let playbookDirectory = try await playbookClient.repository.directory(configuration)
let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)"
logger.trace("Playbook path: \(playbookPath)")
let inventoryPath = parseInventoryPath(configuration, playbookDirectory)
logger.trace("Inventory path: \(inventoryPath)")
var arguments = [
Constants.playbookCommand, playbookPath,
"--inventory", inventoryPath
]
if let defaultArgs = configuration.args {
arguments.append(contentsOf: defaultArgs)
}
if configuration.useVaultArgs, let vaultArgs = configuration.vault.args {
arguments.append(contentsOf: vaultArgs)
}
// try applyArguments(to: &arguments, configuration: configuration)
return arguments
}
}
// NOTE: We're not using the `Coders` client because we generally do not
// want the output to be `prettyPrinted` or anything, unless we're running
// tests, so we use a supplied json encoder.
extension CliClient.RunPlaybook.CreateOptions {
func createJSONData(
configuration: Configuration,
encoder: JSONEncoder = .init()
) throws -> Data {
@Dependency(\.logger) var logger
let templateDir = template.directory ?? configuration.template.directory
let templateRepo = template.url ?? configuration.template.url
let version = template.version ?? configuration.template.version
logger.debug("""
(\(useLocalTemplateDirectory), \(String(describing: templateDir)), \(String(describing: templateRepo)))
""")
switch (useLocalTemplateDirectory, templateDir, templateRepo) {
case (true, .none, _):
// User supplied they wanted to use a local template directory, but we could not find
// the path set from command line or in configuration.
throw CliClientError.templateDirectoryNotFound
case let (false, _, .some(repo)):
// User did not supply they wanted to use a local template directory, and we found a repo url that was
// either set by the command line or found in the configuration.
logger.debug("Using repo.")
return try encoder.encode(TemplateRepo(repo: repo, version: version))
case let (true, .some(templateDir), _):
// User supplied they wanted to use a local template directory, and we found the template directory
// either set by the command line or in the configuration.
logger.debug("Using template directory.")
return try encoder.encode(TemplateDirJson(path: templateDir))
case let (false, .some(templateDir), _):
// User supplied they did not wanted to use a local template directory, and we found the template directory
// either set by the command line or in the configuration, and no repo was found / handled previously.
logger.debug("Using template directory.")
return try encoder.encode(TemplateDirJson(path: templateDir))
case (_, .none, .none):
// We could not find a repo or template directory.
throw CliClientError.templateDirectoryOrRepoNotSpecified
}
}
}
private struct TemplateDirJson: Encodable {
let template: Template
init(path: String) {
self.template = .init(path: path)
}
struct Template: Encodable {
let path: String
}
}
private struct TemplateRepo: Encodable {
let template: Template
init(repo: String, version: String?) {
self.template = .init(repo: .init(url: repo, version: version ?? "main"))
}
struct Template: Encodable {
let repo: Repo
}
struct Repo: Encodable {
let url: String
let version: String
}
}

View File

@@ -1,16 +0,0 @@
import Foundation
public enum CliClientError: Error {
case brewfileNotFound
case encodingError
case playbookDirectoryNotFound
case projectDirectoryNotFound
case generate(GenerateError)
case templateDirectoryNotFound
case templateDirectoryOrRepoNotSpecified
case vaultFileNotFound
public enum GenerateError: Sendable {
case projectDirectoryNotSpecified
}
}

View File

@@ -1,11 +0,0 @@
enum Constants {
static let executableName = "hpa"
static let playbookBundleDirectoryName = "ansible-hpa-playbook"
static let playbookCommand = "ansible-playbook"
static let playbookFileName = "main.yml"
static let inventoryFileName = "inventory.ini"
static let vaultCommand = "ansible-vault"
static let brewPackages = [
"ansible", "imagemagick", "pandoc", "texLive"
]
}

View File

@@ -1,85 +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.
// TODO: Remove.
func createJSONData(
_ options: CliClient.GenerateJsonOptions,
logging loggingOptions: CliClient.LoggingOptions,
encoder: JSONEncoder = .init()
) async throws -> Data {
try await CliClient.withLogger(loggingOptions) {
@Dependency(\.logger) var logger
@Dependency(\.configurationClient) var configurationClient
let configuration = try await configurationClient.findAndLoad()
let templateDir = options.templateDirectory ?? configuration.template.directory
let templateRepo = options.templateRepo ?? configuration.template.url
let version = options.version ?? configuration.template.version
logger.debug("""
(\(options.useLocalTemplateDirectory), \(String(describing: templateDir)), \(String(describing: templateRepo)))
""")
switch (options.useLocalTemplateDirectory, templateDir, templateRepo) {
case (true, .none, _):
// User supplied they wanted to use a local template directory, but we could not find
// the path set from command line or in configuration.
throw CliClientError.templateDirectoryNotFound
case let (false, _, .some(repo)):
// User did not supply they wanted to use a local template directory, and we found a repo url that was
// either set by the command line or found in the configuration.
logger.debug("Using repo.")
return try encoder.encode(TemplateRepo(repo: repo, version: version))
case let (true, .some(templateDir), _):
// User supplied they wanted to use a local template directory, and we found the template directory
// either set by the command line or in the configuration.
logger.debug("Using template directory.")
return try encoder.encode(TemplateDirJson(path: templateDir))
case let (false, .some(templateDir), _):
// User supplied they did not wanted to use a local template directory, and we found the template directory
// either set by the command line or in the configuration, and no repo was found / handled previously.
logger.debug("Using template directory.")
return try encoder.encode(TemplateDirJson(path: templateDir))
case (_, .none, .none):
// We could not find a repo or template directory.
throw CliClientError.templateDirectoryOrRepoNotSpecified
}
}
}
private struct TemplateDirJson: Encodable {
let template: Template
init(path: String) {
self.template = .init(path: path)
}
struct Template: Encodable {
let path: String
}
}
private struct TemplateRepo: Encodable {
let template: Template
init(repo: String, version: String?) {
self.template = .init(repo: .init(url: repo, version: version ?? "main"))
}
struct Template: Encodable {
let repo: Repo
}
struct Repo: Encodable {
let url: String
let version: String
}
}

View File

@@ -1,329 +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 {
@DependencyClient
struct RunPlaybook: Sendable {
public var buildProject: @Sendable (RunOptions, BuildOptions) async throws -> Void
public var createProject: @Sendable (RunOptions, CreateOptions) async throws -> String
public struct RunOptions: Equatable, Sendable {
public let loggingOptions: CliClient.LoggingOptions
public let quiet: Bool
public let shell: String?
}
public struct BuildOptions: Equatable, Sendable {
public let extraOptions: [String]?
public let inventoryFilePath: String?
public let projectDirectory: String?
public init(
extraOptions: [String]?,
inventoryFilePath: String?,
projectDirectory: String
) {
self.extraOptions = extraOptions
self.inventoryFilePath = inventoryFilePath
self.projectDirectory = projectDirectory
}
}
public struct CreateOptions: Equatable, Sendable {
public let extraOptions: [String]?
public let inventoryFilePath: String?
public let projectDirectory: String
public let template: Configuration.Template
public let useLocalTemplateDirectory: Bool
public init(
extraOptions: [String]?,
inventoryFilePath: String?,
projectDirectory: String,
template: Configuration.Template,
useLocalTemplateDirectory: Bool
) {
self.extraOptions = extraOptions
self.inventoryFilePath = inventoryFilePath
self.projectDirectory = projectDirectory
self.template = template
self.useLocalTemplateDirectory = useLocalTemplateDirectory
}
}
}
}
public extension CliClient {
struct PandocOptions: Equatable, Sendable {
let buildDirectory: String?
let files: [String]?
let includeInHeader: [String]?
let outputDirectory: String?
let outputFileName: String?
let outputFileType: FileType
let projectDirectory: String?
let quiet: Bool
let shell: String?
let shouldBuild: Bool
public init(
buildDirectory: String? = nil,
files: [String]? = nil,
includeInHeader: [String]? = nil,
outputDirectory: String? = nil,
outputFileName: String? = nil,
outputFileType: FileType,
projectDirectory: String?,
quiet: Bool,
shell: String? = nil,
shouldBuild: Bool
) {
self.buildDirectory = buildDirectory
self.files = files
self.includeInHeader = includeInHeader
self.outputDirectory = outputDirectory
self.outputFileName = outputFileName
self.outputFileType = outputFileType
self.projectDirectory = projectDirectory
self.quiet = quiet
self.shell = shell
self.shouldBuild = shouldBuild
}
// swiftlint:disable nesting
public enum FileType: Equatable, Sendable {
case html
case latex
case pdf(engine: String?)
}
// swiftlint:enable nesting
}
struct GenerateJsonOptions: Equatable, Sendable {
let templateDirectory: String?
let templateRepo: String?
let version: String?
let useLocalTemplateDirectory: Bool
public init(
templateDirectory: String?,
templateRepo: String?,
version: String?,
useLocalTemplateDirectory: Bool
) {
self.templateDirectory = templateDirectory
self.templateRepo = templateRepo
self.version = version
self.useLocalTemplateDirectory = useLocalTemplateDirectory
}
}
struct LoggingOptions: Equatable, Sendable {
let commandName: String
let logLevel: Logger.Level
public init(commandName: String, logLevel: Logger.Level) {
self.commandName = commandName
self.logLevel = logLevel
}
}
struct PlaybookOptions: Sendable, Equatable {
let arguments: [String]
let configuration: Configuration?
let inventoryFilePath: String?
let playbookDirectory: String?
let quiet: Bool
let shell: String?
public init(
arguments: [String],
configuration: Configuration? = nil,
inventoryFilePath: String? = nil,
playbookDirectory: String? = nil,
quiet: Bool,
shell: String? = nil
) {
self.arguments = arguments
self.configuration = configuration
self.inventoryFilePath = inventoryFilePath
self.playbookDirectory = playbookDirectory
self.quiet = quiet
self.shell = shell
}
public enum Route {
case build(BuildOption)
case create(CreateOption)
public struct BuildOption: Equatable, Sendable {
public let extraOptions: [String]?
public let inventoryFilePath: String?
public let projectDirectory: String?
public init(
extraOptions: [String]?,
inventoryFilePath: String?,
projectDirectory: String
) {
self.extraOptions = extraOptions
self.inventoryFilePath = inventoryFilePath
self.projectDirectory = projectDirectory
}
}
public struct CreateOption: Equatable, Sendable {
public let extraOptions: [String]?
public let inventoryFilePath: String?
public let projectDirectory: String
public let template: Configuration.Template
public let useLocalTemplateDirectory: Bool
public init(
extraOptions: [String]?,
inventoryFilePath: String?,
projectDirectory: String,
template: Configuration.Template,
useLocalTemplateDirectory: Bool
) {
self.extraOptions = extraOptions
self.inventoryFilePath = inventoryFilePath
self.projectDirectory = projectDirectory
self.template = template
self.useLocalTemplateDirectory = useLocalTemplateDirectory
}
}
}
}
struct RunCommandOptions: Sendable, Equatable {
public let arguments: [String]
public let quiet: Bool
public let shell: ShellCommand.Shell
public init(
arguments: [String],
quiet: Bool,
shell: ShellCommand.Shell
) {
self.arguments = arguments
self.quiet = quiet
self.shell = shell
}
}
struct VaultOptions: Equatable, Sendable {
let arguments: [String]
let configuration: Configuration?
let quiet: Bool
let shell: String?
let vaultFilePath: String?
public init(
arguments: [String],
configuration: Configuration? = nil,
quiet: Bool,
shell: String?,
vaultFilePath: String? = nil
) {
self.arguments = arguments
self.configuration = configuration
self.quiet = quiet
self.shell = shell
self.vaultFilePath = vaultFilePath
}
}
}
extension CliClient: DependencyKey {
public static func live(
env: [String: String]
) -> Self {
@Dependency(\.logger) var logger
return .init { options in
@Dependency(\.asyncShellClient) var shellClient
if !options.quiet {
try await shellClient.foreground(.init(
shell: options.shell,
environment: ProcessInfo.processInfo.environment,
in: nil,
options.arguments
))
} else {
try await shellClient.background(.init(
shell: options.shell,
environment: ProcessInfo.processInfo.environment,
in: nil,
options.arguments
))
}
} generateJSON: { options, loggingOptions, encoder in
let data = try await createJSONData(options, logging: loggingOptions, encoder: encoder)
guard let string = String(data: data, encoding: .utf8) else {
throw CliClientError.encodingError
}
return string
}
}
public static var liveValue: CliClient {
.live(env: ProcessInfo.processInfo.environment)
}
public static let testValue: CliClient = Self()
public static func capturing(_ client: CapturingClient) -> Self {
.init { options in
await client.set(options)
} generateJSON: {
try await Self().generateJSON($0, $1, $2)
}
}
public actor CapturingClient: Sendable {
public private(set) var quiet: Bool?
public private(set) var shell: ShellCommand.Shell?
public private(set) var arguments: [String]?
public init() {}
public func set(
_ options: RunCommandOptions
) {
quiet = options.quiet
shell = options.shell
arguments = options.arguments
}
}
}

View File

@@ -1,29 +0,0 @@
import Dependencies
import Logging
import ShellClient
// TODO: Remove.
public extension CliClient {
@discardableResult
func withLogger<T>(
_ options: LoggingOptions,
operation: @Sendable @escaping () async throws -> T
) async rethrows -> T {
try await Self.withLogger(options, operation: operation)
}
@discardableResult
static func withLogger<T>(
_ options: LoggingOptions,
operation: @Sendable @escaping () async throws -> T
) async rethrows -> T {
try await withDependencies {
$0.logger = .init(label: "\(Constants.executableName)")
$0.logger.logLevel = options.logLevel
$0.logger[metadataKey: "command"] = "\(options.commandName.blue)"
} operation: {
try await operation()
}
}
}

View File

@@ -4,6 +4,8 @@ import Foundation
import TOMLKit
public extension DependencyValues {
/// Holds onto decoders and encoders for json and toml files.
var coders: Coders {
get { self[Coders.self] }
set { self[Coders.self] = newValue }

View File

@@ -110,15 +110,33 @@ public struct CommandClient: Sendable {
}
}
/// 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
@@ -204,7 +222,12 @@ extension ShellCommand.Shell {
if let path {
self = .custom(path: path, useDashC: true)
} else {
self = .zsh(useDashC: true)
#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
}
}
}

View File

@@ -2,13 +2,28 @@ import Foundation
// NOTE: When adding items, then the 'hpa.toml' resource file needs to be updated.
/// Represents configurable settings for the command line tool.
/// Represents configurable settings for the application.
public struct Configuration: Codable, Equatable, Sendable {
/// Default arguments / options that can get passed into
/// ansible-playbook commands.
public let args: [String]?
/// Whether to use the vault arguments as defaults that get passed into
/// the ansible-playbook commands.
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?
/// Template configuration options.
public let template: Template
/// Ansible-vault configuration options.
public let vault: Vault
public init(
@@ -37,11 +52,34 @@ 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(
@@ -58,8 +96,10 @@ public struct Configuration: Codable, Equatable, Sendable {
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",
@@ -71,10 +111,22 @@ public struct Configuration: Codable, Equatable, Sendable {
}
}
/// 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 {
/// The directory location of the ansible playbook.
public let directory: String?
/// The inventory file name / location.
public let inventory: String?
/// The playbook version, branch, or tag.
public let version: String?
public init(
@@ -90,9 +142,28 @@ public struct Configuration: Codable, Equatable, Sendable {
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 {
/// A url of the template when it is a remote git repository.
public let url: String?
/// The version, branch, or tag of the remote repository.
public let version: String?
/// The local directory that contains the template on the local file system.
public let directory: String?
public init(
@@ -114,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 {
/// A list of arguments / options that get passed to the `ansible-vault` command.
///
/// - SeeAlso: `ansible-vault --help`
public let args: [String]?
/// An id that is used during encrypting `ansible-vault` files.
///
/// - SeeAlso: `ansible-vault encrypt --help`
public let encryptId: String?
public init(

View File

@@ -1,4 +1,5 @@
import CodersClient
import CommandClient
import Dependencies
import DependenciesMacros
import FileClient
@@ -6,37 +7,119 @@ import Foundation
import ShellClient
public extension DependencyValues {
/// Interacts with the user's configuration.
var configurationClient: ConfigurationClient {
get { self[ConfigurationClient.self] }
set { self[ConfigurationClient.self] = newValue }
}
}
/// Represents actions that can be taken on user's configuration files.
///
///
@DependencyClient
public struct ConfigurationClient: Sendable {
public var find: @Sendable () async throws -> File
var generate: @Sendable (File, Bool) async throws -> Void
public var load: @Sendable (File?) async throws -> Configuration
var write: @Sendable (File, Configuration, Bool) async throws -> Void
/// 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 {
let file = try? await find()
return try await load(file)
}
public func generate(
at file: File,
force: Bool = false
) async throws {
try await generate(file, force)
}
/// Write the configuration to the given file, optionally forcing an overwrite of
/// the file. If a file already exists and force is not supplied then we will create
/// a backup of the existing 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.
///
/// - 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(
_ configuration: Configuration,
to file: File,
force: Bool = false
) 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,15 +128,12 @@ extension ConfigurationClient: DependencyKey {
public static func live(environment: [String: String]) -> Self {
let liveClient = LiveConfigurationClient(environment: environment)
return .init {
try await liveClient.find()
} generate: { file, force in
try await liveClient.generate(at: file, force: force)
} load: { file in
try await liveClient.load(file: file)
} write: { file, configuration, force in
try await liveClient.write(configuration, to: file, force: force)
}
return .init(
find: { try await liveClient.find() },
generate: { try await liveClient.generate($0) },
load: { try await liveClient.load(file: $0) },
write: { try await liveClient.write($0) }
)
}
public static var liveValue: Self {
@@ -76,6 +156,7 @@ struct LiveConfigurationClient {
private let environment: [String: String]
@Dependency(\.coders) var coders
@Dependency(\.commandClient) var commandClient
@Dependency(\.fileClient) var fileManager
@Dependency(\.logger) var logger
@@ -126,10 +207,24 @@ struct LiveConfigurationClient {
throw ConfigurationError.configurationNotFound
}
func generate(at file: File, force: Bool) async throws {
func generate(_ options: ConfigurationClient.GenerateOptions) async throws -> String {
@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(
of: "~",
@@ -138,10 +233,10 @@ struct LiveConfigurationClient {
let fileUrl = URL(filePath: expandedPath)
if !force {
guard !fileManager.fileExists(fileUrl) else {
throw ConfigurationError.fileExists(path: file.path)
}
let exists = fileManager.fileExists(fileUrl)
if !options.force, exists {
try await createBackup(file.url)
}
let fileDirectory = fileUrl.deletingLastPathComponent()
@@ -153,21 +248,19 @@ struct LiveConfigurationClient {
}
if case .toml = file {
// In the case of toml, we copy the internal resource that includes
// usage comments in the file.
guard let resourceFile = Bundle.module.url(
forResource: HPAKey.resourceFileName,
withExtension: HPAKey.resourceFileExtension
) else {
throw ConfigurationError.resourceNotFound
}
try await fileManager.copy(resourceFile, fileUrl)
// Copy the file using curl, because when installed as a pre-built binary we
// don't have access to bundled resources.
try await commandClient.run(
quiet: true,
["curl", HPAKey.tomlConfigUrl, "--output", fileUrl.path]
)
} else {
// Json does not allow comments, so we write the mock configuration
// to the file path.
try await write(.mock, to: File(fileUrl)!, force: force)
try await write(.init(.mock, to: File(fileUrl)!, force: options.force))
}
return fileUrl.cleanFilePath
}
func load(file: File?) async throws -> Configuration {
@@ -186,14 +279,16 @@ struct LiveConfigurationClient {
}
func write(
_ configuration: Configuration,
to file: File,
force: Bool
_ options: ConfigurationClient.WriteOptions
) async throws {
if !force {
guard !fileManager.fileExists(file.url) else {
throw ConfigurationError.fileExists(path: file.path)
}
let configuration = options.configuration
let file = options.file
let force = options.force
let exists = fileManager.fileExists(file.url)
if !force, exists {
try await createBackup(file.url)
}
let data: Data
@@ -209,6 +304,13 @@ struct LiveConfigurationClient {
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? {
for file in validFileNames {
let url = directory.appending(path: file)

View File

@@ -1,3 +1,5 @@
/// Represents keys in the environment that can be used to locate a user's
/// configuration file.
@_spi(Internal)
public enum EnvironmentKey {
static let xdgConfigHome = "XDG_CONFIG_HOME"
@@ -5,12 +7,15 @@ public enum EnvironmentKey {
static let hpaConfigFile = "HPA_CONFIG_FILE"
}
/// Represents keys that are used internally for directory names, file names, etc.
@_spi(Internal)
public enum HPAKey {
public static let configDirName = "hpa"
public static let resourceFileName = "hpa"
public static let resourceFileExtension = "toml"
public static let defaultFileName = "config.toml"
public static let defaultFileNameWithoutExtension = "config"
public static let tomlConfigUrl = "https://git.housh.dev/michael/swift-hpa/raw/branch/main/Sources/ConfigurationClient/Resources/hpa.toml"
}
extension [String: String] {

View File

@@ -2,11 +2,21 @@ import FileClient
import Foundation
/// 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 {
case json(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) {
if url.cleanFilePath.hasSuffix("json") {
self = .json(url)
@@ -17,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) {
self.init(URL(filePath: path))
}
public static func json(_ path: String) -> Self {
.json(URL(filePath: path))
}
public static func toml(_ path: String) -> Self {
.toml(URL(filePath: path))
}
/// Get the url of the file.
public var url: URL {
switch self {
case let .json(url): return url
@@ -36,10 +45,14 @@ public enum File: Equatable, Sendable {
}
}
/// Get the path string of the file.
public var path: String {
url.cleanFilePath
}
/// Represents the default file path for a user's configuration.
///
/// Which is `~/.config/hpa/config.toml`
public static var `default`: Self {
let fileUrl = FileManager.default
.homeDirectoryForCurrentUser

View File

@@ -3,22 +3,53 @@ import DependenciesMacros
import Foundation
public extension DependencyValues {
/// Represents interactions with the file system.
///
var fileClient: FileClient {
get { self[FileClient.self] }
set { self[FileClient.self] = newValue }
}
}
/// Represents interactions with the file system.
///
///
@DependencyClient
public struct FileClient: Sendable {
/// Copy an item from one location to another.
public var copy: @Sendable (URL, URL) async throws -> Void
/// Create a directory at the given location.
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 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: "~/") }
/// Check if an item is a directory or not.
public var isDirectory: @Sendable (URL) async throws -> Bool
/// Load a file from the given location.
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
/// 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 {
@@ -26,23 +57,17 @@ extension FileClient: DependencyKey {
public static var liveValue: Self {
let manager = LiveFileClient()
return .init {
try await manager.copy($0, to: $1)
} createDirectory: {
try await manager.creatDirectory($0)
} fileExists: { url in
manager.fileExists(at: url)
} findVaultFileInCurrentDirectory: {
try await manager.findVaultFileInCurrentDirectory()
} homeDirectory: {
manager.homeDirectory()
} isDirectory: {
manager.isDirectory($0)
} load: { url in
try await manager.load(from: url)
} write: { data, url in
try await manager.write(data, to: url)
}
return .init(
copy: { try await manager.copy($0, to: $1) },
createDirectory: { try await manager.creatDirectory($0) },
delete: { try await manager.delete($0) },
fileExists: { manager.fileExists(at: $0) },
findVaultFile: { try await manager.findVaultFile(in: $0) },
homeDirectory: { manager.homeDirectory() },
isDirectory: { manager.isDirectory($0) },
load: { try await manager.load(from: $0) },
write: { try await manager.write($0, to: $1) }
)
}
}
@@ -58,30 +83,35 @@ struct LiveFileClient: Sendable {
try manager.createDirectory(at: url, withIntermediateDirectories: true)
}
func delete(_ url: URL) async throws {
try manager.removeItem(at: url)
}
func fileExists(at url: URL) -> Bool {
manager.fileExists(atPath: url.cleanFilePath)
}
func findVaultFileInCurrentDirectory() async throws -> URL? {
let urls = try manager.contentsOfDirectory(
at: URL(filePath: "./"),
includingPropertiesForKeys: nil
)
func findVaultFile(in url: URL) async throws -> URL? {
guard isDirectory(url) else { return nil }
let urls = try manager.contentsOfDirectory(at: url, 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) }
for folder in subfolders {
let vault = try manager.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: nil
)
.firstVaultFile
let subfolders = urls.filter { isDirectory($0) }
for folder in subfolders {
let files = try manager.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: nil
)
if let vault = files.firstVaultFile { return vault }
if let vault { return vault }
}
// Didn't find a file.
return nil
}
return nil
return vault
}
func homeDirectory() -> URL {
@@ -90,7 +120,11 @@ struct LiveFileClient: Sendable {
func isDirectory(_ url: URL) -> Bool {
var isDirectory: ObjCBool = false
manager.fileExists(atPath: url.cleanFilePath, isDirectory: &isDirectory)
#if os(Linux)
_ = manager.fileExists(atPath: url.cleanFilePath, isDirectory: &isDirectory)
#else
manager.fileExists(atPath: url.cleanFilePath, isDirectory: &isDirectory)
#endif
return isDirectory.boolValue
}
@@ -114,6 +148,8 @@ private extension Array where Element == URL {
public extension URL {
var cleanFilePath: String {
absoluteString.replacing("file://", with: "")
absoluteString
.replacing("file://", with: "")
.replacing("/private", with: "")
}
}

View 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"
}
}

View 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 {}

View 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)
}
}

View File

@@ -1,4 +1,6 @@
// TODO: Use an actuall version tag for playbook repo.
// 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 {

View File

@@ -81,6 +81,8 @@ extension PlaybookClient.RunPlaybook.SharedRunOptions {
) async throws -> T {
@Dependency(\.commandClient) var commandClient
try await ensurePlaybookExists()
return try await commandClient.run(
logging: loggingOptions,
quiet: quiet,
@@ -101,6 +103,17 @@ extension PlaybookClient.RunPlaybook.SharedRunOptions {
return (arguments, output)
}
}
private func ensurePlaybookExists() async throws {
@Dependency(\.fileClient) var fileClient
@Dependency(\.playbookClient.repository) var repository
let directory = try await repository.directory()
let exists = try await fileClient.isDirectory(URL(filePath: directory))
if !exists {
try await repository.install()
}
}
}
@_spi(Internal)

View File

@@ -9,6 +9,11 @@ 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 }
@@ -17,21 +22,36 @@ public extension DependencyValues {
@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)
}
@@ -40,23 +60,56 @@ public extension PlaybookClient {
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 {
public let extraOptions: [String]?
public let inventoryFilePath: String?
public let loggingOptions: LoggingOptions
public let quiet: Bool
public let shell: String?
/// 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,
@@ -72,11 +125,21 @@ public extension PlaybookClient {
}
}
/// Options require when calling the build project command.
@dynamicMemberLookup
public struct BuildOptions: Equatable, Sendable {
public let projectDirectory: String?
public let shared: SharedRunOptions
/// 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
@@ -90,13 +153,25 @@ public extension PlaybookClient {
}
}
/// Options required when creating a new home performance assessment project.
@dynamicMemberLookup
public struct CreateOptions: Equatable, Sendable {
public let projectDirectory: String
public let shared: SharedRunOptions
public let template: Configuration.Template?
public let useLocalTemplateDirectory: Bool
/// 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,
@@ -114,13 +189,25 @@ public extension PlaybookClient {
}
}
/// Options required when generating a new template repository / directory.
@dynamicMemberLookup
public struct GenerateTemplateOptions: Equatable, Sendable {
public let shared: SharedRunOptions
public let templateDirectory: String
public let templateVarsDirectory: String?
public let useVault: Bool
/// 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,

View File

@@ -4,9 +4,8 @@ import Dependencies
import DependenciesMacros
import FileClient
// TODO: Add edit / view routes, possibly create?
public extension DependencyValues {
/// Manages interactions with `ansible-vault` command line application.
var vaultClient: VaultClient {
get { self[VaultClient.self] }
set { self[VaultClient.self] = newValue }
@@ -15,23 +14,41 @@ public extension DependencyValues {
@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 {
public let extraOptions: [String]?
public let loggingOptions: LoggingOptions
public let outputFilePath: String?
public let quiet: Bool
public let shell: String?
public let vaultFilePath: String?
/// 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,
@@ -51,8 +68,8 @@ public struct VaultClient: Sendable {
@_spi(Internal)
public enum Route: String, Equatable, Sendable {
case encrypt
case decrypt
case encrypt
public var verb: String { rawValue }
}
@@ -65,12 +82,8 @@ extension VaultClient: DependencyKey {
public static var liveValue: VaultClient {
.init(
run: .init(
decrypt: {
try await $0.run(route: .decrypt)
},
encrypt: {
try await $0.run(route: .encrypt)
}
decrypt: { try await $0.run(route: .decrypt) },
encrypt: { try await $0.run(route: .encrypt) }
)
)
}

View File

@@ -1,5 +1,4 @@
import ArgumentParser
import CliClient
import Dependencies
import Foundation
import PlaybookClient

View File

@@ -1,5 +1,4 @@
import ArgumentParser
import CliClient
import ConfigurationClient
import Dependencies
import Foundation
@@ -41,7 +40,10 @@ struct CreateCommand: AsyncParsableCommand {
var templateDir: String?
@Flag(
name: .shortAndLong,
name: [
.short,
.customLong("use-local-template")
],
help: "Force using a local template directory."
)
var localTemplateDir = false
@@ -71,14 +73,3 @@ struct CreateCommand: AsyncParsableCommand {
print(projectDir)
}
}
private extension CreateCommand {
var generateJsonOptions: CliClient.GenerateJsonOptions {
.init(
templateDirectory: templateDir,
templateRepo: repo,
version: branch,
useLocalTemplateDirectory: localTemplateDir
)
}
}

View File

@@ -1,6 +1,6 @@
import ArgumentParser
import CliClient
import Dependencies
import PandocClient
// TODO: Need to add a step to build prior to generating file.
struct GenerateHtmlCommand: AsyncParsableCommand {
@@ -13,12 +13,12 @@ struct GenerateHtmlCommand: AsyncParsableCommand {
@OptionGroup var globals: GenerateOptions
mutating func run() async throws {
@Dependency(\.cliClient) var cliClient
@Dependency(\.pandocClient) var pandocClient
try await cliClient.runPandocCommand(
globals.pandocOptions(.html),
logging: globals.loggingOptions(commandName: Self.commandName)
let output = try await pandocClient.run.generateHtml(
globals.pandocRunOptions(commandName: Self.commandName)
)
print(output)
}
}

View File

@@ -1,5 +1,4 @@
import ArgumentParser
import CliClient
import Dependencies
// TODO: Need to add a step to build prior to generating file.
@@ -13,11 +12,11 @@ struct GenerateLatexCommand: AsyncParsableCommand {
@OptionGroup var globals: GenerateOptions
mutating func run() async throws {
@Dependency(\.cliClient) var cliClient
@Dependency(\.pandocClient) var pandocClient
try await cliClient.runPandocCommand(
globals.pandocOptions(.latex),
logging: globals.loggingOptions(commandName: Self.commandName)
let output = try await pandocClient.run.generateLatex(
globals.pandocRunOptions(commandName: Self.commandName)
)
print(output)
}
}

View File

@@ -1,11 +1,19 @@
import ArgumentParser
import CliClient
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.",
@@ -65,20 +73,20 @@ struct GenerateOptions: ParsableArguments {
extension GenerateOptions {
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
func loggingOptions(commandName: String) -> LoggingOptions {
basic.loggingOptions(commandName: commandName)
}
func pandocOptions(
_ fileType: CliClient.PandocOptions.FileType
) -> CliClient.PandocOptions {
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,
outputFileName: outputFileName,
outputFileType: fileType,
projectDirectory: projectDirectory,
outputFileName: outputFileName,
quiet: basic.quiet,
shell: basic.shell,
shouldBuild: !noBuild

View File

@@ -1,6 +1,6 @@
import ArgumentParser
import CliClient
import Dependencies
import PandocClient
// TODO: Need to add a step to build prior to generating file.
@@ -20,11 +20,11 @@ struct GeneratePdfCommand: AsyncParsableCommand {
@OptionGroup var globals: GenerateOptions
mutating func run() async throws {
@Dependency(\.cliClient) var cliClient
@Dependency(\.pandocClient) var pandocClient
let output = try await cliClient.runPandocCommand(
globals.pandocOptions(.pdf(engine: pdfEngine)),
logging: globals.loggingOptions(commandName: Self.commandName)
let output = try await pandocClient.run.generatePdf(
globals.pandocRunOptions(commandName: Self.commandName),
pdfEngine: pdfEngine
)
print(output)

View File

@@ -3,6 +3,9 @@ import Rainbow
// Constant string values.
enum Constants {
static let appName = "hpa"
static let brewPackages = [
"ansible", "imagemagick", "pandoc", "texLive"
]
static let playbookFileName = "main.yml"
static let inventoryFileName = "inventory.ini"
static let importantExtraArgsNote = """

View File

@@ -1,5 +1,5 @@
import ArgumentParser
import CliClient
import CommandClient
import ConfigurationClient
import PlaybookClient
@@ -59,10 +59,8 @@ struct GlobalOptions: ParsableArguments {
}
// TODO: Update these to use CommandClient.LoggingOptions
extension GlobalOptions {
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
func loggingOptions(commandName: String) -> LoggingOptions {
.init(
commandName: commandName,
logLevel: .init(globals: basic, quietOnlyPlaybook: quietOnlyPlaybook)
@@ -71,7 +69,7 @@ extension GlobalOptions {
}
extension BasicGlobalOptions {
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
func loggingOptions(commandName: String) -> LoggingOptions {
.init(
commandName: commandName,
logLevel: .init(globals: self, quietOnlyPlaybook: false)
@@ -79,24 +77,6 @@ extension BasicGlobalOptions {
}
}
// TODO: Remove
extension GlobalOptions {
func playbookOptions(
arguments: [String],
configuration: Configuration?
) -> CliClient.PlaybookOptions {
.init(
arguments: arguments,
configuration: configuration,
inventoryFilePath: inventoryPath,
playbookDirectory: playbookDirectory,
quiet: quietOnlyPlaybook ? true : basic.quiet,
shell: basic.shell
)
}
}
extension GlobalOptions {
func sharedPlaybookRunOptions(
commandName: String,

View File

@@ -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
)
}
}

View 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)
}
}

View File

@@ -1,6 +1,6 @@
import ArgumentParser
import CliClient
import CliDoc
import CommandClient
import ConfigurationClient
import Dependencies
@@ -16,14 +16,14 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
Note {
"""
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 {
"EXAMPLE:".yellow.bold
"Create a directory and generate the configuration".green
ShellCommand("mkdir -p ~/.config/hpa-playbook")
ShellCommand("hpa generate-config --path ~/.config/hpa-playbook")
ShellCommand("mkdir -p ~/.config/hpa")
ShellCommand("hpa generate-config --path ~/.config/hpa")
}
}
.separator(.newLine(count: 2))
@@ -39,7 +39,7 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
@Flag(
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
@@ -56,26 +56,18 @@ struct GenerateConfigurationCommand: AsyncParsableCommand {
}
private func _run() async throws {
@Dependency(\.cliClient) var cliClient
@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
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")") {
actualPath = file
} else {
actualPath = .default
}
logger.debug("Generating config at path: \(actualPath.path)")
try await configurationClient.generate(
at: actualPath,
force: force
)
print(output)
}
}
}

View File

@@ -1,5 +1,4 @@
import ArgumentParser
import CliClient
import Dependencies
import PlaybookClient

View File

@@ -1,6 +1,6 @@
import ArgumentParser
import CliClient
import CliDoc
import CommandClient
import Dependencies
struct InstallDependenciesCommand: AsyncParsableCommand {
@@ -33,11 +33,20 @@ struct InstallDependenciesCommand: AsyncParsableCommand {
var extraOptions: [String] = []
mutating func run() async throws {
@Dependency(\.cliClient) var cliClient
try await cliClient.installDependencies(
@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,
extraArgs: extraOptions
arguments
)
try await playbookClient.repository.install()
}
}

View File

@@ -10,6 +10,7 @@ struct UtilsCommand: AsyncParsableCommand {
These are commands that are generally only run on occasion / less frequently used.
""",
subcommands: [
DumpConfigCommand.self,
GenerateProjectTemplateCommand.self,
GenerateConfigurationCommand.self,
InstallDependenciesCommand.self

View File

@@ -1,5 +1,4 @@
import ArgumentParser
import CliClient
import Dependencies
import VaultClient

View File

@@ -0,0 +1 @@

View File

@@ -1,5 +1,4 @@
import ArgumentParser
import CliClient
import Dependencies
import VaultClient

View File

@@ -24,7 +24,7 @@ struct VaultCommand: AsyncParsableCommand {
.separator(.newLine(count: 2))
},
subcommands: [
EncryptCommand.self, DecryptCommand.self
DecryptCommand.self, EncryptCommand.self
]
)
}

View File

@@ -1,5 +1,5 @@
import ArgumentParser
import CliClient
import CommandClient
import VaultClient
// Holds the common options for vault commands, as they all share the
@@ -32,12 +32,6 @@ struct VaultOptions: ParsableArguments {
}
extension VaultOptions {
func loggingOptions(commandName: String) -> CliClient.LoggingOptions {
globals.loggingOptions(commandName: commandName)
}
}
extension VaultOptions {
func runOptions(

View File

@@ -1,2 +1,2 @@
// Do not set this variable, it is set during the build process.
let VERSION: String? = nil
let VERSION: String? = "0.1.5"

7
TODO.md Normal file
View File

@@ -0,0 +1,7 @@
# TODO
- [ ] Build docker images in ci.
- [ ] Generate documentation for docker usage.
- [ ] Generally need to create a local wrapper script to mount volumes.
- [ ] Completions can be installed / used with the wrapper script by calling
`docker run --it --rm <image> --generate-completion-script <shell> > /path/to/completions/on/local`

View File

@@ -1,333 +0,0 @@
@_spi(Internal) import CliClient
import ConfigurationClient
import Dependencies
import FileClient
import Foundation
import PlaybookClient
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" : {
"url" : "https://git.example.com/template.git",
"version" : "main"
}
}
}
""")
),
GenerateJsonTestOption(
options: .init(
templateDirectory: nil,
templateRepo: nil,
version: nil,
useLocalTemplateDirectory: false
),
configuration: .init(template: .init(url: "https://git.example.com/template.git", version: "v0.1.0")),
expectation: .success("""
{
"template" : {
"repo" : {
"url" : "https://git.example.com/template.git",
"version" : "v0.1.0"
}
}
}
""")
)
]
)
func generateJson(input: GenerateJsonTestOption) async {
await withTestLogger(key: "generateJson") {
$0.configurationClient = .mock(input.configuration ?? .init())
$0.cliClient = .liveValue
} 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)
$0.playbookClient = .liveValue
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
}()

View File

@@ -18,6 +18,9 @@ struct ConfigurationClientTests: TestCase {
@Test(arguments: ["config.toml", "config.json"])
func generateConfigFile(fileName: String) async throws {
try await withTestLogger(key: "generateConfigFile") {
$0.asyncShellClient = .liveValue
$0.commandClient = .liveValue
$0.coders = .liveValue
$0.fileClient = .liveValue
} operation: {
@Dependency(\.logger) var logger
@@ -26,17 +29,14 @@ struct ConfigurationClientTests: TestCase {
try await withTemporaryDirectory { tempDir in
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(fileClient.fileExists(tempFile))
// Ensure that we do not overwrite files if they exist.
do {
try await configuration.generate(at: File(tempFile)!, force: false)
#expect(Bool(false))
} catch {
#expect(Bool(true))
}
#expect(output == tempFile.cleanFilePath)
}
}
}
@@ -44,6 +44,9 @@ struct ConfigurationClientTests: TestCase {
@Test(arguments: ["config.toml", "config.json", nil])
func loadConfigFile(fileName: String?) async throws {
try await withTestLogger(key: "generateConfigFile") {
$0.asyncShellClient = .liveValue
$0.commandClient = .liveValue
$0.coders = .liveValue
$0.fileClient = .liveValue
} operation: {
@Dependency(\.logger) var logger
@@ -66,6 +69,8 @@ struct ConfigurationClientTests: TestCase {
@Test(arguments: ["config.toml", "config.json", ".hparc.json", ".hparc.toml"])
func findConfiguration(fileName: String) async throws {
try await withTestLogger(key: "findConfiguration") {
$0.asyncShellClient = .liveValue
$0.commandClient = .liveValue
$0.fileClient = .liveValue
} operation: {
@Dependency(\.logger) var logger
@@ -95,6 +100,8 @@ struct ConfigurationClientTests: TestCase {
@Test(arguments: ["config.toml", "config.json", ".hparc.json", ".hparc.toml"])
func findXdgConfiguration(fileName: String) async throws {
try await withTestLogger(key: "findXdgConfiguration") {
$0.asyncShellClient = .liveValue
$0.commandClient = .liveValue
$0.fileClient = .liveValue
} operation: {
@Dependency(\.logger) var logger
@@ -130,6 +137,29 @@ struct ConfigurationClientTests: TestCase {
}
}
}
@Test
func writeCreatesBackupFile() async throws {
try await withDependencies {
$0.asyncShellClient = .liveValue
$0.commandClient = .liveValue
$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]] {
@@ -150,7 +180,11 @@ func withGeneratedConfigFile(
) async rethrows {
try await withTemporaryDirectory { tempDir in
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)
}
}
@@ -167,7 +201,11 @@ func withGeneratedXDGConfigFile(
withIntermediateDirectories: false
)
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)
}
}

View File

@@ -0,0 +1,75 @@
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)
#if os(Linux)
_ = FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
#else
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
#endif
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)
#if os(Linux)
_ = FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
#else
FileManager.default.createFile(atPath: vaultFilePath.cleanFilePath, contents: nil)
#endif
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)
}
}
}

View 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)
)
)
]
}

View File

@@ -22,12 +22,14 @@ struct PlaybookClientTests: TestCase {
@Test(.tags(.repository))
func repositoryInstallation() async throws {
try await withDependencies {
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
@@ -35,7 +37,8 @@ struct PlaybookClientTests: TestCase {
try? FileManager.default.removeItem(at: pathUrl)
try await playbookClient.repository.install(configuration)
let exists = FileManager.default.fileExists(atPath: pathUrl.cleanFilePath)
logger.debug("Done cloning playbook")
let exists = try await fileClient.isDirectory(pathUrl)
#expect(exists)
}
}
@@ -61,8 +64,6 @@ struct PlaybookClientTests: TestCase {
try await withMockConfiguration(captured, key: "runBuildProject") {
@Dependency(\.playbookClient) var playbookClient
let configuration = Configuration.mock
try await playbookClient.run.buildProject(.init(projectDirectory: "/foo", shared: Self.sharedRunOptions))
let arguments = await captured.options!.arguments
@@ -92,8 +93,6 @@ struct PlaybookClientTests: TestCase {
@Dependency(\.logger) var logger
@Dependency(\.playbookClient) var playbookClient
let configuration = Configuration.mock
try await playbookClient.run.createProject(
.init(
projectDirectory: "/project",
@@ -146,6 +145,7 @@ struct PlaybookClientTests: TestCase {
@Test
func generateTemplate() async throws {
try await withCapturingCommandClient("generateTemplate") {
$0.fileClient.isDirectory = { _ in true }
$0.configurationClient = .mock()
$0.playbookClient = .liveValue
} run: {
@@ -181,6 +181,7 @@ struct PlaybookClientTests: TestCase {
operation: @Sendable @escaping () async throws -> Void
) async rethrows {
try await withDependencies {
$0.fileClient.isDirectory = { _ in true }
$0.configurationClient = .mock(configuration)
$0.commandClient = .capturing(capturing)
$0.playbookClient = .liveValue

View File

@@ -14,7 +14,7 @@ struct VaultClientTests: TestCase {
func decrypt(input: TestOptions) async throws {
try await withCapturingCommandClient("decrypt") {
$0.configurationClient = .mock(input.configuration)
$0.fileClient.findVaultFileInCurrentDirectory = { URL(filePath: "/vault.yml") }
$0.fileClient.findVaultFile = { _ in URL(filePath: "/vault.yml") }
$0.vaultClient = .liveValue
} run: {
@Dependency(\.vaultClient) var vaultClient
@@ -45,7 +45,7 @@ struct VaultClientTests: TestCase {
func encrypt(input: TestOptions) async throws {
try await withCapturingCommandClient("decrypt") {
$0.configurationClient = .mock(input.configuration)
$0.fileClient.findVaultFileInCurrentDirectory = { URL(filePath: "/vault.yml") }
$0.fileClient.findVaultFile = { _ in URL(filePath: "/vault.yml") }
$0.vaultClient = .liveValue
} run: {
@Dependency(\.vaultClient) var vaultClient

77
docker/Dockerfile Executable file
View File

@@ -0,0 +1,77 @@
# Used this to build the release version of the image.
# Build the executable
ARG SWIFT_IMAGE_VERSION="6.0.3"
# ============================================================
# Build Swift Image
# ============================================================
FROM docker.io/swift:${SWIFT_IMAGE_VERSION} AS build
# Install OS updates
RUN export DEBIAN_FRONTEND=nointeractive DEBCONF_NOINTERACTIVE_SEEN=true && \
apt-get -q update && \
apt-get -q dist-upgrade -y && \
apt-get install -y libjemalloc-dev
WORKDIR /build
# Resolve dependencies, this creates a cached layer.
COPY ./Package.* ./
RUN --mount=type=cache,target=/build/.build swift package resolve
COPY . .
# Build the application.
RUN --mount=type=cache,target=/build/.build \
swift build -c release \
--product hpa \
--static-swift-stdlib \
-Xlinker -ljemalloc
# Switch to staging area.
WORKDIR /staging
# Copy main executable to staging area.
RUN --mount=type=cache,target=/build/.build \
cp "$(swift build --package-path /build -c release --show-bin-path)/hpa" ./
# ============================================================
# Run Image
# ============================================================
FROM docker.io/ubuntu:noble
# Update base image and install needed packages.
#
# NOTE: NB: Installs vim as minimal text editor to use inside the container, bc
# when I mount my home directory / use my neovim config it requires
# neovim v11+, but generally only going to edit ansible vault files
# inside the container.
RUN export DEBIAN_FRONTEND=nointeractive DEBCONF_NOINTERACTIVE_SEEN=true && \
apt-get -q update && \
apt-get -q dist-upgrade -y && \
apt-get -q install -y \
ansible \
curl \
imagemagick \
pandoc \
texlive \
texlive-xetex \
libjemalloc2 \
libcurl4 \
tzdata \
vim \
&& rm -r /var/lib/apt/lists/*
# Install the hpa executable.
COPY --from=build /staging/hpa /usr/local/bin
# Install the entrypoint script and make execuatable.
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && mkdir /root/project
# Set workdir and volume mounts.
WORKDIR /root/project
VOLUME /root/project
ENTRYPOINT [ "/entrypoint.sh" ]
CMD ["--help"]

15
docker/Dockerfile.test Normal file
View File

@@ -0,0 +1,15 @@
# Used to build a test image.
ARG SWIFT_IMAGE_VERSION="6.0.3"
FROM swift:${SWIFT_IMAGE_VERSION}
RUN export DEBIAN_FRONTEND=nointeractive DEBCONF_NOINTERACTIVE_SEEN=true && apt-get -q update && \
apt-get -q install -y \
curl \
&& rm -r /var/lib/apt/lists/*
WORKDIR /app
COPY ./Package.* ./
RUN swift package resolve
COPY . .
RUN swift build
CMD ["/bin/bash", "-xc", "swift", "test"]

27
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
declare -a args
# Allows to attach to a shell inside the container, or run ansbile commands,
# otherwise run the 'hpa' script with the given arguments.
#
while [[ $# -gt 0 ]]; do
if [[ $1 == "/bin/bash" ]] || [[ $1 == "bash" ]]; then
shift
/bin/bash "$@"
exit $?
elif [[ $1 == "/bin/sh" ]] || [[ $1 == "sh" ]]; then
shift
/bin/sh "$@"
exit $?
elif [[ $1 =~ ^ansible ]]; then
exec "$@"
exit $?
else
args+=("$1")
fi
shift
done
# If we made it here then run the hpa script.
/usr/local/bin/hpa "${args[@]}"

View File

@@ -1,25 +1,86 @@
docker_image_name := "swift-hpa"
install_path := "~/.local/share/bin/hpa"
completion_path := "~/.local/share/zsh/completions/_hpa"
build mode="debug":
swift build -c {{mode}}
tap_url := "https://git.housh.dev/michael/homebrew-formula"
tap := "michael/formula"
formula := "hpa"
release_base_url := "https://git.housh.dev/michael/swift-hpa/archive"
# Build and bottle homebrew formula.
bottle:
@brew uninstall {{formula}} || true
@brew tap {{tap}} {{tap_url}}
@brew install --build-bottle {{tap}}/{{formula}}
@brew bottle {{formula}}
bottle="$(ls *.gz)" && mv "${bottle}" "${bottle/--/-}"
# Build the command-line tool.
build configuration="debug" arch="arm64":
swift build \
--disable-sandbox \
--configuration {{configuration}} \
--arch {{arch}}
alias b := build
# Build the docker image.
build-docker file="Dockerfile" tag="latest":
@docker build \
--file docker/{{file}} \
--tag {{docker_image_name}}:{{tag}} .
# Build the docker test image used for testing.
build-docker-test: (build-docker "Dockerfile.test" "test")
build-universal-binary: (build "release" "arm64") (build "release" "x86_64")
@lipo -create -output {{formula}} \
".build/arm64-apple-macosx/release/hpa" \
".build/x86_64-apple-macosx/release/hpa"
# Run tests.
test *ARGS:
swift test {{ARGS}}
alias t := test
# Run tests in docker container.
test-docker *ARGS: (build-docker-test)
@docker run --rm \
--network host \
{{docker_image_name}}:test \
swift test {{ARGS}}
alias td := test-docker
# Remove bottles
remove-bottles:
rm -rf *.gz
# Run the application.
run *ARGS:
swift run hpa {{ARGS}}
alias r := run
# Clean the build folder.
clean:
rm -rf .build
# Bump the version based on the git tag.
update-version:
@swift package \
--disable-sandbox \
--allow-writing-to-package-directory \
update-version \
hpa
# Get the sha256 sum of the release and copy to clipboard.
get-release-sha prefix="": (build "release")
version=$(.build/release/hpa --version) && \
url="{{release_base_url}}/{{prefix}}${version}.tar.gz" && \
sha=$(curl "$url" | shasum -a 256) && \
stripped="${sha% *}" && \
echo "$stripped" | pbcopy && \
echo "Copied sha to clipboard: $stripped"