diff --git a/.claude/skills/swiftyshell.md b/.claude/skills/swiftyshell.md index ff5e2e4..b4a3843 100644 --- a/.claude/skills/swiftyshell.md +++ b/.claude/skills/swiftyshell.md @@ -33,14 +33,32 @@ Before writing any code, follow this decision tree: → Use `Gh` 9. Is this a Docker CLI operation (`docker buildx`, `docker compose`, `docker debug`, `docker mcp`, `docker scout`, ...)? → Use `Docker` -10. Does the operation need typed output, structured results, or conditional follow-up? - → Use the appropriate typed client -11. Are two or more commands chained by pipe? - → Use `.pipe(to:)` to build a `Pipeline` -12. Does the command write output to a file? - → Use `.stdout(.file(path:append:))` on the command -13. Is this any other command? - → Use `Command` +10. Is this a Makefile operation (`make check`, `make test`, `make -j8`, ...)? + → Use `Make` +11. Is this a Node.js runtime operation (`node --eval`, `node --check`, running a `.js` file, ...)? + → Use `Node` +12. Is this an npm operation (`npm ci`, `npm run build`, `npm exec`, ...)? + → Use `Npm` +13. Is this a Yarn operation (`yarn install`, `yarn run build`, `yarn dlx`, ...)? + → Use `Yarn` +14. Is this a pnpm operation (`pnpm install`, `pnpm run build`, `pnpm exec`, ...)? + → Use `Pnpm` +15. Is this a Bun operation (`bun run`, `bun test`, `bun build`, ...)? + → Use `Bun` +16. Is this a Terraform operation (`terraform init`, `terraform plan`, `terraform apply`, ...)? + → Use `Terraform` +17. Is this a Kubernetes CLI operation (`kubectl get`, `kubectl apply`, `kubectl logs`, ...)? + → Use `Kubectl` +18. Is this a Python interpreter operation (`python3 -m`, `python3 -c`, running a `.py` file, ...)? + → Use `Python` +19. Does the operation need typed output, structured results, or conditional follow-up? + → Use the appropriate typed client +20. Are two or more commands chained by pipe? + → Use `.pipe(to:)` to build a `Pipeline` +21. Does the command write output to a file? + → Use `.stdout(.file(path:append:))` on the command +22. Is this any other command? + → Use `Command` ### API Reference @@ -952,6 +970,296 @@ public struct Docker: RunnableCommandFamily { } ``` +#### Scripting CLIs + +```swift +public struct Make: RunnableCommandFamily { + public init(context: ShellContext = .init()) + public func file(_ path: String) -> Self + public func directory(_ path: String) -> Self + public func jobs(_ count: Int) -> Self + public func keepGoing(_ enabled: Bool = true) -> Self + public func silent(_ enabled: Bool = true) -> Self + public func dryRun(_ enabled: Bool = true) -> Self + public func alwaysMake(_ enabled: Bool = true) -> Self + public func argument(_ value: String) -> Self + public func arguments(_ values: [String]) -> Self + public func target(_ name: String) -> Self + public func targets(_ names: [String]) -> Self + public func command() -> Command + public func run() async throws -> ShellOutput +} + +public struct Node: RunnableCommandFamily { + public init(context: ShellContext = .init()) + public func version() -> Self + public func eval(_ code: String) -> Self + public func printExpression(_ code: String) -> Self + public func check(_ path: String) -> Self + public func script(_ path: String) -> Self + public func require(_ module: String) -> Self + public func inspect(_ enabled: Bool = true) -> Self + public func watch(_ enabled: Bool = true) -> Self + public func argument(_ value: String) -> Self + public func arguments(_ values: [String]) -> Self + public func scriptArgument(_ value: String) -> Self + public func scriptArguments(_ values: [String]) -> Self + public func command() -> Command + public func run() async throws -> ShellOutput +} + +public enum NpmSubcommand: String, Sendable, Equatable, Hashable { + case install + case ci + case run + case test + case publish + case exec + case outdated + case audit + case version +} + +public struct Npm: RunnableCommandFamily { + public init(context: ShellContext = .init()) + public func subcommand(_ value: NpmSubcommand) -> Self + public func subcommand(_ value: String) -> Self + public func install() -> Self + public func ci() -> Self + public func test() -> Self + public func exec(_ binary: String? = nil) -> Self + public func runScript(_ name: String) -> Self + public func prefix(_ path: String) -> Self + public func global(_ enabled: Bool = true) -> Self + public func production(_ enabled: Bool = true) -> Self + public func ifPresent(_ enabled: Bool = true) -> Self + public func silent(_ enabled: Bool = true) -> Self + public func json(_ enabled: Bool = true) -> Self + public func argument(_ value: String) -> Self + public func arguments(_ values: [String]) -> Self + public func positionalArgument(_ value: String) -> Self + public func positionalArguments(_ values: [String]) -> Self + public func command() -> Command + public func run() async throws -> ShellOutput +} + +public enum YarnSubcommand: String, Sendable, Equatable, Hashable { + case install + case add + case remove + case run + case test + case exec + case dlx + case workspaces + case version +} + +public struct Yarn: RunnableCommandFamily { + public init(context: ShellContext = .init()) + public func subcommand(_ value: YarnSubcommand) -> Self + public func subcommand(_ value: String) -> Self + public func install() -> Self + public func add(_ packages: String...) -> Self + public func add(_ packages: [String]) -> Self + public func remove(_ packages: String...) -> Self + public func remove(_ packages: [String]) -> Self + public func test() -> Self + public func exec(_ binary: String? = nil) -> Self + public func dlx(_ package: String? = nil) -> Self + public func runScript(_ name: String) -> Self + public func cwd(_ path: String) -> Self + public func immutable(_ enabled: Bool = true) -> Self + public func production(_ enabled: Bool = true) -> Self + public func silent(_ enabled: Bool = true) -> Self + public func json(_ enabled: Bool = true) -> Self + public func argument(_ value: String) -> Self + public func arguments(_ values: [String]) -> Self + public func positionalArgument(_ value: String) -> Self + public func positionalArguments(_ values: [String]) -> Self + public func command() -> Command + public func run() async throws -> ShellOutput +} + +public enum PnpmSubcommand: String, Sendable, Equatable, Hashable { + case install + case add + case remove + case run + case test + case exec + case dlx + case audit + case version +} + +public struct Pnpm: RunnableCommandFamily { + public init(context: ShellContext = .init()) + public func subcommand(_ value: PnpmSubcommand) -> Self + public func subcommand(_ value: String) -> Self + public func install() -> Self + public func add(_ packages: String...) -> Self + public func add(_ packages: [String]) -> Self + public func remove(_ packages: String...) -> Self + public func remove(_ packages: [String]) -> Self + public func test() -> Self + public func exec(_ binary: String? = nil) -> Self + public func dlx(_ package: String? = nil) -> Self + public func runScript(_ name: String) -> Self + public func directory(_ path: String) -> Self + public func filter(_ selector: String) -> Self + public func recursive(_ enabled: Bool = true) -> Self + public func ifPresent(_ enabled: Bool = true) -> Self + public func frozenLockfile(_ enabled: Bool = true) -> Self + public func production(_ enabled: Bool = true) -> Self + public func json(_ enabled: Bool = true) -> Self + public func argument(_ value: String) -> Self + public func arguments(_ values: [String]) -> Self + public func positionalArgument(_ value: String) -> Self + public func positionalArguments(_ values: [String]) -> Self + public func command() -> Command + public func run() async throws -> ShellOutput +} + +public enum BunSubcommand: String, Sendable, Equatable, Hashable { + case run + case test + case install + case add + case remove + case build + case x + case pm + case upgrade +} + +public struct Bun: RunnableCommandFamily { + public init(context: ShellContext = .init()) + public func subcommand(_ value: BunSubcommand) -> Self + public func subcommand(_ value: String) -> Self + public func install() -> Self + public func add(_ packages: String...) -> Self + public func add(_ packages: [String]) -> Self + public func remove(_ packages: String...) -> Self + public func remove(_ packages: [String]) -> Self + public func test() -> Self + public func build(_ entrypoints: String...) -> Self + public func build(_ entrypoints: [String]) -> Self + public func x(_ binary: String? = nil) -> Self + public func runScript(_ name: String) -> Self + public func cwd(_ path: String) -> Self + public func watch(_ enabled: Bool = true) -> Self + public func hot(_ enabled: Bool = true) -> Self + public func production(_ enabled: Bool = true) -> Self + public func frozenLockfile(_ enabled: Bool = true) -> Self + public func argument(_ value: String) -> Self + public func arguments(_ values: [String]) -> Self + public func positionalArgument(_ value: String) -> Self + public func positionalArguments(_ values: [String]) -> Self + public func command() -> Command + public func run() async throws -> ShellOutput +} + +public enum TerraformSubcommand: String, Sendable, Equatable, Hashable { + case version + case initialize + case plan + case apply + case destroy + case validate + case format + case output + case workspace +} + +public struct Terraform: RunnableCommandFamily { + public init(context: ShellContext = .init()) + public func subcommand(_ value: TerraformSubcommand) -> Self + public func subcommand(_ value: String) -> Self + public func initCommand() -> Self + public func plan() -> Self + public func apply() -> Self + public func destroy() -> Self + public func validate() -> Self + public func format() -> Self + public func output() -> Self + public func workspace(_ nested: String? = nil) -> Self + public func chdir(_ path: String) -> Self + public func input(_ enabled: Bool) -> Self + public func noColor(_ enabled: Bool = true) -> Self + public func json(_ enabled: Bool = true) -> Self + public func autoApprove(_ enabled: Bool = true) -> Self + public func refresh(_ enabled: Bool) -> Self + public func `var`(_ assignment: String) -> Self + public func `var`(_ key: String, _ value: String) -> Self + public func varFile(_ path: String) -> Self + public func out(_ path: String) -> Self + public func target(_ address: String) -> Self + public func argument(_ value: String) -> Self + public func arguments(_ values: [String]) -> Self + public func positionalArgument(_ value: String) -> Self + public func positionalArguments(_ values: [String]) -> Self + public func command() -> Command + public func run() async throws -> ShellOutput +} + +public enum KubectlSubcommand: String, Sendable, Equatable, Hashable { + case get + case describe + case apply + case delete + case logs + case exec + case rollout + case config + case version +} + +public struct Kubectl: RunnableCommandFamily { + public init(context: ShellContext = .init()) + public func subcommand(_ value: KubectlSubcommand) -> Self + public func subcommand(_ value: String) -> Self + public func get(_ resource: String? = nil) -> Self + public func describe(_ resource: String? = nil) -> Self + public func apply() -> Self + public func delete(_ resource: String? = nil) -> Self + public func logs(_ resource: String? = nil) -> Self + public func exec(_ resource: String? = nil) -> Self + public func kubeconfig(_ path: String) -> Self + public func contextName(_ name: String) -> Self + public func namespace(_ name: String) -> Self + public func output(_ format: String) -> Self + public func filename(_ path: String) -> Self + public func selector(_ value: String) -> Self + public func container(_ name: String) -> Self + public func allNamespaces(_ enabled: Bool = true) -> Self + public func argument(_ value: String) -> Self + public func arguments(_ values: [String]) -> Self + public func positionalArgument(_ value: String) -> Self + public func positionalArguments(_ values: [String]) -> Self + public func command() -> Command + public func run() async throws -> ShellOutput +} + +public struct Python: RunnableCommandFamily { + public init(context: ShellContext = .init()) + public func version() -> Self + public func module(_ name: String) -> Self + public func commandString(_ code: String) -> Self + public func script(_ path: String) -> Self + public func isolated(_ enabled: Bool = true) -> Self + public func unbuffered(_ enabled: Bool = true) -> Self + public func dontWriteBytecode(_ enabled: Bool = true) -> Self + public func optimize(_ level: Int = 1) -> Self + public func option(_ value: String) -> Self + public func options(_ values: [String]) -> Self + public func argument(_ value: String) -> Self + public func arguments(_ values: [String]) -> Self + public func command() -> Command + public func run() async throws -> ShellOutput +} +``` + #### Archives (Tar / Zip / Unzip) ```swift @@ -1621,7 +1929,7 @@ SwiftyShell uses [SwiftPM Package Traits](https://github.com/swiftlang/swift-evo Declared in `Package.swift`: -- **Per-family** — `Git`, `Brew`, `Grep`, `Fzf`, `Rg`, `Swift`, `Gh`, `Docker`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip`. One trait per family directory; for `Common/`, one trait per file. +- **Per-family** — `Git`, `Brew`, `Grep`, `Fzf`, `Rg`, `Swift`, `Gh`, `Docker`, `Make`, `Node`, `Npm`, `Yarn`, `Pnpm`, `Bun`, `Terraform`, `Kubectl`, `Python`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip`. One trait per family directory; for `Common/`, one trait per file. - **Umbrellas** — `CommonUtilities` (every `Common/*` family), `All` (every command family). Consumers select families with `traits:` on `.package(...)`: @@ -1634,7 +1942,7 @@ Consumers select families with `traits:` on `.package(...)`: The contract is enforced by `Scripts/validate-traits.swift` and CI: -1. Every `.swift` file under a gated source directory (`Git/`, `Brew/`, `Grep/`, `Fzf/`, `Rg/`, `Swift/`, `Gh/`, `Docker/`, and each file in `Common/`) is wrapped top-to-bottom in `#if ... #endif`. +1. Every `.swift` file under a gated source directory (`Git/`, `Brew/`, `Grep/`, `Fzf/`, `Rg/`, `Swift/`, `Gh/`, `Docker/`, `Make/`, `Node/`, `Npm/`, `Yarn/`, `Pnpm/`, `Bun/`, `Terraform/`, `Kubectl/`, `Python/`, and each file in `Common/`) is wrapped top-to-bottom in `#if ... #endif`. 2. Every test file targeting a gated family is wrapped the same way. Cross-family tests use combined guards (`#if Git && Grep`). 3. Every family directory (or `Common/*.swift` file) has a matching `.trait(name:)` entry in `Package.swift`. 4. The `All` umbrella's `enabledTraits` transitively enables every per-family trait. The `CommonUtilities` umbrella enables every `Common/*` trait. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4160811..fda2bac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: - name: Compute affected traits id: compute run: | - FULL='["", "Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "Gh", "Docker", "Rsync", "Tar", "Zip", "Unzip", "CommonUtilities", "All"]' + FULL='["", "Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "Gh", "Docker", "Make", "Node", "Npm", "Yarn", "Pnpm", "Bun", "Terraform", "Kubectl", "Python", "Rsync", "Tar", "Zip", "Unzip", "CommonUtilities", "All"]' CHANGED=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD") echo "Changed files:" @@ -56,6 +56,15 @@ jobs: echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Swift/|Tests/SwiftyShellTests/Swift/)' && family_traits+=("Swift") echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Gh/|Tests/SwiftyShellTests/Gh/)' && family_traits+=("Gh") echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Docker/|Tests/SwiftyShellTests/Docker/)' && family_traits+=("Docker") + echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Make/|Tests/SwiftyShellTests/Make/)' && family_traits+=("Make") + echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Node/|Tests/SwiftyShellTests/Node/)' && family_traits+=("Node") + echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Npm/|Tests/SwiftyShellTests/Npm/)' && family_traits+=("Npm") + echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Yarn/|Tests/SwiftyShellTests/Yarn/)' && family_traits+=("Yarn") + echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Pnpm/|Tests/SwiftyShellTests/Pnpm/)' && family_traits+=("Pnpm") + echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Bun/|Tests/SwiftyShellTests/Bun/)' && family_traits+=("Bun") + echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Terraform/|Tests/SwiftyShellTests/Terraform/)' && family_traits+=("Terraform") + echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Kubectl/|Tests/SwiftyShellTests/Kubectl/)' && family_traits+=("Kubectl") + echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Python/|Tests/SwiftyShellTests/Python/)' && family_traits+=("Python") echo "$CHANGED" | grep -qE '^(Sources/SwiftyShell/Common/|Tests/SwiftyShellTests/Common/)' && family_traits+=("CommonUtilities") traits+=("${family_traits[@]}") diff --git a/.github/workflows/reusable-ci.yml b/.github/workflows/reusable-ci.yml index f3b0b0d..2b776b8 100644 --- a/.github/workflows/reusable-ci.yml +++ b/.github/workflows/reusable-ci.yml @@ -25,7 +25,7 @@ on: Defaults to the full matrix when omitted. required: false type: string - default: '["", "Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "Gh", "Docker", "Rsync", "Tar", "Zip", "Unzip", "CommonUtilities", "All"]' + default: '["", "Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "Gh", "Docker", "Make", "Node", "Npm", "Yarn", "Pnpm", "Bun", "Terraform", "Kubectl", "Python", "Rsync", "Tar", "Zip", "Unzip", "CommonUtilities", "All"]' jobs: validate-traits: diff --git a/AGENTS.md b/AGENTS.md index d683654..0ed79a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,42 @@ Typed wrapper for the GitHub CLI: `Gh` and `GhSubcommand`. Typed wrapper for the Docker CLI: `Docker`, `DockerSubcommand`, and `DockerBuildProgress`. +### `Sources/SwiftyShell/Make/` + +Typed wrapper for Make build automation: `Make`. + +### `Sources/SwiftyShell/Node/` + +Typed wrapper for the Node.js runtime: `Node`. + +### `Sources/SwiftyShell/Npm/` + +Typed wrapper for the npm package manager: `Npm` and `NpmSubcommand`. + +### `Sources/SwiftyShell/Yarn/` + +Typed wrapper for the Yarn package manager: `Yarn` and `YarnSubcommand`. + +### `Sources/SwiftyShell/Pnpm/` + +Typed wrapper for the pnpm package manager: `Pnpm` and `PnpmSubcommand`. + +### `Sources/SwiftyShell/Bun/` + +Typed wrapper for the Bun runtime and package manager: `Bun` and `BunSubcommand`. + +### `Sources/SwiftyShell/Terraform/` + +Typed wrapper for the Terraform CLI: `Terraform` and `TerraformSubcommand`. + +### `Sources/SwiftyShell/Kubectl/` + +Typed wrapper for the Kubernetes CLI: `Kubectl` and `KubectlSubcommand`. + +### `Sources/SwiftyShell/Python/` + +Typed wrapper for the Python interpreter CLI: `Python`. + ### `Sources/SwiftyShell/Common/` Typed wrappers for frequently used shell utilities: `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `JqArgument`, `Rsync`, `Tar`, `TarOperation`, `TarCompression`, `Zip`, `ZipCompressionLevel`, `Unzip`, and `UnzipEntry`. Each follows the same fluent builder conventions as all other command families. @@ -52,7 +88,7 @@ DocC documentation catalog. `SwiftyShell.md` is the top-level landing page. Arti ### `Tests/SwiftyShellTests/` -Test suite. Sub-folders mirror the source layout: `Brew/`, `Common/`, `Core/`, `Docker/`, `Fzf/`, `Gh/`, `Git/`, `Grep/`, `Pipelines/`, `Rg/`, and `Swift/`. Test files for gated families are wrapped in `#if ` so the test target compiles under any trait selection. `Common/` has one test file per family (`LsTests.swift`, `CpTests.swift`, …) plus `CommonTestSupport.swift` (shared helpers, ungated). +Test suite. Sub-folders mirror the source layout: `Brew/`, `Bun/`, `Common/`, `Core/`, `Docker/`, `Fzf/`, `Gh/`, `Git/`, `Grep/`, `Kubectl/`, `Make/`, `Node/`, `Npm/`, `Pipelines/`, `Pnpm/`, `Python/`, `Rg/`, `Swift/`, `Terraform/`, and `Yarn/`. Test files for gated families are wrapped in `#if ` so the test target compiles under any trait selection. `Common/` has one test file per family (`LsTests.swift`, `CpTests.swift`, …) plus `CommonTestSupport.swift` (shared helpers, ungated). ### `Scripts/` @@ -173,12 +209,12 @@ SwiftyShell uses [SwiftPM Package Traits](https://github.com/swiftlang/swift-evo **Trait inventory (declared in `Package.swift`):** -- Per-family: `Git`, `Brew`, `Grep`, `Fzf`, `Rg`, `Swift`, `Gh`, `Docker`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip` (one trait per family directory; for `Common/`, one trait per file). +- Per-family: `Git`, `Brew`, `Grep`, `Fzf`, `Rg`, `Swift`, `Gh`, `Docker`, `Make`, `Node`, `Npm`, `Yarn`, `Pnpm`, `Bun`, `Terraform`, `Kubectl`, `Python`, `Ls`, `Cp`, `Mkdir`, `Chmod`, `Rm`, `Mv`, `Pwd`, `Jq`, `Rsync`, `Tar`, `Zip`, `Unzip` (one trait per family directory; for `Common/`, one trait per file). - Umbrellas: `CommonUtilities` (all `Common/*`), `All` (every family). **The wiring contract** — enforced by `Scripts/validate-traits.swift` and CI: -1. Every `.swift` file under a gated source directory (`Git/`, `Brew/`, `Grep/`, and each file in `Common/`) is wrapped top-to-bottom in `#if ... #endif`. +1. Every `.swift` file under a gated source directory (`Git/`, `Brew/`, `Grep/`, `Fzf/`, `Rg/`, `Swift/`, `Gh/`, `Docker/`, `Make/`, `Node/`, `Npm/`, `Yarn/`, `Pnpm/`, `Bun/`, `Terraform/`, `Kubectl/`, `Python/`, and each file in `Common/`) is wrapped top-to-bottom in `#if ... #endif`. 2. Every test file targeting a gated family is wrapped the same way. Cross-family tests use combined guards (`#if Git && Grep`). 3. Every family directory (or `Common/*.swift` file) has a matching `.trait(name:)` entry in `Package.swift`. 4. The `All` umbrella's `enabledTraits` transitively enables every per-family trait. The `CommonUtilities` umbrella enables every `Common/*` trait. diff --git a/Package.swift b/Package.swift index c1921ac..a002b75 100644 --- a/Package.swift +++ b/Package.swift @@ -24,6 +24,15 @@ let package = Package( .trait(name: "Swift", description: "Typed wrapper for the Swift toolchain CLI."), .trait(name: "Gh", description: "Typed wrapper for the GitHub CLI."), .trait(name: "Docker", description: "Typed wrapper for the Docker CLI."), + .trait(name: "Make", description: "Typed wrapper for the make build automation CLI."), + .trait(name: "Node", description: "Typed wrapper for the Node.js runtime CLI."), + .trait(name: "Npm", description: "Typed wrapper for the npm package manager CLI."), + .trait(name: "Yarn", description: "Typed wrapper for the Yarn package manager CLI."), + .trait(name: "Pnpm", description: "Typed wrapper for the pnpm package manager CLI."), + .trait(name: "Bun", description: "Typed wrapper for the Bun runtime and package manager CLI."), + .trait(name: "Terraform", description: "Typed wrapper for the Terraform CLI."), + .trait(name: "Kubectl", description: "Typed wrapper for the Kubernetes kubectl CLI."), + .trait(name: "Python", description: "Typed wrapper for the Python interpreter CLI."), .trait(name: "Ls", description: "Typed wrapper for ls."), .trait(name: "Cp", description: "Typed wrapper for cp."), .trait(name: "Mkdir", description: "Typed wrapper for mkdir."), @@ -46,7 +55,10 @@ let package = Package( .trait( name: "All", description: "Enables every command family shipped by SwiftyShell.", - enabledTraits: ["Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "Gh", "Docker", "CommonUtilities"] + enabledTraits: [ + "Git", "Brew", "Grep", "Fzf", "Rg", "Swift", "Gh", "Docker", "Make", "Node", "Npm", "Yarn", + "Pnpm", "Bun", "Terraform", "Kubectl", "Python", "CommonUtilities", + ] ), // Default is intentionally empty: consumers opt in to the families they want. .default(enabledTraits: []), diff --git a/README.md b/README.md index 2704dc6..cd77dff 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,15 @@ SwiftyShell ships typed wrappers for common tools. Each family is gated behind a | `Swift` | `swift` | `Swift` | SwiftPM build, test, run, package commands, traits, compiler flags | | `Gh` | `gh` | `Gh` | GitHub CLI automation for PRs, repos, workflows, Copilot, skills, API calls | | `Docker` | `docker` | `Docker` | Docker automation for Buildx, Compose, Debug, MCP, Scout, images, containers | +| `Make` | `make` | `Make` | Makefile targets, parallel jobs, dry runs, keep-going builds | +| `Node` | `node` | `Node` | Node.js runtime scripting, eval, syntax checks, script files | +| `Npm` | `npm` | `Npm` | npm installs, scripts, package execution, audits | +| `Yarn` | `yarn` | `Yarn` | Yarn installs, scripts, package execution, workspaces | +| `Pnpm` | `pnpm` | `Pnpm` | pnpm installs, scripts, filters, recursive workspace runs | +| `Bun` | `bun` | `Bun` | Bun runtime, package manager, tests, scripts, and builds | +| `Terraform` | `terraform` | `Terraform` | Terraform init, plan, apply, workspaces, outputs | +| `Kubectl` | `kubectl` | `Kubectl` | Kubernetes get, apply, logs, exec, namespaces, contexts | +| `Python` | `python3` | `Python` | Python interpreter modules, command strings, scripts, options | | `Ls` | `ls` | `Ls` | All flags, recursive, human-readable sizes | | `Cp` | `cp` | `Cp` | Recursive, force | | `Mkdir` | `mkdir` | `Mkdir` | Parent directories, permissions | diff --git a/Sources/SwiftyShell/Bun/Bun.swift b/Sources/SwiftyShell/Bun/Bun.swift new file mode 100644 index 0000000..7d34155 --- /dev/null +++ b/Sources/SwiftyShell/Bun/Bun.swift @@ -0,0 +1,233 @@ +#if Bun +import Foundation + +/// The top-level Bun command to invoke. +public enum BunSubcommand: String, Sendable, Equatable, Hashable { + /// `bun run` — run a package script or JavaScript/TypeScript file. + case run + /// `bun test` — run Bun's test runner. + case test + /// `bun install` — install project dependencies. + case install + /// `bun add` — add dependencies to a project. + case add + /// `bun remove` — remove dependencies from a project. + case remove + /// `bun build` — bundle source files. + case build + /// `bun x` — execute a package binary. + case x + /// `bun pm` — run package-manager maintenance commands. + case pm + /// `bun upgrade` — upgrade the Bun executable. + case upgrade +} + +/// A fluent wrapper for the Bun runtime and package manager CLI. +/// +/// ``Bun`` covers common script execution, testing, dependency management, and +/// bundling entry points while preserving access to raw Bun options. +/// +/// ```swift +/// try await Bun() +/// .runScript("dev") +/// .watch() +/// .run() +/// ``` +public struct Bun: RunnableCommandFamily { + private let state: State + + /// The shell context used when running this command family. + public var context: ShellContext { state.config.context } + + /// Creates a Bun command family bound to a shell context. + public init(context: ShellContext = .init()) { + self.state = State(config: ToolConfiguration(context: context)) + } + + private init(state: State) { self.state = state } + + /// Returns a copy with updated shared tool configuration. + public func updatingConfiguration(_ update: (ToolConfiguration) -> ToolConfiguration) -> Self { + copy(config: update(state.config)) + } + + /// Returns a copy that routes stdout to the given destination. + public func settingStdoutDestination(_ destination: OutputDestination) -> Self { + copy(stdoutDestination: destination) + } + + /// Returns a copy that routes stderr to the given destination. + public func settingStderrDestination(_ destination: OutputDestination) -> Self { + copy(stderrDestination: destination) + } + + /// Returns a copy that selects a Bun subcommand. + public func subcommand(_ value: BunSubcommand) -> Self { copy(subcommand: value.rawValue, scriptName: nil) } + + /// Returns a copy that selects a raw Bun subcommand. + public func subcommand(_ value: String) -> Self { copy(subcommand: value, scriptName: nil) } + + /// Returns a copy configured for `bun install`. + public func install() -> Self { subcommand(.install) } + + /// Returns a copy configured for `bun add `. + public func add(_ packages: String...) -> Self { add(packages) } + + /// Returns a copy configured for `bun add `. + public func add(_ packages: [String]) -> Self { copy(subcommand: "add", scriptName: nil, positionals: packages) } + + /// Returns a copy configured for `bun remove `. + public func remove(_ packages: String...) -> Self { remove(packages) } + + /// Returns a copy configured for `bun remove `. + public func remove(_ packages: [String]) -> Self { + copy(subcommand: "remove", scriptName: nil, positionals: packages) + } + + /// Returns a copy configured for `bun test`. + public func test() -> Self { subcommand(.test) } + + /// Returns a copy configured for `bun build `. + public func build(_ entrypoints: String...) -> Self { build(entrypoints) } + + /// Returns a copy configured for `bun build `. + public func build(_ entrypoints: [String]) -> Self { + copy(subcommand: "build", scriptName: nil, buildEntrypoints: entrypoints, positionals: []) + } + + /// Returns a copy configured for `bun x `. + public func x(_ binary: String? = nil) -> Self { + copy(subcommand: "x", scriptName: nil, positionals: binary.map { [$0] } ?? []) + } + + /// Returns a copy configured for `bun run `. + public func runScript(_ name: String) -> Self { copy(subcommand: "run", scriptName: name, positionals: []) } + + /// Returns a copy that passes `--cwd `. + public func cwd(_ path: String) -> Self { copy(cwdPath: path) } + + /// Returns a copy that passes `--watch`. + public func watch(_ enabled: Bool = true) -> Self { copy(watches: enabled) } + + /// Returns a copy that passes `--hot`. + public func hot(_ enabled: Bool = true) -> Self { copy(usesHotReload: enabled) } + + /// Returns a copy that passes `--production`. + public func production(_ enabled: Bool = true) -> Self { copy(isProduction: enabled) } + + /// Returns a copy that passes `--frozen-lockfile`. + public func frozenLockfile(_ enabled: Bool = true) -> Self { copy(usesFrozenLockfile: enabled) } + + /// Returns a copy that appends a raw option before positional arguments. + public func argument(_ value: String) -> Self { copy(extraArguments: state.extraArguments + [value]) } + + /// Returns a copy that appends raw options before positional arguments. + public func arguments(_ values: [String]) -> Self { copy(extraArguments: state.extraArguments + values) } + + /// Returns a copy that appends a positional package, binary, or script argument. + public func positionalArgument(_ value: String) -> Self { copy(positionals: state.positionals + [value]) } + + /// Returns a copy that appends positional package, binary, or script arguments. + public func positionalArguments(_ values: [String]) -> Self { copy(positionals: state.positionals + values) } + + /// Builds the raw `bun` command represented by the current builder state. + public func command() -> Command { + var arguments = [state.subcommand] + appendOption("--cwd", state.cwdPath, to: &arguments) + if state.subcommand == BunSubcommand.build.rawValue { + arguments += state.buildEntrypoints + } + if state.watches { arguments.append("--watch") } + if state.usesHotReload { arguments.append("--hot") } + if state.isProduction { arguments.append("--production") } + if state.usesFrozenLockfile { arguments.append("--frozen-lockfile") } + arguments += state.extraArguments + if let scriptName = state.scriptName { arguments.append(scriptName) } + arguments += state.positionals + + let base = Command("bun").args(arguments).stdout(state.stdoutDestination).stderr(state.stderrDestination) + return state.config.apply(to: base) + } + + private func copy( + config: ToolConfiguration? = nil, + stdoutDestination: OutputDestination? = nil, + stderrDestination: OutputDestination? = nil, + subcommand: String? = nil, + scriptName: String?? = nil, + cwdPath: String?? = nil, + watches: Bool? = nil, + usesHotReload: Bool? = nil, + isProduction: Bool? = nil, + usesFrozenLockfile: Bool? = nil, + extraArguments: [String]? = nil, + buildEntrypoints: [String]? = nil, + positionals: [String]? = nil + ) -> Self { + Self( + state: State( + config: config ?? state.config, + stdoutDestination: stdoutDestination ?? state.stdoutDestination, + stderrDestination: stderrDestination ?? state.stderrDestination, + subcommand: subcommand ?? state.subcommand, + scriptName: scriptName ?? state.scriptName, + cwdPath: cwdPath ?? state.cwdPath, + watches: watches ?? state.watches, + usesHotReload: usesHotReload ?? state.usesHotReload, + isProduction: isProduction ?? state.isProduction, + usesFrozenLockfile: usesFrozenLockfile ?? state.usesFrozenLockfile, + extraArguments: extraArguments ?? state.extraArguments, + buildEntrypoints: buildEntrypoints ?? state.buildEntrypoints, + positionals: positionals ?? state.positionals + ) + ) + } +} + +private struct State: Sendable { + let config: ToolConfiguration + let stdoutDestination: OutputDestination + let stderrDestination: OutputDestination + let subcommand: String + let scriptName: String? + let cwdPath: String? + let watches: Bool + let usesHotReload: Bool + let isProduction: Bool + let usesFrozenLockfile: Bool + let extraArguments: [String] + let buildEntrypoints: [String] + let positionals: [String] + + init( + config: ToolConfiguration, + stdoutDestination: OutputDestination = .capture, + stderrDestination: OutputDestination = .capture, + subcommand: String = "--version", + scriptName: String? = nil, + cwdPath: String? = nil, + watches: Bool = false, + usesHotReload: Bool = false, + isProduction: Bool = false, + usesFrozenLockfile: Bool = false, + extraArguments: [String] = [], + buildEntrypoints: [String] = [], + positionals: [String] = [] + ) { + self.config = config + self.stdoutDestination = stdoutDestination + self.stderrDestination = stderrDestination + self.subcommand = subcommand + self.scriptName = scriptName + self.cwdPath = cwdPath + self.watches = watches + self.usesHotReload = usesHotReload + self.isProduction = isProduction + self.usesFrozenLockfile = usesFrozenLockfile + self.extraArguments = extraArguments + self.buildEntrypoints = buildEntrypoints + self.positionals = positionals + } +} +#endif diff --git a/Sources/SwiftyShell/Core/CommandFamily.swift b/Sources/SwiftyShell/Core/CommandFamily.swift index da426ae..6d8f16f 100644 --- a/Sources/SwiftyShell/Core/CommandFamily.swift +++ b/Sources/SwiftyShell/Core/CommandFamily.swift @@ -219,3 +219,7 @@ public extension RunnableCommandFamily { try await command().spawn(in: context, teardown: teardown) } } + +func appendOption(_ name: String, _ value: String?, to arguments: inout [String]) { + if let value { arguments += [name, value] } +} diff --git a/Sources/SwiftyShell/Kubectl/Kubectl.swift b/Sources/SwiftyShell/Kubectl/Kubectl.swift new file mode 100644 index 0000000..bb7a915 --- /dev/null +++ b/Sources/SwiftyShell/Kubectl/Kubectl.swift @@ -0,0 +1,240 @@ +#if Kubectl +import Foundation + +/// The top-level kubectl command to invoke. +public enum KubectlSubcommand: String, Sendable, Equatable, Hashable { + /// `kubectl get` — display resources. + case get + /// `kubectl describe` — show detailed resource information. + case describe + /// `kubectl apply` — apply a configuration. + case apply + /// `kubectl delete` — delete resources. + case delete + /// `kubectl logs` — print pod logs. + case logs + /// `kubectl exec` — execute a command in a container. + case exec + /// `kubectl rollout` — manage rollouts. + case rollout + /// `kubectl config` — manage kubeconfig. + case config + /// `kubectl version` — print client/server version information. + case version +} + +/// A fluent wrapper for the Kubernetes `kubectl` CLI. +/// +/// ``Kubectl`` covers common automation commands and shared selection flags +/// such as namespace, context, output format, labels, files, and containers. +/// +/// ```swift +/// let pods = try await Kubectl() +/// .get("pods") +/// .namespace("default") +/// .output("json") +/// .run() +/// ``` +public struct Kubectl: RunnableCommandFamily { + private let state: State + + /// The shell context used when running this command family. + public var context: ShellContext { state.config.context } + + /// Creates a kubectl command family bound to a shell context. + public init(context: ShellContext = .init()) { + self.state = State(config: ToolConfiguration(context: context)) + } + + private init(state: State) { self.state = state } + + /// Returns a copy with updated shared tool configuration. + public func updatingConfiguration(_ update: (ToolConfiguration) -> ToolConfiguration) -> Self { + copy(config: update(state.config)) + } + + /// Returns a copy that routes stdout to the given destination. + public func settingStdoutDestination(_ destination: OutputDestination) -> Self { + copy(stdoutDestination: destination) + } + + /// Returns a copy that routes stderr to the given destination. + public func settingStderrDestination(_ destination: OutputDestination) -> Self { + copy(stderrDestination: destination) + } + + /// Returns a copy that selects a kubectl subcommand. + public func subcommand(_ value: KubectlSubcommand) -> Self { copy(subcommand: value.rawValue, resources: []) } + + /// Returns a copy that selects a raw kubectl subcommand. + public func subcommand(_ value: String) -> Self { copy(subcommand: value, resources: []) } + + /// Returns a copy configured for `kubectl get `. + public func get(_ resource: String? = nil) -> Self { resourceCommand(.get, resource) } + + /// Returns a copy configured for `kubectl describe `. + public func describe(_ resource: String? = nil) -> Self { resourceCommand(.describe, resource) } + + /// Returns a copy configured for `kubectl apply`. + public func apply() -> Self { subcommand(.apply) } + + /// Returns a copy configured for `kubectl delete `. + public func delete(_ resource: String? = nil) -> Self { resourceCommand(.delete, resource) } + + /// Returns a copy configured for `kubectl logs `. + public func logs(_ resource: String? = nil) -> Self { resourceCommand(.logs, resource) } + + /// Returns a copy configured for `kubectl exec `. + /// + /// Use ``argument(_:)`` or ``positionalArgument(_:)`` to insert `--` before the remote command + /// when the command needs to be separated from kubectl flags. + public func exec(_ resource: String? = nil) -> Self { resourceCommand(.exec, resource) } + + /// Returns a copy that passes `--kubeconfig `. + public func kubeconfig(_ path: String) -> Self { copy(kubeconfigPath: path) } + + /// Returns a copy that passes `--context `. + /// + /// The method is named `contextName` to avoid colliding with the ``ShellContext``-backed + /// ``context`` property shared by command families. + public func contextName(_ name: String) -> Self { copy(kubeContextName: name) } + + /// Returns a copy that passes `--namespace `. + public func namespace(_ name: String) -> Self { copy(namespaceName: name) } + + /// Returns a copy that passes `--output `. + public func output(_ format: String) -> Self { copy(outputFormat: format) } + + /// Returns a copy that passes `--filename `. + public func filename(_ path: String) -> Self { copy(filenames: state.filenames + [path]) } + + /// Returns a copy that passes `--selector `. + public func selector(_ value: String) -> Self { copy(selectorValue: value) } + + /// Returns a copy that passes `--container `. + public func container(_ name: String) -> Self { copy(containerName: name) } + + /// Returns a copy that passes `--all-namespaces`. + public func allNamespaces(_ enabled: Bool = true) -> Self { copy(allNamespacesEnabled: enabled) } + + /// Returns a copy that appends a raw option before positional arguments. + public func argument(_ value: String) -> Self { copy(extraArguments: state.extraArguments + [value]) } + + /// Returns a copy that appends raw options before positional arguments. + public func arguments(_ values: [String]) -> Self { copy(extraArguments: state.extraArguments + values) } + + /// Returns a copy that appends a resource or command argument. + public func positionalArgument(_ value: String) -> Self { copy(resources: state.resources + [value]) } + + /// Returns a copy that appends resources or command arguments. + public func positionalArguments(_ values: [String]) -> Self { copy(resources: state.resources + values) } + + /// Builds the raw `kubectl` command represented by the current builder state. + public func command() -> Command { + var arguments: [String] = [] + appendOption("--kubeconfig", state.kubeconfigPath, to: &arguments) + appendOption("--context", state.kubeContextName, to: &arguments) + arguments.append(state.subcommand) + appendOption("--namespace", state.namespaceName, to: &arguments) + appendOption("--output", state.outputFormat, to: &arguments) + for filename in state.filenames { arguments += ["--filename", filename] } + appendOption("--selector", state.selectorValue, to: &arguments) + appendOption("--container", state.containerName, to: &arguments) + if state.allNamespacesEnabled { arguments.append("--all-namespaces") } + arguments += state.extraArguments + state.resources + let base = Command("kubectl").args(arguments).stdout(state.stdoutDestination).stderr(state.stderrDestination) + return state.config.apply(to: base) + } + + private func resourceCommand(_ subcommand: KubectlSubcommand, _ resource: String?) -> Self { + var result = self.subcommand(subcommand) + if let resource { result = result.positionalArgument(resource) } + return result + } + + private func copy( + config: ToolConfiguration? = nil, + stdoutDestination: OutputDestination? = nil, + stderrDestination: OutputDestination? = nil, + subcommand: String? = nil, + kubeconfigPath: String?? = nil, + kubeContextName: String?? = nil, + namespaceName: String?? = nil, + outputFormat: String?? = nil, + filenames: [String]? = nil, + selectorValue: String?? = nil, + containerName: String?? = nil, + allNamespacesEnabled: Bool? = nil, + extraArguments: [String]? = nil, + resources: [String]? = nil + ) -> Self { + Self( + state: State( + config: config ?? state.config, + stdoutDestination: stdoutDestination ?? state.stdoutDestination, + stderrDestination: stderrDestination ?? state.stderrDestination, + subcommand: subcommand ?? state.subcommand, + kubeconfigPath: kubeconfigPath ?? state.kubeconfigPath, + kubeContextName: kubeContextName ?? state.kubeContextName, + namespaceName: namespaceName ?? state.namespaceName, + outputFormat: outputFormat ?? state.outputFormat, + filenames: filenames ?? state.filenames, + selectorValue: selectorValue ?? state.selectorValue, + containerName: containerName ?? state.containerName, + allNamespacesEnabled: allNamespacesEnabled ?? state.allNamespacesEnabled, + extraArguments: extraArguments ?? state.extraArguments, + resources: resources ?? state.resources + ) + ) + } +} + +private struct State: Sendable { + let config: ToolConfiguration + let stdoutDestination: OutputDestination + let stderrDestination: OutputDestination + let subcommand: String + let kubeconfigPath: String? + let kubeContextName: String? + let namespaceName: String? + let outputFormat: String? + let filenames: [String] + let selectorValue: String? + let containerName: String? + let allNamespacesEnabled: Bool + let extraArguments: [String] + let resources: [String] + + init( + config: ToolConfiguration, + stdoutDestination: OutputDestination = .capture, + stderrDestination: OutputDestination = .capture, + subcommand: String = KubectlSubcommand.version.rawValue, + kubeconfigPath: String? = nil, + kubeContextName: String? = nil, + namespaceName: String? = nil, + outputFormat: String? = nil, + filenames: [String] = [], + selectorValue: String? = nil, + containerName: String? = nil, + allNamespacesEnabled: Bool = false, + extraArguments: [String] = [], + resources: [String] = [] + ) { + self.config = config + self.stdoutDestination = stdoutDestination + self.stderrDestination = stderrDestination + self.subcommand = subcommand + self.kubeconfigPath = kubeconfigPath + self.kubeContextName = kubeContextName + self.namespaceName = namespaceName + self.outputFormat = outputFormat + self.filenames = filenames + self.selectorValue = selectorValue + self.containerName = containerName + self.allNamespacesEnabled = allNamespacesEnabled + self.extraArguments = extraArguments + self.resources = resources + } +} +#endif diff --git a/Sources/SwiftyShell/Make/Make.swift b/Sources/SwiftyShell/Make/Make.swift new file mode 100644 index 0000000..6804696 --- /dev/null +++ b/Sources/SwiftyShell/Make/Make.swift @@ -0,0 +1,174 @@ +#if Make +import Foundation + +/// A fluent wrapper for the `make` build automation CLI. +/// +/// ``Make`` models common scripting options such as Makefile selection, +/// working directory, parallel jobs, and target lists while preserving raw +/// escape hatches for project-specific variables and flags. +/// +/// ```swift +/// try await Make() +/// .file("Makefile") +/// .jobs(8) +/// .target("check") +/// .run() +/// ``` +public struct Make: RunnableCommandFamily { + private let state: State + + /// The shell context used when running this command family. + public var context: ShellContext { state.config.context } + + /// Creates a Make command family bound to a shell context. + /// + /// - Parameter context: The shell context whose executor, search paths, + /// environment, and defaults will be used. + public init(context: ShellContext = .init()) { + self.state = State(config: ToolConfiguration(context: context)) + } + + private init(state: State) { + self.state = state + } + + /// Returns a copy with updated shared tool configuration. + public func updatingConfiguration(_ update: (ToolConfiguration) -> ToolConfiguration) -> Self { + copy(config: update(state.config)) + } + + /// Returns a copy that routes stdout to the given destination. + public func settingStdoutDestination(_ destination: OutputDestination) -> Self { + copy(stdoutDestination: destination) + } + + /// Returns a copy that routes stderr to the given destination. + public func settingStderrDestination(_ destination: OutputDestination) -> Self { + copy(stderrDestination: destination) + } + + /// Returns a copy that selects a Makefile with `--file `. + public func file(_ path: String) -> Self { copy(filePath: path) } + + /// Returns a copy that changes make's directory with `--directory `. + public func directory(_ path: String) -> Self { copy(directoryPath: path) } + + /// Returns a copy that sets parallelism with `--jobs `. + public func jobs(_ count: Int) -> Self { copy(jobCount: count) } + + /// Returns a copy that passes `--keep-going`. + public func keepGoing(_ enabled: Bool = true) -> Self { copy(keepsGoing: enabled) } + + /// Returns a copy that passes `--silent`. + public func silent(_ enabled: Bool = true) -> Self { copy(isSilent: enabled) } + + /// Returns a copy that passes `--dry-run`. + public func dryRun(_ enabled: Bool = true) -> Self { copy(isDryRun: enabled) } + + /// Returns a copy that passes `--always-make`. + public func alwaysMake(_ enabled: Bool = true) -> Self { copy(alwaysMakes: enabled) } + + /// Returns a copy that appends a raw option or variable assignment before targets. + public func argument(_ value: String) -> Self { copy(extraArguments: state.extraArguments + [value]) } + + /// Returns a copy that appends raw options or variable assignments before targets. + public func arguments(_ values: [String]) -> Self { copy(extraArguments: state.extraArguments + values) } + + /// Returns a copy that appends a make target. + public func target(_ name: String) -> Self { copy(targets: state.targets + [name]) } + + /// Returns a copy that appends multiple make targets. + public func targets(_ names: [String]) -> Self { copy(targets: state.targets + names) } + + /// Builds the raw `make` command represented by the current builder state. + public func command() -> Command { + var arguments: [String] = [] + appendOption("--file", state.filePath, to: &arguments) + appendOption("--directory", state.directoryPath, to: &arguments) + if let jobCount = state.jobCount { arguments += ["--jobs", String(jobCount)] } + if state.keepsGoing { arguments.append("--keep-going") } + if state.isSilent { arguments.append("--silent") } + if state.isDryRun { arguments.append("--dry-run") } + if state.alwaysMakes { arguments.append("--always-make") } + arguments += state.extraArguments + state.targets + + let base = Command("make").args(arguments).stdout(state.stdoutDestination).stderr(state.stderrDestination) + return state.config.apply(to: base) + } + + private func copy( + config: ToolConfiguration? = nil, + stdoutDestination: OutputDestination? = nil, + stderrDestination: OutputDestination? = nil, + filePath: String?? = nil, + directoryPath: String?? = nil, + jobCount: Int?? = nil, + keepsGoing: Bool? = nil, + isSilent: Bool? = nil, + isDryRun: Bool? = nil, + alwaysMakes: Bool? = nil, + extraArguments: [String]? = nil, + targets: [String]? = nil + ) -> Self { + Self( + state: State( + config: config ?? state.config, + stdoutDestination: stdoutDestination ?? state.stdoutDestination, + stderrDestination: stderrDestination ?? state.stderrDestination, + filePath: filePath ?? state.filePath, + directoryPath: directoryPath ?? state.directoryPath, + jobCount: jobCount ?? state.jobCount, + keepsGoing: keepsGoing ?? state.keepsGoing, + isSilent: isSilent ?? state.isSilent, + isDryRun: isDryRun ?? state.isDryRun, + alwaysMakes: alwaysMakes ?? state.alwaysMakes, + extraArguments: extraArguments ?? state.extraArguments, + targets: targets ?? state.targets + ) + ) + } +} + +private struct State: Sendable { + let config: ToolConfiguration + let stdoutDestination: OutputDestination + let stderrDestination: OutputDestination + let filePath: String? + let directoryPath: String? + let jobCount: Int? + let keepsGoing: Bool + let isSilent: Bool + let isDryRun: Bool + let alwaysMakes: Bool + let extraArguments: [String] + let targets: [String] + + init( + config: ToolConfiguration, + stdoutDestination: OutputDestination = .capture, + stderrDestination: OutputDestination = .capture, + filePath: String? = nil, + directoryPath: String? = nil, + jobCount: Int? = nil, + keepsGoing: Bool = false, + isSilent: Bool = false, + isDryRun: Bool = false, + alwaysMakes: Bool = false, + extraArguments: [String] = [], + targets: [String] = [] + ) { + self.config = config + self.stdoutDestination = stdoutDestination + self.stderrDestination = stderrDestination + self.filePath = filePath + self.directoryPath = directoryPath + self.jobCount = jobCount + self.keepsGoing = keepsGoing + self.isSilent = isSilent + self.isDryRun = isDryRun + self.alwaysMakes = alwaysMakes + self.extraArguments = extraArguments + self.targets = targets + } +} +#endif diff --git a/Sources/SwiftyShell/Node/Node.swift b/Sources/SwiftyShell/Node/Node.swift new file mode 100644 index 0000000..e5b2f2b --- /dev/null +++ b/Sources/SwiftyShell/Node/Node.swift @@ -0,0 +1,168 @@ +#if Node +import Foundation + +/// A fluent wrapper for the Node.js runtime CLI (`node`). +/// +/// ``Node`` covers common scripting entry points: version checks, inline code +/// evaluation, syntax checks, preload modules, and script execution. +/// +/// ```swift +/// let output = try await Node() +/// .eval("console.log(process.version)") +/// .run() +/// ``` +public struct Node: RunnableCommandFamily { + private let state: State + + /// The shell context used when running this command family. + public var context: ShellContext { state.config.context } + + /// Creates a Node command family bound to a shell context. + public init(context: ShellContext = .init()) { + self.state = State(config: ToolConfiguration(context: context)) + } + + private init(state: State) { self.state = state } + + /// Returns a copy with updated shared tool configuration. + public func updatingConfiguration(_ update: (ToolConfiguration) -> ToolConfiguration) -> Self { + copy(config: update(state.config)) + } + + /// Returns a copy that routes stdout to the given destination. + public func settingStdoutDestination(_ destination: OutputDestination) -> Self { + copy(stdoutDestination: destination) + } + + /// Returns a copy that routes stderr to the given destination. + public func settingStderrDestination(_ destination: OutputDestination) -> Self { + copy(stderrDestination: destination) + } + + /// Returns a copy that prints Node.js version information with `--version`. + /// + /// Selecting version mode clears any script arguments from a previously selected entry point. + public func version() -> Self { copy(mode: .version, scriptArguments: []) } + + /// Returns a copy that evaluates JavaScript with `--eval `. + public func eval(_ code: String) -> Self { copy(mode: .eval(code), scriptArguments: []) } + + /// Returns a copy that prints JavaScript expression output with `--print `. + public func printExpression(_ code: String) -> Self { copy(mode: .print(code), scriptArguments: []) } + + /// Returns a copy that checks a script's syntax with `--check `. + public func check(_ path: String) -> Self { copy(mode: .check(path), scriptArguments: []) } + + /// Returns a copy that runs a JavaScript file. + public func script(_ path: String) -> Self { copy(mode: .script(path), scriptArguments: []) } + + /// Returns a copy that preloads a module with `--require `. + public func require(_ module: String) -> Self { copy(requires: state.requires + [module]) } + + /// Returns a copy that enables inspector support with `--inspect`. + public func inspect(_ enabled: Bool = true) -> Self { copy(inspects: enabled) } + + /// Returns a copy that enables watch mode with `--watch`. + public func watch(_ enabled: Bool = true) -> Self { copy(watches: enabled) } + + /// Returns a copy that appends a raw Node option before the selected entry point. + public func argument(_ value: String) -> Self { copy(extraArguments: state.extraArguments + [value]) } + + /// Returns a copy that appends raw Node options before the selected entry point. + public func arguments(_ values: [String]) -> Self { copy(extraArguments: state.extraArguments + values) } + + /// Returns a copy that appends an argument passed to the selected script or inline program. + public func scriptArgument(_ value: String) -> Self { copy(scriptArguments: state.scriptArguments + [value]) } + + /// Returns a copy that appends arguments passed to the selected script or inline program. + public func scriptArguments(_ values: [String]) -> Self { copy(scriptArguments: state.scriptArguments + values) } + + /// Builds the raw `node` command represented by the current builder state. + public func command() -> Command { + var arguments: [String] = [] + for module in state.requires { arguments += ["--require", module] } + if state.inspects { arguments.append("--inspect") } + if state.watches { arguments.append("--watch") } + arguments += state.extraArguments + switch state.mode { + case .version: arguments.append("--version") + case let .eval(code): arguments += ["--eval", code] + case let .print(code): arguments += ["--print", code] + case let .check(path): arguments += ["--check", path] + case let .script(path): arguments.append(path) + } + arguments += state.scriptArguments + + let base = Command("node").args(arguments).stdout(state.stdoutDestination).stderr(state.stderrDestination) + return state.config.apply(to: base) + } + + private func copy( + config: ToolConfiguration? = nil, + stdoutDestination: OutputDestination? = nil, + stderrDestination: OutputDestination? = nil, + mode: Mode? = nil, + requires: [String]? = nil, + inspects: Bool? = nil, + watches: Bool? = nil, + extraArguments: [String]? = nil, + scriptArguments: [String]? = nil + ) -> Self { + Self( + state: State( + config: config ?? state.config, + stdoutDestination: stdoutDestination ?? state.stdoutDestination, + stderrDestination: stderrDestination ?? state.stderrDestination, + mode: mode ?? state.mode, + requires: requires ?? state.requires, + inspects: inspects ?? state.inspects, + watches: watches ?? state.watches, + extraArguments: extraArguments ?? state.extraArguments, + scriptArguments: scriptArguments ?? state.scriptArguments + ) + ) + } +} + +private enum Mode: Sendable { + case version + case eval(String) + case print(String) + case check(String) + case script(String) +} + +private struct State: Sendable { + let config: ToolConfiguration + let stdoutDestination: OutputDestination + let stderrDestination: OutputDestination + let mode: Mode + let requires: [String] + let inspects: Bool + let watches: Bool + let extraArguments: [String] + let scriptArguments: [String] + + init( + config: ToolConfiguration, + stdoutDestination: OutputDestination = .capture, + stderrDestination: OutputDestination = .capture, + mode: Mode = .version, + requires: [String] = [], + inspects: Bool = false, + watches: Bool = false, + extraArguments: [String] = [], + scriptArguments: [String] = [] + ) { + self.config = config + self.stdoutDestination = stdoutDestination + self.stderrDestination = stderrDestination + self.mode = mode + self.requires = requires + self.inspects = inspects + self.watches = watches + self.extraArguments = extraArguments + self.scriptArguments = scriptArguments + } +} +#endif diff --git a/Sources/SwiftyShell/Npm/Npm.swift b/Sources/SwiftyShell/Npm/Npm.swift new file mode 100644 index 0000000..eccebbe --- /dev/null +++ b/Sources/SwiftyShell/Npm/Npm.swift @@ -0,0 +1,217 @@ +#if Npm +import Foundation + +/// The top-level npm command to invoke. +public enum NpmSubcommand: String, Sendable, Equatable, Hashable { + /// `npm install` — install package dependencies. + case install + /// `npm ci` — install dependencies from a lockfile for CI. + case ci + /// `npm run` — run a package script. + case run + /// `npm test` — run package tests. + case test + /// `npm publish` — publish a package. + case publish + /// `npm exec` — execute a package binary. + case exec + /// `npm outdated` — check for outdated dependencies. + case outdated + /// `npm audit` — audit dependency vulnerabilities. + case audit + /// `npm version` — manage package versioning. + case version +} + +/// A fluent wrapper for the npm package manager CLI. +/// +/// ``Npm`` focuses on script automation, CI installs, package execution, and +/// common npm global flags. +/// +/// ```swift +/// try await Npm() +/// .runScript("build") +/// .ifPresent() +/// .run() +/// ``` +public struct Npm: RunnableCommandFamily { + private let state: State + + /// The shell context used when running this command family. + public var context: ShellContext { state.config.context } + + /// Creates an npm command family bound to a shell context. + public init(context: ShellContext = .init()) { + self.state = State(config: ToolConfiguration(context: context)) + } + + private init(state: State) { self.state = state } + + /// Returns a copy with updated shared tool configuration. + public func updatingConfiguration(_ update: (ToolConfiguration) -> ToolConfiguration) -> Self { + copy(config: update(state.config)) + } + + /// Returns a copy that routes stdout to the given destination. + public func settingStdoutDestination(_ destination: OutputDestination) -> Self { + copy(stdoutDestination: destination) + } + + /// Returns a copy that routes stderr to the given destination. + public func settingStderrDestination(_ destination: OutputDestination) -> Self { + copy(stderrDestination: destination) + } + + /// Returns a copy that selects an npm subcommand. + public func subcommand(_ value: NpmSubcommand) -> Self { copy(subcommand: value.rawValue, scriptName: nil) } + + /// Returns a copy that selects a raw npm subcommand. + public func subcommand(_ value: String) -> Self { copy(subcommand: value, scriptName: nil) } + + /// Returns a copy configured for `npm install`. + public func install() -> Self { subcommand(.install) } + + /// Returns a copy configured for `npm ci`. + public func ci() -> Self { subcommand(.ci) } + + /// Returns a copy configured for `npm test`. + public func test() -> Self { subcommand(.test) } + + /// Returns a copy configured for `npm exec `. + public func exec(_ binary: String? = nil) -> Self { + copy(subcommand: "exec", scriptName: nil, positionals: binary.map { [$0] } ?? []) + } + + /// Returns a copy configured for `npm run `. + public func runScript(_ name: String) -> Self { copy(subcommand: "run", scriptName: name, positionals: []) } + + /// Returns a copy that passes `--prefix `. + public func prefix(_ path: String) -> Self { copy(prefixPath: path) } + + /// Returns a copy that passes `--global`. + /// + /// Combining this with ``prefix(_:)`` mirrors npm's permissive CLI behavior, + /// but npm treats global installs as outside the project prefix workflow. + public func global(_ enabled: Bool = true) -> Self { copy(isGlobal: enabled) } + + /// Returns a copy that passes `--production`. + public func production(_ enabled: Bool = true) -> Self { copy(isProduction: enabled) } + + /// Returns a copy that passes `--if-present`. + public func ifPresent(_ enabled: Bool = true) -> Self { copy(ifPresentEnabled: enabled) } + + /// Returns a copy that passes `--silent`. + public func silent(_ enabled: Bool = true) -> Self { copy(isSilent: enabled) } + + /// Returns a copy that passes `--json`. + public func json(_ enabled: Bool = true) -> Self { copy(outputsJSON: enabled) } + + /// Returns a copy that appends a raw option before positional arguments. + public func argument(_ value: String) -> Self { copy(extraArguments: state.extraArguments + [value]) } + + /// Returns a copy that appends raw options before positional arguments. + public func arguments(_ values: [String]) -> Self { copy(extraArguments: state.extraArguments + values) } + + /// Returns a copy that appends a positional package, binary, or script argument. + public func positionalArgument(_ value: String) -> Self { copy(positionals: state.positionals + [value]) } + + /// Returns a copy that appends positional package, binary, or script arguments. + public func positionalArguments(_ values: [String]) -> Self { copy(positionals: state.positionals + values) } + + /// Builds the raw `npm` command represented by the current builder state. + public func command() -> Command { + var arguments = [state.subcommand] + appendOption("--prefix", state.prefixPath, to: &arguments) + if state.isGlobal { arguments.append("--global") } + if state.isProduction { arguments.append("--production") } + if state.ifPresentEnabled { arguments.append("--if-present") } + if state.isSilent { arguments.append("--silent") } + if state.outputsJSON { arguments.append("--json") } + arguments += state.extraArguments + if let scriptName = state.scriptName { arguments.append(scriptName) } + arguments += state.positionals + let base = Command("npm").args(arguments).stdout(state.stdoutDestination).stderr(state.stderrDestination) + return state.config.apply(to: base) + } + + private func copy( + config: ToolConfiguration? = nil, + stdoutDestination: OutputDestination? = nil, + stderrDestination: OutputDestination? = nil, + subcommand: String? = nil, + scriptName: String?? = nil, + prefixPath: String?? = nil, + isGlobal: Bool? = nil, + isProduction: Bool? = nil, + ifPresentEnabled: Bool? = nil, + isSilent: Bool? = nil, + outputsJSON: Bool? = nil, + extraArguments: [String]? = nil, + positionals: [String]? = nil + ) -> Self { + Self( + state: State( + config: config ?? state.config, + stdoutDestination: stdoutDestination ?? state.stdoutDestination, + stderrDestination: stderrDestination ?? state.stderrDestination, + subcommand: subcommand ?? state.subcommand, + scriptName: scriptName ?? state.scriptName, + prefixPath: prefixPath ?? state.prefixPath, + isGlobal: isGlobal ?? state.isGlobal, + isProduction: isProduction ?? state.isProduction, + ifPresentEnabled: ifPresentEnabled ?? state.ifPresentEnabled, + isSilent: isSilent ?? state.isSilent, + outputsJSON: outputsJSON ?? state.outputsJSON, + extraArguments: extraArguments ?? state.extraArguments, + positionals: positionals ?? state.positionals + ) + ) + } +} + +private struct State: Sendable { + let config: ToolConfiguration + let stdoutDestination: OutputDestination + let stderrDestination: OutputDestination + let subcommand: String + let scriptName: String? + let prefixPath: String? + let isGlobal: Bool + let isProduction: Bool + let ifPresentEnabled: Bool + let isSilent: Bool + let outputsJSON: Bool + let extraArguments: [String] + let positionals: [String] + + init( + config: ToolConfiguration, + stdoutDestination: OutputDestination = .capture, + stderrDestination: OutputDestination = .capture, + subcommand: String = "--version", + scriptName: String? = nil, + prefixPath: String? = nil, + isGlobal: Bool = false, + isProduction: Bool = false, + ifPresentEnabled: Bool = false, + isSilent: Bool = false, + outputsJSON: Bool = false, + extraArguments: [String] = [], + positionals: [String] = [] + ) { + self.config = config + self.stdoutDestination = stdoutDestination + self.stderrDestination = stderrDestination + self.subcommand = subcommand + self.scriptName = scriptName + self.prefixPath = prefixPath + self.isGlobal = isGlobal + self.isProduction = isProduction + self.ifPresentEnabled = ifPresentEnabled + self.isSilent = isSilent + self.outputsJSON = outputsJSON + self.extraArguments = extraArguments + self.positionals = positionals + } +} +#endif diff --git a/Sources/SwiftyShell/Pnpm/Pnpm.swift b/Sources/SwiftyShell/Pnpm/Pnpm.swift new file mode 100644 index 0000000..977ebc0 --- /dev/null +++ b/Sources/SwiftyShell/Pnpm/Pnpm.swift @@ -0,0 +1,241 @@ +#if Pnpm +import Foundation + +/// The top-level pnpm command to invoke. +public enum PnpmSubcommand: String, Sendable, Equatable, Hashable { + /// `pnpm install` — install project dependencies. + case install + /// `pnpm add` — add dependencies to a project. + case add + /// `pnpm remove` — remove dependencies from a project. + case remove + /// `pnpm run` — run a package script. + case run + /// `pnpm test` — run the package test script. + case test + /// `pnpm exec` — execute a package binary. + case exec + /// `pnpm dlx` — run a package in a temporary environment. + case dlx + /// `pnpm audit` — audit dependency vulnerabilities. + case audit + /// `pnpm version` — manage project versions. + case version +} + +/// A fluent wrapper for the pnpm package manager CLI. +/// +/// ``Pnpm`` focuses on deterministic installs, workspace script execution, +/// filtering, and package binary execution. +/// +/// ```swift +/// try await Pnpm() +/// .runScript("build") +/// .recursive() +/// .filter("./packages/app") +/// .run() +/// ``` +public struct Pnpm: RunnableCommandFamily { + private let state: State + + /// The shell context used when running this command family. + public var context: ShellContext { state.config.context } + + /// Creates a pnpm command family bound to a shell context. + public init(context: ShellContext = .init()) { + self.state = State(config: ToolConfiguration(context: context)) + } + + private init(state: State) { self.state = state } + + /// Returns a copy with updated shared tool configuration. + public func updatingConfiguration(_ update: (ToolConfiguration) -> ToolConfiguration) -> Self { + copy(config: update(state.config)) + } + + /// Returns a copy that routes stdout to the given destination. + public func settingStdoutDestination(_ destination: OutputDestination) -> Self { + copy(stdoutDestination: destination) + } + + /// Returns a copy that routes stderr to the given destination. + public func settingStderrDestination(_ destination: OutputDestination) -> Self { + copy(stderrDestination: destination) + } + + /// Returns a copy that selects a pnpm subcommand. + public func subcommand(_ value: PnpmSubcommand) -> Self { copy(subcommand: value.rawValue, scriptName: nil) } + + /// Returns a copy that selects a raw pnpm subcommand. + public func subcommand(_ value: String) -> Self { copy(subcommand: value, scriptName: nil) } + + /// Returns a copy configured for `pnpm install`. + public func install() -> Self { subcommand(.install) } + + /// Returns a copy configured for `pnpm add `. + public func add(_ packages: String...) -> Self { add(packages) } + + /// Returns a copy configured for `pnpm add `. + public func add(_ packages: [String]) -> Self { copy(subcommand: "add", scriptName: nil, positionals: packages) } + + /// Returns a copy configured for `pnpm remove `. + public func remove(_ packages: String...) -> Self { remove(packages) } + + /// Returns a copy configured for `pnpm remove `. + public func remove(_ packages: [String]) -> Self { + copy(subcommand: "remove", scriptName: nil, positionals: packages) + } + + /// Returns a copy configured for `pnpm test`. + public func test() -> Self { subcommand(.test) } + + /// Returns a copy configured for `pnpm exec `. + public func exec(_ binary: String? = nil) -> Self { + copy(subcommand: "exec", scriptName: nil, positionals: binary.map { [$0] } ?? []) + } + + /// Returns a copy configured for `pnpm dlx `. + public func dlx(_ package: String? = nil) -> Self { + copy(subcommand: "dlx", scriptName: nil, positionals: package.map { [$0] } ?? []) + } + + /// Returns a copy configured for `pnpm run `. + public func runScript(_ name: String) -> Self { copy(subcommand: "run", scriptName: name, positionals: []) } + + /// Returns a copy that passes `--dir `. + public func directory(_ path: String) -> Self { copy(directoryPath: path) } + + /// Returns a copy that passes `--filter `. + public func filter(_ selector: String) -> Self { copy(filters: state.filters + [selector]) } + + /// Returns a copy that passes `--recursive`. + public func recursive(_ enabled: Bool = true) -> Self { copy(isRecursive: enabled) } + + /// Returns a copy that passes `--if-present`. + public func ifPresent(_ enabled: Bool = true) -> Self { copy(ifPresentEnabled: enabled) } + + /// Returns a copy that passes `--frozen-lockfile`. + public func frozenLockfile(_ enabled: Bool = true) -> Self { copy(usesFrozenLockfile: enabled) } + + /// Returns a copy that passes `--prod`. + public func production(_ enabled: Bool = true) -> Self { copy(isProduction: enabled) } + + /// Returns a copy that passes `--json`. + public func json(_ enabled: Bool = true) -> Self { copy(outputsJSON: enabled) } + + /// Returns a copy that appends a raw option before positional arguments. + public func argument(_ value: String) -> Self { copy(extraArguments: state.extraArguments + [value]) } + + /// Returns a copy that appends raw options before positional arguments. + public func arguments(_ values: [String]) -> Self { copy(extraArguments: state.extraArguments + values) } + + /// Returns a copy that appends a positional package, binary, or script argument. + public func positionalArgument(_ value: String) -> Self { copy(positionals: state.positionals + [value]) } + + /// Returns a copy that appends positional package, binary, or script arguments. + public func positionalArguments(_ values: [String]) -> Self { copy(positionals: state.positionals + values) } + + /// Builds the raw `pnpm` command represented by the current builder state. + public func command() -> Command { + var arguments = [state.subcommand] + appendOption("--dir", state.directoryPath, to: &arguments) + if state.isRecursive { arguments.append("--recursive") } + for filter in state.filters { arguments += ["--filter", filter] } + if state.ifPresentEnabled { arguments.append("--if-present") } + if state.usesFrozenLockfile { arguments.append("--frozen-lockfile") } + if state.isProduction { arguments.append("--prod") } + if state.outputsJSON { arguments.append("--json") } + arguments += state.extraArguments + if let scriptName = state.scriptName { arguments.append(scriptName) } + arguments += state.positionals + + let base = Command("pnpm").args(arguments).stdout(state.stdoutDestination).stderr(state.stderrDestination) + return state.config.apply(to: base) + } + + private func copy( + config: ToolConfiguration? = nil, + stdoutDestination: OutputDestination? = nil, + stderrDestination: OutputDestination? = nil, + subcommand: String? = nil, + scriptName: String?? = nil, + directoryPath: String?? = nil, + filters: [String]? = nil, + isRecursive: Bool? = nil, + ifPresentEnabled: Bool? = nil, + usesFrozenLockfile: Bool? = nil, + isProduction: Bool? = nil, + outputsJSON: Bool? = nil, + extraArguments: [String]? = nil, + positionals: [String]? = nil + ) -> Self { + Self( + state: State( + config: config ?? state.config, + stdoutDestination: stdoutDestination ?? state.stdoutDestination, + stderrDestination: stderrDestination ?? state.stderrDestination, + subcommand: subcommand ?? state.subcommand, + scriptName: scriptName ?? state.scriptName, + directoryPath: directoryPath ?? state.directoryPath, + filters: filters ?? state.filters, + isRecursive: isRecursive ?? state.isRecursive, + ifPresentEnabled: ifPresentEnabled ?? state.ifPresentEnabled, + usesFrozenLockfile: usesFrozenLockfile ?? state.usesFrozenLockfile, + isProduction: isProduction ?? state.isProduction, + outputsJSON: outputsJSON ?? state.outputsJSON, + extraArguments: extraArguments ?? state.extraArguments, + positionals: positionals ?? state.positionals + ) + ) + } +} + +private struct State: Sendable { + let config: ToolConfiguration + let stdoutDestination: OutputDestination + let stderrDestination: OutputDestination + let subcommand: String + let scriptName: String? + let directoryPath: String? + let filters: [String] + let isRecursive: Bool + let ifPresentEnabled: Bool + let usesFrozenLockfile: Bool + let isProduction: Bool + let outputsJSON: Bool + let extraArguments: [String] + let positionals: [String] + + init( + config: ToolConfiguration, + stdoutDestination: OutputDestination = .capture, + stderrDestination: OutputDestination = .capture, + subcommand: String = "--version", + scriptName: String? = nil, + directoryPath: String? = nil, + filters: [String] = [], + isRecursive: Bool = false, + ifPresentEnabled: Bool = false, + usesFrozenLockfile: Bool = false, + isProduction: Bool = false, + outputsJSON: Bool = false, + extraArguments: [String] = [], + positionals: [String] = [] + ) { + self.config = config + self.stdoutDestination = stdoutDestination + self.stderrDestination = stderrDestination + self.subcommand = subcommand + self.scriptName = scriptName + self.directoryPath = directoryPath + self.filters = filters + self.isRecursive = isRecursive + self.ifPresentEnabled = ifPresentEnabled + self.usesFrozenLockfile = usesFrozenLockfile + self.isProduction = isProduction + self.outputsJSON = outputsJSON + self.extraArguments = extraArguments + self.positionals = positionals + } +} +#endif diff --git a/Sources/SwiftyShell/Python/Python.swift b/Sources/SwiftyShell/Python/Python.swift new file mode 100644 index 0000000..b8790f9 --- /dev/null +++ b/Sources/SwiftyShell/Python/Python.swift @@ -0,0 +1,170 @@ +#if Python +import Foundation + +/// A fluent wrapper for the Python interpreter CLI (`python3` by default). +/// +/// ``Python`` is for script orchestration, not embedded Python interop. It +/// models common interpreter modes such as `-m`, `-c`, and script execution. +/// +/// ```swift +/// try await Python() +/// .module("http.server") +/// .argument("8080") +/// .run() +/// ``` +public struct Python: RunnableCommandFamily { + private let state: State + + /// The shell context used when running this command family. + public var context: ShellContext { state.config.context } + + /// Creates a Python command family bound to a shell context. + public init(context: ShellContext = .init()) { + self.state = State(config: ToolConfiguration(context: context)) + } + + private init(state: State) { self.state = state } + + /// Returns a copy with updated shared tool configuration. + public func updatingConfiguration(_ update: (ToolConfiguration) -> ToolConfiguration) -> Self { + copy(config: update(state.config)) + } + + /// Returns a copy that routes stdout to the given destination. + public func settingStdoutDestination(_ destination: OutputDestination) -> Self { + copy(stdoutDestination: destination) + } + + /// Returns a copy that routes stderr to the given destination. + public func settingStderrDestination(_ destination: OutputDestination) -> Self { + copy(stderrDestination: destination) + } + + /// Returns a copy that prints Python version information with `--version`. + public func version() -> Self { copy(mode: .version, programArguments: []) } + + /// Returns a copy that runs a module with `-m `. + public func module(_ name: String) -> Self { copy(mode: .module(name), programArguments: []) } + + /// Returns a copy that runs code with `-c `. + public func commandString(_ code: String) -> Self { copy(mode: .command(code), programArguments: []) } + + /// Returns a copy that runs a Python script path. + public func script(_ path: String) -> Self { copy(mode: .script(path), programArguments: []) } + + /// Returns a copy that passes `-I` for isolated mode. + public func isolated(_ enabled: Bool = true) -> Self { copy(isolatedEnabled: enabled) } + + /// Returns a copy that passes `-u` for unbuffered binary stdout and stderr. + public func unbuffered(_ enabled: Bool = true) -> Self { copy(unbufferedEnabled: enabled) } + + /// Returns a copy that passes `-B` to avoid writing `.pyc` files. + public func dontWriteBytecode(_ enabled: Bool = true) -> Self { copy(dontWriteBytecodeEnabled: enabled) } + + /// Returns a copy that appends `-O` optimization flags. + public func optimize(_ level: Int = 1) -> Self { copy(optimizationLevel: level) } + + /// Returns a copy that appends a raw interpreter option before the mode. + public func option(_ value: String) -> Self { copy(extraOptions: state.extraOptions + [value]) } + + /// Returns a copy that appends raw interpreter options before the mode. + public func options(_ values: [String]) -> Self { copy(extraOptions: state.extraOptions + values) } + + /// Returns a copy that appends an argument for the selected module, command, or script. + public func argument(_ value: String) -> Self { copy(programArguments: state.arguments + [value]) } + + /// Returns a copy that appends arguments for the selected module, command, or script. + public func arguments(_ values: [String]) -> Self { copy(programArguments: state.arguments + values) } + + /// Builds the raw `python3` command represented by the current builder state. + public func command() -> Command { + var arguments: [String] = [] + if state.isolatedEnabled { arguments.append("-I") } + if state.unbufferedEnabled { arguments.append("-u") } + if state.dontWriteBytecodeEnabled { arguments.append("-B") } + for _ in 0.. Self { + Self( + state: State( + config: config ?? state.config, + stdoutDestination: stdoutDestination ?? state.stdoutDestination, + stderrDestination: stderrDestination ?? state.stderrDestination, + mode: mode ?? state.mode, + isolatedEnabled: isolatedEnabled ?? state.isolatedEnabled, + unbufferedEnabled: unbufferedEnabled ?? state.unbufferedEnabled, + dontWriteBytecodeEnabled: dontWriteBytecodeEnabled ?? state.dontWriteBytecodeEnabled, + optimizationLevel: optimizationLevel ?? state.optimizationLevel, + extraOptions: extraOptions ?? state.extraOptions, + arguments: programArguments ?? state.arguments + ) + ) + } +} + +private enum Mode: Sendable { + case version + case module(String) + case command(String) + case script(String) +} + +private struct State: Sendable { + let config: ToolConfiguration + let stdoutDestination: OutputDestination + let stderrDestination: OutputDestination + let mode: Mode + let isolatedEnabled: Bool + let unbufferedEnabled: Bool + let dontWriteBytecodeEnabled: Bool + let optimizationLevel: Int + let extraOptions: [String] + let arguments: [String] + + init( + config: ToolConfiguration, + stdoutDestination: OutputDestination = .capture, + stderrDestination: OutputDestination = .capture, + mode: Mode = .version, + isolatedEnabled: Bool = false, + unbufferedEnabled: Bool = false, + dontWriteBytecodeEnabled: Bool = false, + optimizationLevel: Int = 0, + extraOptions: [String] = [], + arguments: [String] = [] + ) { + self.config = config + self.stdoutDestination = stdoutDestination + self.stderrDestination = stderrDestination + self.mode = mode + self.isolatedEnabled = isolatedEnabled + self.unbufferedEnabled = unbufferedEnabled + self.dontWriteBytecodeEnabled = dontWriteBytecodeEnabled + self.optimizationLevel = optimizationLevel + self.extraOptions = extraOptions + self.arguments = arguments + } +} +#endif diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Articles/GettingStarted.md b/Sources/SwiftyShell/SwiftyShell.docc/Articles/GettingStarted.md index aa8976f..5ad9a51 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/Articles/GettingStarted.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/Articles/GettingStarted.md @@ -5,7 +5,7 @@ Add SwiftyShell to your package, pick a typed command family, and run your first ## Overview SwiftyShell provides typed wrappers for common shell tools — ``Git``, ``Brew``, ``Grep``, -and more. The compiler enforces which flags exist, what arguments are required, and what +``Make``, ``Npm``, ``Yarn``, ``Pnpm``, ``Bun``, ``Terraform``, ``Kubectl``, ``Python``, and more. The compiler enforces which flags exist, what arguments are required, and what the result looks like. For the full API reference and per-family guides, see the article and the [documentation](https://maniramezan.github.io/SwiftyShell/documentation/swiftyshell/). diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md b/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md index b63c814..e36dab0 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/Articles/SelectingCommandFamilies.md @@ -6,7 +6,8 @@ Pick exactly the typed shell wrappers your project needs. SwiftyShell is split into a small, always-available `Core` (commands, pipelines, contexts, errors, executors) and a set of **opt-in** typed -command families: ``Git``, ``Brew``, ``Grep``, ``Fzf``, ``Rg``, ``Swift``, ``Gh``, ``Docker``, and a collection of common +command families: ``Git``, ``Brew``, ``Grep``, ``Fzf``, ``Rg``, ``Swift``, ``Gh``, ``Docker``, ``Make``, ``Node``, ``Npm``, +``Yarn``, ``Pnpm``, ``Bun``, ``Terraform``, ``Kubectl``, ``Python``, and a collection of common file/directory utilities (``Ls``, ``Cp``, ``Mv``, ``Mkdir``, ``Chmod``, ``Rm``, ``Pwd``, ``Jq``, ``Rsync``, ``Tar``, ``Zip``, ``Unzip``). @@ -51,6 +52,15 @@ let status = try await Git() | `Swift` | ``Swift`` Swift toolchain and SwiftPM wrapper | | `Gh` | ``Gh`` GitHub CLI automation wrapper | | `Docker` | ``Docker`` Docker CLI automation wrapper | +| `Make` | ``Make`` build automation wrapper | +| `Node` | ``Node`` Node.js runtime wrapper | +| `Npm` | ``Npm`` package manager and script runner wrapper | +| `Yarn` | ``Yarn`` package manager and script runner wrapper | +| `Pnpm` | ``Pnpm`` package manager and workspace script runner wrapper | +| `Bun` | ``Bun`` runtime, bundler, and package manager wrapper | +| `Terraform` | ``Terraform`` infrastructure automation wrapper | +| `Kubectl` | ``Kubectl`` Kubernetes CLI automation wrapper | +| `Python` | ``Python`` interpreter and script runner wrapper | | `Ls` | ``Ls`` | | `Cp` | ``Cp`` | | `Mv` | ``Mv`` | diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Bun.md b/Sources/SwiftyShell/SwiftyShell.docc/Bun.md new file mode 100644 index 0000000..d85b3f0 --- /dev/null +++ b/Sources/SwiftyShell/SwiftyShell.docc/Bun.md @@ -0,0 +1,61 @@ +# ``Bun`` + +A fluent wrapper for the Bun runtime and package manager CLI. + +## Overview + +Use ``Bun`` for script execution, testing, dependency management, package binary +execution, and bundling through Bun's CLI. + +```swift +try await Bun() + .runScript("dev") + .watch() + .run() +``` + +The wrapper exposes common automation flags and keeps raw options available for +newer Bun features. + +```swift +let output = try await Bun() + .build("src/index.ts") + .argument("--outdir") + .positionalArgument("dist") + .run() +``` + +## Topics + +### Subcommands + +- ``BunSubcommand`` +- ``subcommand(_:)-(BunSubcommand)`` +- ``subcommand(_:)-(String)`` +- ``install()`` + +### Running Code + +- ``Bun/runScript(_:)`` +- ``Bun/test()`` +- ``Bun/x(_:)`` + +### Building + +- ``build(_:)-(String...)`` +- ``build(_:)-([String])`` + +### Options + +- ``cwd(_:)`` +- ``watch(_:)`` +- ``hot(_:)`` +- ``production(_:)`` +- ``frozenLockfile(_:)`` + +### Arguments + +- ``argument(_:)`` +- ``arguments(_:)`` +- ``positionalArgument(_:)`` +- ``positionalArguments(_:)`` diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Kubectl.md b/Sources/SwiftyShell/SwiftyShell.docc/Kubectl.md new file mode 100644 index 0000000..11c7dbe --- /dev/null +++ b/Sources/SwiftyShell/SwiftyShell.docc/Kubectl.md @@ -0,0 +1,64 @@ +# ``Kubectl`` + +A fluent wrapper for the Kubernetes `kubectl` CLI. + +``Kubectl`` covers common cluster automation: reading resources, applying files, +deleting resources, fetching logs, executing container commands, and shared +selection flags like kubeconfig, context, namespace, output, selectors, and +containers. + +List pods as JSON: + +```swift +let pods = try await Kubectl(context: context) + .get("pods") + .namespace("default") + .output("json") + .run() +``` + +Apply a manifest: + +```swift +try await Kubectl(context: context) + .apply() + .filename("deploy.yml") + .run() +``` + +## Topics + +### Subcommands + +- ``KubectlSubcommand`` +- ``subcommand(_:)-(KubectlSubcommand)`` +- ``subcommand(_:)-(String)`` +- ``get(_:)`` +- ``describe(_:)`` +- ``apply()`` +- ``delete(_:)`` +- ``logs(_:)`` +- ``exec(_:)`` + +### Options + +- ``kubeconfig(_:)`` +- ``contextName(_:)`` +- ``namespace(_:)`` +- ``output(_:)`` +- ``filename(_:)`` +- ``selector(_:)`` +- ``container(_:)`` +- ``allNamespaces(_:)`` + +### Arguments + +- ``argument(_:)`` +- ``arguments(_:)`` +- ``positionalArgument(_:)`` +- ``positionalArguments(_:)`` + +### Running + +- ``init(context:)`` +- ``command()`` diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Make.md b/Sources/SwiftyShell/SwiftyShell.docc/Make.md new file mode 100644 index 0000000..064648c --- /dev/null +++ b/Sources/SwiftyShell/SwiftyShell.docc/Make.md @@ -0,0 +1,51 @@ +# ``Make`` + +A fluent wrapper for the `make` build automation CLI. + +``Make`` is useful for project scripts that already expose tasks through a +Makefile. It models common flags such as Makefile selection, directory changes, +parallel jobs, dry runs, and target lists while keeping raw variable and option +arguments available. + +Run a repository check target with parallel jobs: + +```swift +try await Make(context: context) + .file("Makefile") + .jobs(8) + .target("check") + .run() +``` + +Pass Make variables before targets: + +```swift +try await Make(context: context) + .argument("CONFIGURATION=release") + .targets(["build", "package"]) + .run() +``` + +## Topics + +### Options + +- ``file(_:)`` +- ``directory(_:)`` +- ``jobs(_:)`` +- ``keepGoing(_:)`` +- ``silent(_:)`` +- ``dryRun(_:)`` +- ``alwaysMake(_:)`` + +### Targets And Raw Arguments + +- ``argument(_:)`` +- ``arguments(_:)`` +- ``target(_:)`` +- ``targets(_:)`` + +### Running + +- ``init(context:)`` +- ``command()`` diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Node.md b/Sources/SwiftyShell/SwiftyShell.docc/Node.md new file mode 100644 index 0000000..c018413 --- /dev/null +++ b/Sources/SwiftyShell/SwiftyShell.docc/Node.md @@ -0,0 +1,52 @@ +# ``Node`` + +A fluent wrapper for the Node.js runtime CLI (`node`). + +``Node`` focuses on scripting entry points: version checks, inline JavaScript, +syntax checks, preload modules, and running script files with arguments. + +Evaluate inline JavaScript: + +```swift +let output = try await Node(context: context) + .eval("console.log(process.version)") + .run() +``` + +Run a script with runtime options and script arguments: + +```swift +try await Node(context: context) + .require("dotenv/config") + .script("server.js") + .scriptArguments(["--port", "3000"]) + .run() +``` + +## Topics + +### Entry Points + +- ``version()`` +- ``eval(_:)`` +- ``printExpression(_:)`` +- ``check(_:)`` +- ``script(_:)`` + +### Runtime Options + +- ``require(_:)`` +- ``inspect(_:)`` +- ``watch(_:)`` + +### Arguments + +- ``argument(_:)`` +- ``arguments(_:)`` +- ``scriptArgument(_:)`` +- ``scriptArguments(_:)`` + +### Running + +- ``init(context:)`` +- ``command()`` diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Npm.md b/Sources/SwiftyShell/SwiftyShell.docc/Npm.md new file mode 100644 index 0000000..5e46a2f --- /dev/null +++ b/Sources/SwiftyShell/SwiftyShell.docc/Npm.md @@ -0,0 +1,60 @@ +# ``Npm`` + +A fluent wrapper for the npm package manager CLI. + +``Npm`` covers common package automation: lockfile installs, running package +scripts, tests, package execution, and shared npm flags such as `--prefix`, +`--if-present`, `--production`, and `--json`. + +Run a package script: + +```swift +try await Npm(context: context) + .runScript("build") + .prefix("Example") + .ifPresent() + .run() +``` + +Install dependencies in CI: + +```swift +try await Npm(context: context) + .ci() + .prefix("Web") + .run() +``` + +## Topics + +### Subcommands + +- ``NpmSubcommand`` +- ``subcommand(_:)-(NpmSubcommand)`` +- ``subcommand(_:)-(String)`` +- ``install()`` +- ``ci()`` +- ``test()`` +- ``exec(_:)`` +- ``runScript(_:)`` + +### Options + +- ``prefix(_:)`` +- ``global(_:)`` +- ``production(_:)`` +- ``ifPresent(_:)`` +- ``silent(_:)`` +- ``json(_:)`` + +### Arguments + +- ``argument(_:)`` +- ``arguments(_:)`` +- ``positionalArgument(_:)`` +- ``positionalArguments(_:)`` + +### Running + +- ``init(context:)`` +- ``command()`` diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Pnpm.md b/Sources/SwiftyShell/SwiftyShell.docc/Pnpm.md new file mode 100644 index 0000000..5d4a56e --- /dev/null +++ b/Sources/SwiftyShell/SwiftyShell.docc/Pnpm.md @@ -0,0 +1,62 @@ +# ``Pnpm`` + +A fluent wrapper for the pnpm package manager CLI. + +## Overview + +Use ``Pnpm`` for deterministic installs, workspace filtering, recursive script +runs, and package binary execution. + +```swift +try await Pnpm() + .runScript("build") + .recursive() + .filter("./packages/app") + .run() +``` + +Raw options and positional arguments keep the wrapper usable for less common +pnpm commands without dropping to ``Command``. + +```swift +let output = try await Pnpm() + .subcommand(.audit) + .json() + .run() +``` + +## Topics + +### Subcommands + +- ``PnpmSubcommand`` +- ``subcommand(_:)-(PnpmSubcommand)`` +- ``subcommand(_:)-(String)`` +- ``install()`` + +### Running Scripts + +- ``Pnpm/runScript(_:)`` +- ``Pnpm/test()`` +- ``Pnpm/exec(_:)`` +- ``Pnpm/dlx(_:)`` + +### Workspaces + +- ``Pnpm/filter(_:)`` +- ``Pnpm/recursive(_:)`` + +### Options + +- ``directory(_:)`` +- ``ifPresent(_:)`` +- ``frozenLockfile(_:)`` +- ``production(_:)`` +- ``json(_:)`` + +### Arguments + +- ``argument(_:)`` +- ``arguments(_:)`` +- ``positionalArgument(_:)`` +- ``positionalArguments(_:)`` diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Python.md b/Sources/SwiftyShell/SwiftyShell.docc/Python.md new file mode 100644 index 0000000..85822aa --- /dev/null +++ b/Sources/SwiftyShell/SwiftyShell.docc/Python.md @@ -0,0 +1,52 @@ +# ``Python`` + +A fluent wrapper for the Python interpreter CLI (`python3` by default). + +``Python`` is for process-based script orchestration. It does not embed Python +or expose dynamic Python objects; use it when Swift automation needs to run +Python modules, inline commands, or script files as subprocesses. + +Start a local HTTP server module: + +```swift +try await Python(context: context) + .module("http.server") + .argument("8080") + .run() +``` + +Run inline Python: + +```swift +let output = try await Python(context: context) + .commandString("print('hello')") + .run() +``` + +## Topics + +### Entry Points + +- ``version()`` +- ``module(_:)`` +- ``commandString(_:)`` +- ``script(_:)`` + +### Interpreter Options + +- ``isolated(_:)`` +- ``unbuffered(_:)`` +- ``dontWriteBytecode(_:)`` +- ``optimize(_:)`` +- ``option(_:)`` +- ``options(_:)`` + +### Arguments + +- ``argument(_:)`` +- ``arguments(_:)`` + +### Running + +- ``init(context:)`` +- ``command()`` diff --git a/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md b/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md index 8991bc2..449b80e 100644 --- a/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md +++ b/Sources/SwiftyShell/SwiftyShell.docc/SwiftyShell.md @@ -5,8 +5,10 @@ Type-safe shell support for Swift. ## Overview SwiftyShell's primary API is a family of typed wrappers — ``Git``, ``Grep``, ``Rg``, -``Brew``, ``Fzf``, ``Swift``, ``Gh``, ``Docker``, ``Ls``, ``Cp``, ``Mkdir``, ``Chmod``, ``Rm``, ``Mv``, ``Pwd``, ``Jq``, -``Rsync``, ``Tar``, ``Zip``, ``Unzip`` — that model shell tools as Swift values. The compiler enforces which flags exist, +``Brew``, ``Fzf``, ``Swift``, ``Gh``, ``Docker``, ``Make``, ``Node``, ``Npm``, +``Yarn``, ``Pnpm``, ``Bun``, ``Terraform``, ``Kubectl``, ``Python``, ``Ls``, ``Cp``, +``Mkdir``, ``Chmod``, ``Rm``, ``Mv``, ``Pwd``, ``Jq``, ``Rsync``, ``Tar``, ``Zip``, +``Unzip`` — that model shell tools as Swift values. The compiler enforces which flags exist, which arguments are required, and what the result looks like. ``Command`` is the fluent escape hatch for tools that don't have a typed wrapper yet; it shares the same builder style so code does not change shape when you fall back @@ -164,6 +166,24 @@ let output = try await Command("echo", arguments: "hello").run(in: context) - ``DockerSubcommand`` - ``DockerBuildProgress`` +### Scripting CLIs + +- ``Make`` +- ``Node`` +- ``Npm`` +- ``NpmSubcommand`` +- ``Yarn`` +- ``YarnSubcommand`` +- ``Pnpm`` +- ``PnpmSubcommand`` +- ``Bun`` +- ``BunSubcommand`` +- ``Terraform`` +- ``TerraformSubcommand`` +- ``Kubectl`` +- ``KubectlSubcommand`` +- ``Python`` + ### Common File-System Commands - ``Ls`` diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Terraform.md b/Sources/SwiftyShell/SwiftyShell.docc/Terraform.md new file mode 100644 index 0000000..0e55574 --- /dev/null +++ b/Sources/SwiftyShell/SwiftyShell.docc/Terraform.md @@ -0,0 +1,72 @@ +# ``Terraform`` + +A fluent wrapper for the Terraform CLI. + +``Terraform`` models high-value infrastructure automation commands such as +`init`, `plan`, `apply`, `destroy`, `validate`, `fmt`, `output`, and workspace +operations. It preserves raw flags for provider- or workflow-specific options. + +Create a plan file in CI: + +```swift +try await Terraform(context: context) + .chdir("infra") + .plan() + .input(false) + .noColor() + .var("region=us-central1") + .out("tfplan") + .run() +``` + +Apply a saved plan: + +```swift +try await Terraform(context: context) + .apply() + .autoApprove() + .positionalArgument("tfplan") + .run() +``` + +## Topics + +### Subcommands + +- ``TerraformSubcommand`` +- ``subcommand(_:)-(TerraformSubcommand)`` +- ``subcommand(_:)-(String)`` +- ``initCommand()`` +- ``plan()`` +- ``apply()`` +- ``destroy()`` +- ``validate()`` +- ``format()`` +- ``output()`` +- ``workspace(_:)`` + +### Options + +- ``chdir(_:)`` +- ``input(_:)`` +- ``noColor(_:)`` +- ``json(_:)`` +- ``autoApprove(_:)`` +- ``refresh(_:)`` +- ``var(_:)-(String)`` +- ``var(_:_:)`` +- ``varFile(_:)`` +- ``out(_:)`` +- ``target(_:)`` + +### Arguments + +- ``argument(_:)`` +- ``arguments(_:)`` +- ``positionalArgument(_:)`` +- ``positionalArguments(_:)`` + +### Running + +- ``init(context:)`` +- ``command()`` diff --git a/Sources/SwiftyShell/SwiftyShell.docc/Yarn.md b/Sources/SwiftyShell/SwiftyShell.docc/Yarn.md new file mode 100644 index 0000000..83ce85f --- /dev/null +++ b/Sources/SwiftyShell/SwiftyShell.docc/Yarn.md @@ -0,0 +1,57 @@ +# ``Yarn`` + +A fluent wrapper for the Yarn package manager CLI. + +## Overview + +Use ``Yarn`` when you want package installs, script execution, or workspace +automation to share SwiftyShell's configuration, redirection, and testing +surface. + +```swift +try await Yarn() + .runScript("build") + .immutable() + .run() +``` + +You can still reach newer or plugin-provided Yarn commands by combining +``subcommand(_:)-(YarnSubcommand)``, raw arguments, and positional arguments. + +```swift +let output = try await Yarn() + .subcommand(.workspaces) + .positionalArguments(["foreach", "--all", "run", "test"]) + .run() +``` + +## Topics + +### Subcommands + +- ``YarnSubcommand`` +- ``subcommand(_:)-(YarnSubcommand)`` +- ``subcommand(_:)-(String)`` +- ``install()`` + +### Running Scripts + +- ``Yarn/runScript(_:)`` +- ``Yarn/test()`` +- ``Yarn/exec(_:)`` +- ``Yarn/dlx(_:)`` + +### Options + +- ``cwd(_:)`` +- ``immutable(_:)`` +- ``production(_:)`` +- ``silent(_:)`` +- ``json(_:)`` + +### Arguments + +- ``argument(_:)`` +- ``arguments(_:)`` +- ``positionalArgument(_:)`` +- ``positionalArguments(_:)`` diff --git a/Sources/SwiftyShell/Terraform/Terraform.swift b/Sources/SwiftyShell/Terraform/Terraform.swift new file mode 100644 index 0000000..be455b2 --- /dev/null +++ b/Sources/SwiftyShell/Terraform/Terraform.swift @@ -0,0 +1,259 @@ +#if Terraform +import Foundation + +/// The top-level Terraform CLI command to invoke. +public enum TerraformSubcommand: String, Sendable, Equatable, Hashable { + /// `terraform version` — print Terraform version information. + case version + /// `terraform init` — initialize a working directory. + case initialize = "init" + /// `terraform plan` — create an execution plan. + case plan + /// `terraform apply` — apply infrastructure changes. + case apply + /// `terraform destroy` — destroy managed infrastructure. + case destroy + /// `terraform validate` — validate configuration files. + case validate + /// `terraform fmt` — rewrite configuration files to canonical format. + case format = "fmt" + /// `terraform output` — read output values. + case output + /// `terraform workspace` — manage workspaces. + case workspace +} + +/// A fluent wrapper for the Terraform CLI. +/// +/// ``Terraform`` models high-value automation workflows such as `init`, `plan`, +/// `apply`, `destroy`, and `validate`, plus shared flags used in CI. +/// +/// ```swift +/// try await Terraform() +/// .plan() +/// .var("region=us-central1") +/// .out("tfplan") +/// .run() +/// ``` +public struct Terraform: RunnableCommandFamily { + private let state: State + + /// The shell context used when running this command family. + public var context: ShellContext { state.config.context } + + /// Creates a Terraform command family bound to a shell context. + public init(context: ShellContext = .init()) { + self.state = State(config: ToolConfiguration(context: context)) + } + + private init(state: State) { self.state = state } + + /// Returns a copy with updated shared tool configuration. + public func updatingConfiguration(_ update: (ToolConfiguration) -> ToolConfiguration) -> Self { + copy(config: update(state.config)) + } + + /// Returns a copy that routes stdout to the given destination. + public func settingStdoutDestination(_ destination: OutputDestination) -> Self { + copy(stdoutDestination: destination) + } + + /// Returns a copy that routes stderr to the given destination. + public func settingStderrDestination(_ destination: OutputDestination) -> Self { + copy(stderrDestination: destination) + } + + /// Returns a copy that selects a Terraform subcommand. + public func subcommand(_ value: TerraformSubcommand) -> Self { copy(subcommand: value.rawValue, positionals: []) } + + /// Returns a copy that selects a raw Terraform subcommand. + public func subcommand(_ value: String) -> Self { copy(subcommand: value, positionals: []) } + + /// Returns a copy configured for `terraform init`. + public func initCommand() -> Self { subcommand(.initialize) } + + /// Returns a copy configured for `terraform plan`. + public func plan() -> Self { subcommand(.plan) } + + /// Returns a copy configured for `terraform apply`. + public func apply() -> Self { subcommand(.apply) } + + /// Returns a copy configured for `terraform destroy`. + public func destroy() -> Self { subcommand(.destroy) } + + /// Returns a copy configured for `terraform validate`. + public func validate() -> Self { subcommand(.validate) } + + /// Returns a copy configured for `terraform fmt`. + public func format() -> Self { subcommand(.format) } + + /// Returns a copy configured for `terraform output`. + public func output() -> Self { subcommand(.output) } + + /// Returns a copy configured for `terraform workspace `. + public func workspace(_ nested: String? = nil) -> Self { + var result = subcommand(.workspace) + if let nested { result = result.positionalArgument(nested) } + return result + } + + /// Returns a copy that passes `-chdir=` before the subcommand. + public func chdir(_ path: String) -> Self { copy(chdirPath: path) } + + /// Returns a copy that passes `-input=`. + public func input(_ enabled: Bool) -> Self { copy(inputEnabled: enabled) } + + /// Returns a copy that passes `-no-color`. + public func noColor(_ enabled: Bool = true) -> Self { copy(noColorEnabled: enabled) } + + /// Returns a copy that passes `-json` for commands that support machine-readable output. + public func json(_ enabled: Bool = true) -> Self { copy(jsonEnabled: enabled) } + + /// Returns a copy that passes `-auto-approve`. + public func autoApprove(_ enabled: Bool = true) -> Self { copy(autoApproves: enabled) } + + /// Returns a copy that passes `-refresh=`. + public func refresh(_ enabled: Bool) -> Self { copy(refreshEnabled: enabled) } + + /// Returns a copy that passes `-var `. + public func `var`(_ assignment: String) -> Self { copy(vars: state.vars + [assignment]) } + + /// Returns a copy that passes `-var `. + public func `var`(_ key: String, _ value: String) -> Self { self.var("\(key)=\(value)") } + + /// Returns a copy that passes `-var-file `. + public func varFile(_ path: String) -> Self { copy(varFiles: state.varFiles + [path]) } + + /// Returns a copy that passes `-out `. + public func out(_ path: String) -> Self { copy(outPath: path) } + + /// Returns a copy that passes `-target
`. + public func target(_ address: String) -> Self { copy(targets: state.targets + [address]) } + + /// Returns a copy that appends a raw Terraform option before positional arguments. + public func argument(_ value: String) -> Self { copy(extraArguments: state.extraArguments + [value]) } + + /// Returns a copy that appends raw Terraform options before positional arguments. + public func arguments(_ values: [String]) -> Self { copy(extraArguments: state.extraArguments + values) } + + /// Returns a copy that appends a positional argument. + public func positionalArgument(_ value: String) -> Self { copy(positionals: state.positionals + [value]) } + + /// Returns a copy that appends positional arguments. + public func positionalArguments(_ values: [String]) -> Self { copy(positionals: state.positionals + values) } + + /// Builds the raw `terraform` command represented by the current builder state. + public func command() -> Command { + var arguments: [String] = [] + if let chdirPath = state.chdirPath { arguments.append("-chdir=\(chdirPath)") } + arguments.append(state.subcommand) + if let inputEnabled = state.inputEnabled { arguments.append("-input=\(inputEnabled)") } + if state.noColorEnabled { arguments.append("-no-color") } + if state.jsonEnabled { arguments.append("-json") } + if state.autoApproves { arguments.append("-auto-approve") } + if let refreshEnabled = state.refreshEnabled { arguments.append("-refresh=\(refreshEnabled)") } + for value in state.vars { arguments += ["-var", value] } + for path in state.varFiles { arguments += ["-var-file", path] } + if let outPath = state.outPath { arguments += ["-out", outPath] } + for target in state.targets { arguments += ["-target", target] } + arguments += state.extraArguments + state.positionals + let base = Command("terraform").args(arguments).stdout(state.stdoutDestination).stderr(state.stderrDestination) + return state.config.apply(to: base) + } + + private func copy( + config: ToolConfiguration? = nil, + stdoutDestination: OutputDestination? = nil, + stderrDestination: OutputDestination? = nil, + subcommand: String? = nil, + chdirPath: String?? = nil, + inputEnabled: Bool?? = nil, + noColorEnabled: Bool? = nil, + jsonEnabled: Bool? = nil, + autoApproves: Bool? = nil, + refreshEnabled: Bool?? = nil, + vars: [String]? = nil, + varFiles: [String]? = nil, + outPath: String?? = nil, + targets: [String]? = nil, + extraArguments: [String]? = nil, + positionals: [String]? = nil + ) -> Self { + Self( + state: State( + config: config ?? state.config, + stdoutDestination: stdoutDestination ?? state.stdoutDestination, + stderrDestination: stderrDestination ?? state.stderrDestination, + subcommand: subcommand ?? state.subcommand, + chdirPath: chdirPath ?? state.chdirPath, + inputEnabled: inputEnabled ?? state.inputEnabled, + noColorEnabled: noColorEnabled ?? state.noColorEnabled, + jsonEnabled: jsonEnabled ?? state.jsonEnabled, + autoApproves: autoApproves ?? state.autoApproves, + refreshEnabled: refreshEnabled ?? state.refreshEnabled, + vars: vars ?? state.vars, + varFiles: varFiles ?? state.varFiles, + outPath: outPath ?? state.outPath, + targets: targets ?? state.targets, + extraArguments: extraArguments ?? state.extraArguments, + positionals: positionals ?? state.positionals + ) + ) + } +} + +private struct State: Sendable { + let config: ToolConfiguration + let stdoutDestination: OutputDestination + let stderrDestination: OutputDestination + let subcommand: String + let chdirPath: String? + let inputEnabled: Bool? + let noColorEnabled: Bool + let jsonEnabled: Bool + let autoApproves: Bool + let refreshEnabled: Bool? + let vars: [String] + let varFiles: [String] + let outPath: String? + let targets: [String] + let extraArguments: [String] + let positionals: [String] + + init( + config: ToolConfiguration, + stdoutDestination: OutputDestination = .capture, + stderrDestination: OutputDestination = .capture, + subcommand: String = TerraformSubcommand.version.rawValue, + chdirPath: String? = nil, + inputEnabled: Bool? = nil, + noColorEnabled: Bool = false, + jsonEnabled: Bool = false, + autoApproves: Bool = false, + refreshEnabled: Bool? = nil, + vars: [String] = [], + varFiles: [String] = [], + outPath: String? = nil, + targets: [String] = [], + extraArguments: [String] = [], + positionals: [String] = [] + ) { + self.config = config + self.stdoutDestination = stdoutDestination + self.stderrDestination = stderrDestination + self.subcommand = subcommand + self.chdirPath = chdirPath + self.inputEnabled = inputEnabled + self.noColorEnabled = noColorEnabled + self.jsonEnabled = jsonEnabled + self.autoApproves = autoApproves + self.refreshEnabled = refreshEnabled + self.vars = vars + self.varFiles = varFiles + self.outPath = outPath + self.targets = targets + self.extraArguments = extraArguments + self.positionals = positionals + } +} +#endif diff --git a/Sources/SwiftyShell/Yarn/Yarn.swift b/Sources/SwiftyShell/Yarn/Yarn.swift new file mode 100644 index 0000000..01562e9 --- /dev/null +++ b/Sources/SwiftyShell/Yarn/Yarn.swift @@ -0,0 +1,223 @@ +#if Yarn +import Foundation + +/// The top-level Yarn command to invoke. +public enum YarnSubcommand: String, Sendable, Equatable, Hashable { + /// `yarn install` — install project dependencies. + case install + /// `yarn add` — add dependencies to a project. + case add + /// `yarn remove` — remove dependencies from a project. + case remove + /// `yarn run` — run a package script or binary. + case run + /// `yarn test` — run the package test script. + case test + /// `yarn exec` — execute a command in the project environment. + case exec + /// `yarn dlx` — run a package in a temporary environment. + case dlx + /// `yarn workspaces` — run workspace-level commands. + case workspaces + /// `yarn version` — manage project versions. + case version +} + +/// A fluent wrapper for the Yarn package manager CLI. +/// +/// ``Yarn`` covers common dependency installation, script execution, and +/// workspace automation while still allowing raw options and positional +/// arguments for less common subcommands. +/// +/// ```swift +/// try await Yarn() +/// .runScript("build") +/// .immutable() +/// .run() +/// ``` +public struct Yarn: RunnableCommandFamily { + private let state: State + + /// The shell context used when running this command family. + public var context: ShellContext { state.config.context } + + /// Creates a Yarn command family bound to a shell context. + public init(context: ShellContext = .init()) { + self.state = State(config: ToolConfiguration(context: context)) + } + + private init(state: State) { self.state = state } + + /// Returns a copy with updated shared tool configuration. + public func updatingConfiguration(_ update: (ToolConfiguration) -> ToolConfiguration) -> Self { + copy(config: update(state.config)) + } + + /// Returns a copy that routes stdout to the given destination. + public func settingStdoutDestination(_ destination: OutputDestination) -> Self { + copy(stdoutDestination: destination) + } + + /// Returns a copy that routes stderr to the given destination. + public func settingStderrDestination(_ destination: OutputDestination) -> Self { + copy(stderrDestination: destination) + } + + /// Returns a copy that selects a Yarn subcommand. + public func subcommand(_ value: YarnSubcommand) -> Self { copy(subcommand: value.rawValue, scriptName: nil) } + + /// Returns a copy that selects a raw Yarn subcommand. + public func subcommand(_ value: String) -> Self { copy(subcommand: value, scriptName: nil) } + + /// Returns a copy configured for `yarn install`. + public func install() -> Self { subcommand(.install) } + + /// Returns a copy configured for `yarn add `. + public func add(_ packages: String...) -> Self { add(packages) } + + /// Returns a copy configured for `yarn add `. + public func add(_ packages: [String]) -> Self { copy(subcommand: "add", scriptName: nil, positionals: packages) } + + /// Returns a copy configured for `yarn remove `. + public func remove(_ packages: String...) -> Self { remove(packages) } + + /// Returns a copy configured for `yarn remove `. + public func remove(_ packages: [String]) -> Self { + copy(subcommand: "remove", scriptName: nil, positionals: packages) + } + + /// Returns a copy configured for `yarn test`. + public func test() -> Self { subcommand(.test) } + + /// Returns a copy configured for `yarn exec `. + public func exec(_ binary: String? = nil) -> Self { + copy(subcommand: "exec", scriptName: nil, positionals: binary.map { [$0] } ?? []) + } + + /// Returns a copy configured for `yarn dlx `. + public func dlx(_ package: String? = nil) -> Self { + copy(subcommand: "dlx", scriptName: nil, positionals: package.map { [$0] } ?? []) + } + + /// Returns a copy configured for `yarn run `. + public func runScript(_ name: String) -> Self { copy(subcommand: "run", scriptName: name, positionals: []) } + + /// Returns a copy that passes `--cwd `. + public func cwd(_ path: String) -> Self { copy(cwdPath: path) } + + /// Returns a copy that passes `--immutable`. + public func immutable(_ enabled: Bool = true) -> Self { copy(isImmutable: enabled) } + + /// Returns a copy that passes `--production`. + public func production(_ enabled: Bool = true) -> Self { copy(isProduction: enabled) } + + /// Returns a copy that passes `--silent`. + public func silent(_ enabled: Bool = true) -> Self { copy(isSilent: enabled) } + + /// Returns a copy that passes `--json`. + public func json(_ enabled: Bool = true) -> Self { copy(outputsJSON: enabled) } + + /// Returns a copy that appends a raw option before positional arguments. + public func argument(_ value: String) -> Self { copy(extraArguments: state.extraArguments + [value]) } + + /// Returns a copy that appends raw options before positional arguments. + public func arguments(_ values: [String]) -> Self { copy(extraArguments: state.extraArguments + values) } + + /// Returns a copy that appends a positional package, binary, or script argument. + public func positionalArgument(_ value: String) -> Self { copy(positionals: state.positionals + [value]) } + + /// Returns a copy that appends positional package, binary, or script arguments. + public func positionalArguments(_ values: [String]) -> Self { copy(positionals: state.positionals + values) } + + /// Builds the raw `yarn` command represented by the current builder state. + public func command() -> Command { + var arguments = [state.subcommand] + appendOption("--cwd", state.cwdPath, to: &arguments) + if state.isImmutable { arguments.append("--immutable") } + if state.isProduction { arguments.append("--production") } + if state.isSilent { arguments.append("--silent") } + if state.outputsJSON { arguments.append("--json") } + arguments += state.extraArguments + if let scriptName = state.scriptName { arguments.append(scriptName) } + arguments += state.positionals + + let base = Command("yarn").args(arguments).stdout(state.stdoutDestination).stderr(state.stderrDestination) + return state.config.apply(to: base) + } + + private func copy( + config: ToolConfiguration? = nil, + stdoutDestination: OutputDestination? = nil, + stderrDestination: OutputDestination? = nil, + subcommand: String? = nil, + scriptName: String?? = nil, + cwdPath: String?? = nil, + isImmutable: Bool? = nil, + isProduction: Bool? = nil, + isSilent: Bool? = nil, + outputsJSON: Bool? = nil, + extraArguments: [String]? = nil, + positionals: [String]? = nil + ) -> Self { + Self( + state: State( + config: config ?? state.config, + stdoutDestination: stdoutDestination ?? state.stdoutDestination, + stderrDestination: stderrDestination ?? state.stderrDestination, + subcommand: subcommand ?? state.subcommand, + scriptName: scriptName ?? state.scriptName, + cwdPath: cwdPath ?? state.cwdPath, + isImmutable: isImmutable ?? state.isImmutable, + isProduction: isProduction ?? state.isProduction, + isSilent: isSilent ?? state.isSilent, + outputsJSON: outputsJSON ?? state.outputsJSON, + extraArguments: extraArguments ?? state.extraArguments, + positionals: positionals ?? state.positionals + ) + ) + } +} + +private struct State: Sendable { + let config: ToolConfiguration + let stdoutDestination: OutputDestination + let stderrDestination: OutputDestination + let subcommand: String + let scriptName: String? + let cwdPath: String? + let isImmutable: Bool + let isProduction: Bool + let isSilent: Bool + let outputsJSON: Bool + let extraArguments: [String] + let positionals: [String] + + init( + config: ToolConfiguration, + stdoutDestination: OutputDestination = .capture, + stderrDestination: OutputDestination = .capture, + subcommand: String = "--version", + scriptName: String? = nil, + cwdPath: String? = nil, + isImmutable: Bool = false, + isProduction: Bool = false, + isSilent: Bool = false, + outputsJSON: Bool = false, + extraArguments: [String] = [], + positionals: [String] = [] + ) { + self.config = config + self.stdoutDestination = stdoutDestination + self.stderrDestination = stderrDestination + self.subcommand = subcommand + self.scriptName = scriptName + self.cwdPath = cwdPath + self.isImmutable = isImmutable + self.isProduction = isProduction + self.isSilent = isSilent + self.outputsJSON = outputsJSON + self.extraArguments = extraArguments + self.positionals = positionals + } +} +#endif diff --git a/Tests/SwiftyShellTests/Bun/BunTests.swift b/Tests/SwiftyShellTests/Bun/BunTests.swift new file mode 100644 index 0000000..9cde7aa --- /dev/null +++ b/Tests/SwiftyShellTests/Bun/BunTests.swift @@ -0,0 +1,80 @@ +#if Bun +import Testing +@testable import SwiftyShell + +struct BunCommandTests { + @Test func defaultsToVersionCommand() { + let command = Bun().command() + + #expect(command.executableName == "bun") + #expect(command.arguments == ["--version"]) + } + + @Test func buildsRunScriptCommand() { + let command = Bun() + .runScript("dev") + .cwd("Example") + .watch() + .hot() + .argument("--bun") + .positionalArguments(["--port", "3000"]) + .command() + + #expect( + command.arguments == [ + "run", + "--cwd", "Example", + "--watch", + "--hot", + "--bun", + "dev", + "--port", "3000", + ] + ) + } + + @Test func buildsModeledSubcommandsAndFlags() { + #expect( + Bun().install().frozenLockfile().production().command().arguments == [ + "install", "--production", "--frozen-lockfile", + ] + ) + #expect(Bun().add("hono", "zod").command().arguments == ["add", "hono", "zod"]) + #expect(Bun().remove(["left-pad"]).command().arguments == ["remove", "left-pad"]) + #expect(Bun().test().command().arguments == ["test"]) + #expect( + Bun().build(["src/index.ts"]).argument("--outdir").positionalArgument("dist").command().arguments == [ + "build", "src/index.ts", "--outdir", "dist", + ] + ) + #expect(Bun().x("vite").command().arguments == ["x", "vite"]) + #expect(Bun().subcommand(.pm).positionalArgument("ls").command().arguments == ["pm", "ls"]) + } + + @Test func preservesToolConfigurationOverrides() async throws { + actor Recorder { var command: Command?; func record(_ command: Command) { self.command = command } } + let recorder = Recorder() + let context = ShellContext( + executor: MockExecutor { command, _ in + await recorder.record(command) + return ShellOutput(stdout: "1.2.0", stderr: "", exitCode: 0) + } + ) + + let output = try await Bun(context: context) + .executable("/opt/bin/bun") + .workingDirectory("/app") + .timeout(5) + .outputLimit(1024) + .run() + + let command = await recorder.command + #expect(output.stdout == "1.2.0") + #expect(command?.executableOverride == "/opt/bin/bun") + #expect(command?.workingDirectoryOverride == "/app") + #expect(command?.timeoutOverride == 5) + #expect(command?.outputLimitOverride == 1024) + #expect(command?.arguments == ["--version"]) + } +} +#endif diff --git a/Tests/SwiftyShellTests/Core/CommandTests.swift b/Tests/SwiftyShellTests/Core/CommandTests.swift index 068016c..380734f 100644 --- a/Tests/SwiftyShellTests/Core/CommandTests.swift +++ b/Tests/SwiftyShellTests/Core/CommandTests.swift @@ -3,7 +3,7 @@ import Testing @testable import SwiftyShell private func waitForFile(at path: String) async throws { - for _ in 0..<100 { + for _ in 0..<500 { if FileManager.default.fileExists(atPath: path) { return } @@ -92,7 +92,7 @@ struct CommandTests { } @Test func timeoutPreservesPartialOutput() async throws { - let context = ShellContext(defaultTimeout: 0.5) + let context = ShellContext(defaultTimeout: 5) let marker = "/tmp/swiftyshell-timeout-\(UUID().uuidString)" defer { try? FileManager.default.removeItem(atPath: marker) } diff --git a/Tests/SwiftyShellTests/Kubectl/KubectlTests.swift b/Tests/SwiftyShellTests/Kubectl/KubectlTests.swift new file mode 100644 index 0000000..d2d0331 --- /dev/null +++ b/Tests/SwiftyShellTests/Kubectl/KubectlTests.swift @@ -0,0 +1,87 @@ +#if Kubectl +import Testing +@testable import SwiftyShell + +struct KubectlCommandTests { + @Test func defaultsToVersionCommand() { + let command = Kubectl().command() + + #expect(command.executableName == "kubectl") + #expect(command.arguments == ["version"]) + } + + @Test func buildsGetCommandWithSelectionFlags() { + let command = Kubectl() + .kubeconfig("/tmp/kubeconfig") + .contextName("kind-local") + .get("pods") + .namespace("default") + .output("json") + .selector("app=api") + .allNamespaces() + .command() + + #expect( + command.arguments == [ + "--kubeconfig", "/tmp/kubeconfig", + "--context", "kind-local", + "get", + "--namespace", "default", + "--output", "json", + "--selector", "app=api", + "--all-namespaces", + "pods", + ] + ) + } + + @Test func buildsApplyDeleteLogsAndExecCommands() { + #expect(Kubectl().apply().filename("deploy.yml").command().arguments == ["apply", "--filename", "deploy.yml"]) + #expect( + Kubectl().apply().filename("a.yml").filename("b.yml").command().arguments == [ + "apply", "--filename", "a.yml", "--filename", "b.yml", + ] + ) + #expect(Kubectl().delete("pod/api").command().arguments == ["delete", "pod/api"]) + #expect( + Kubectl().logs("pod/api").container("api").command().arguments == ["logs", "--container", "api", "pod/api"] + ) + #expect( + Kubectl().exec("pod/api").argument("--").positionalArguments(["env"]).command().arguments == [ + "exec", "--", "pod/api", "env", + ] + ) + #expect( + Kubectl().subcommand(.rollout).positionalArguments(["status", "deployment/api"]).command().arguments == [ + "rollout", "status", "deployment/api", + ] + ) + } + + @Test func preservesToolConfigurationOverrides() async throws { + actor Recorder { var command: Command?; func record(_ command: Command) { self.command = command } } + let recorder = Recorder() + let context = ShellContext( + executor: MockExecutor { command, _ in + await recorder.record(command) + return ShellOutput(stdout: "Client Version", stderr: "", exitCode: 0) + } + ) + + let output = try await Kubectl(context: context) + .executable("/opt/bin/kubectl") + .workingDirectory("/cluster") + .timeout(5) + .outputLimit(1024) + .run() + + let command = await recorder.command + #expect(output.stdout == "Client Version") + #expect(command?.executableOverride == "/opt/bin/kubectl") + #expect(command?.workingDirectoryOverride == "/cluster") + #expect(command?.timeoutOverride == 5) + #expect(command?.outputLimitOverride == 1024) + #expect(command?.arguments == ["version"]) + } +} +#endif diff --git a/Tests/SwiftyShellTests/Make/MakeTests.swift b/Tests/SwiftyShellTests/Make/MakeTests.swift new file mode 100644 index 0000000..775310f --- /dev/null +++ b/Tests/SwiftyShellTests/Make/MakeTests.swift @@ -0,0 +1,78 @@ +#if Make +import Testing +@testable import SwiftyShell + +struct MakeCommandTests { + @Test func buildsDefaultMakeCommand() { + let command = Make().command() + + #expect(command.executableName == "make") + #expect(command.arguments == []) + } + + @Test func buildsMakeCommandWithCommonOptionsAndTargets() { + let command = Make() + .file("Build.mk") + .directory("Example") + .jobs(8) + .keepGoing() + .silent() + .dryRun() + .alwaysMake() + .argument("CONFIG=release") + .target("check") + .targets(["package", "deploy"]) + .command() + + #expect( + command.arguments == [ + "--file", "Build.mk", + "--directory", "Example", + "--jobs", "8", + "--keep-going", + "--silent", + "--dry-run", + "--always-make", + "CONFIG=release", + "check", "package", "deploy", + ] + ) + } + + @Test func serializesJobCountAsSeparateArgument() { + #expect(Make().jobs(8).command().arguments == ["--jobs", "8"]) + } + + @Test func preservesToolConfigurationOverrides() async throws { + actor Recorder { + var command: Command? + func record(_ command: Command) { self.command = command } + } + + let recorder = Recorder() + let context = ShellContext( + executor: MockExecutor { command, _ in + await recorder.record(command) + return ShellOutput(stdout: "ok", stderr: "", exitCode: 0) + } + ) + + let output = try await Make(context: context) + .executable("/usr/bin/make") + .workingDirectory("/repo") + .timeout(5) + .outputLimit(1024) + .target("check") + .run() + + let command = await recorder.command + #expect(output.stdout == "ok") + #expect(command?.executableName == "make") + #expect(command?.executableOverride == "/usr/bin/make") + #expect(command?.workingDirectoryOverride == "/repo") + #expect(command?.timeoutOverride == 5) + #expect(command?.outputLimitOverride == 1024) + #expect(command?.arguments == ["check"]) + } +} +#endif diff --git a/Tests/SwiftyShellTests/Node/NodeTests.swift b/Tests/SwiftyShellTests/Node/NodeTests.swift new file mode 100644 index 0000000..4fbe6aa --- /dev/null +++ b/Tests/SwiftyShellTests/Node/NodeTests.swift @@ -0,0 +1,79 @@ +#if Node +import Testing +@testable import SwiftyShell + +struct NodeCommandTests { + @Test func defaultsToVersionCommand() { + let command = Node().command() + + #expect(command.executableName == "node") + #expect(command.arguments == ["--version"]) + } + + @Test func buildsEvalAndPrintCommands() { + #expect( + Node() + .require("dotenv/config") + .inspect() + .watch() + .argument("--trace-warnings") + .eval("console.log(process.version)") + .scriptArgument("--verbose") + .command().arguments == [ + "--require", "dotenv/config", + "--inspect", + "--watch", + "--trace-warnings", + "--eval", "console.log(process.version)", + "--verbose", + ] + ) + + #expect(Node().printExpression("1 + 1").command().arguments == ["--print", "1 + 1"]) + } + + @Test func buildsCheckAndScriptCommands() { + #expect(Node().check("index.js").command().arguments == ["--check", "index.js"]) + #expect( + Node().script("server.js").scriptArguments(["--port", "3000"]).command().arguments == [ + "server.js", "--port", "3000", + ] + ) + } + + @Test func placesRequiredModulesBeforeScriptPath() { + #expect( + Node().script("server.js").require("dotenv/config").command().arguments == [ + "--require", "dotenv/config", "server.js", + ] + ) + } + + @Test func preservesToolConfigurationOverrides() async throws { + actor Recorder { var command: Command?; func record(_ command: Command) { self.command = command } } + let recorder = Recorder() + let context = ShellContext( + executor: MockExecutor { command, _ in + await recorder.record(command) + return ShellOutput(stdout: "v22.0.0", stderr: "", exitCode: 0) + } + ) + + let output = try await Node(context: context) + .executable("/opt/bin/node") + .workingDirectory("/app") + .timeout(5) + .outputLimit(1024) + .version() + .run() + + let command = await recorder.command + #expect(output.stdout == "v22.0.0") + #expect(command?.executableOverride == "/opt/bin/node") + #expect(command?.workingDirectoryOverride == "/app") + #expect(command?.timeoutOverride == 5) + #expect(command?.outputLimitOverride == 1024) + #expect(command?.arguments == ["--version"]) + } +} +#endif diff --git a/Tests/SwiftyShellTests/Npm/NpmTests.swift b/Tests/SwiftyShellTests/Npm/NpmTests.swift new file mode 100644 index 0000000..d7a48ac --- /dev/null +++ b/Tests/SwiftyShellTests/Npm/NpmTests.swift @@ -0,0 +1,77 @@ +#if Npm +import Testing +@testable import SwiftyShell + +struct NpmCommandTests { + @Test func defaultsToVersionCommand() { + let command = Npm().command() + + #expect(command.executableName == "npm") + #expect(command.arguments == ["--version"]) + } + + @Test func buildsRunScriptCommand() { + let command = Npm() + .runScript("build") + .prefix("Example") + .ifPresent() + .silent() + .argument("--") + .positionalArguments(["--mode", "production"]) + .command() + + #expect( + command.arguments == [ + "run", + "--prefix", "Example", + "--if-present", + "--silent", + "--", + "build", + "--mode", "production", + ] + ) + } + + @Test func buildsModeledSubcommandsAndFlags() { + #expect( + Npm().install().production().positionalArgument("left-pad").command().arguments == [ + "install", "--production", "left-pad", + ] + ) + #expect(Npm().ci().command().arguments == ["ci"]) + #expect(Npm().test().command().arguments == ["test"]) + #expect(Npm().exec("vite").global().json().command().arguments == ["exec", "--global", "--json", "vite"]) + #expect(Npm().subcommand(.audit).command().arguments == ["audit"]) + #expect( + Npm().subcommand("view").positionalArgument("swift-shell").command().arguments == ["view", "swift-shell"] + ) + } + + @Test func preservesToolConfigurationOverrides() async throws { + actor Recorder { var command: Command?; func record(_ command: Command) { self.command = command } } + let recorder = Recorder() + let context = ShellContext( + executor: MockExecutor { command, _ in + await recorder.record(command) + return ShellOutput(stdout: "10.0.0", stderr: "", exitCode: 0) + } + ) + + let output = try await Npm(context: context) + .executable("/opt/bin/npm") + .workingDirectory("/app") + .timeout(5) + .outputLimit(1024) + .run() + + let command = await recorder.command + #expect(output.stdout == "10.0.0") + #expect(command?.executableOverride == "/opt/bin/npm") + #expect(command?.workingDirectoryOverride == "/app") + #expect(command?.timeoutOverride == 5) + #expect(command?.outputLimitOverride == 1024) + #expect(command?.arguments == ["--version"]) + } +} +#endif diff --git a/Tests/SwiftyShellTests/Pnpm/PnpmTests.swift b/Tests/SwiftyShellTests/Pnpm/PnpmTests.swift new file mode 100644 index 0000000..8d11304 --- /dev/null +++ b/Tests/SwiftyShellTests/Pnpm/PnpmTests.swift @@ -0,0 +1,101 @@ +#if Pnpm +import Testing +@testable import SwiftyShell + +struct PnpmCommandTests { + @Test func defaultsToVersionCommand() { + let command = Pnpm().command() + + #expect(command.executableName == "pnpm") + #expect(command.arguments == ["--version"]) + } + + @Test func buildsRunScriptCommand() { + let command = Pnpm() + .runScript("build") + .directory("Example") + .filter("./packages/app") + .recursive() + .ifPresent() + .argument("--stream") + .positionalArguments(["--mode", "production"]) + .command() + + #expect( + command.arguments == [ + "run", + "--dir", "Example", + "--recursive", + "--filter", "./packages/app", + "--if-present", + "--stream", + "build", + "--mode", "production", + ] + ) + } + + @Test func buildsRecursiveFilteredWorkspaceRun() { + let command = Pnpm() + .runScript("test") + .recursive() + .filter("./packages/app") + .filter("./packages/shared") + .command() + + #expect( + command.arguments == [ + "run", + "--recursive", + "--filter", "./packages/app", + "--filter", "./packages/shared", + "test", + ] + ) + } + + @Test func buildsRecursiveRunWithoutFilters() { + #expect(Pnpm().runScript("test").recursive().command().arguments == ["run", "--recursive", "test"]) + } + + @Test func buildsModeledSubcommandsAndFlags() { + #expect( + Pnpm().install().frozenLockfile().production().command().arguments == [ + "install", "--frozen-lockfile", "--prod", + ] + ) + #expect(Pnpm().add("left-pad", "vite").command().arguments == ["add", "left-pad", "vite"]) + #expect(Pnpm().remove(["left-pad"]).command().arguments == ["remove", "left-pad"]) + #expect(Pnpm().test().command().arguments == ["test"]) + #expect(Pnpm().exec("vite").json().command().arguments == ["exec", "--json", "vite"]) + #expect(Pnpm().dlx("create-vite").command().arguments == ["dlx", "create-vite"]) + #expect(Pnpm().subcommand(.audit).command().arguments == ["audit"]) + } + + @Test func preservesToolConfigurationOverrides() async throws { + actor Recorder { var command: Command?; func record(_ command: Command) { self.command = command } } + let recorder = Recorder() + let context = ShellContext( + executor: MockExecutor { command, _ in + await recorder.record(command) + return ShellOutput(stdout: "11.0.0", stderr: "", exitCode: 0) + } + ) + + let output = try await Pnpm(context: context) + .executable("/opt/bin/pnpm") + .workingDirectory("/app") + .timeout(5) + .outputLimit(1024) + .run() + + let command = await recorder.command + #expect(output.stdout == "11.0.0") + #expect(command?.executableOverride == "/opt/bin/pnpm") + #expect(command?.workingDirectoryOverride == "/app") + #expect(command?.timeoutOverride == 5) + #expect(command?.outputLimitOverride == 1024) + #expect(command?.arguments == ["--version"]) + } +} +#endif diff --git a/Tests/SwiftyShellTests/Python/PythonTests.swift b/Tests/SwiftyShellTests/Python/PythonTests.swift new file mode 100644 index 0000000..be68f60 --- /dev/null +++ b/Tests/SwiftyShellTests/Python/PythonTests.swift @@ -0,0 +1,76 @@ +#if Python +import Testing +@testable import SwiftyShell + +struct PythonCommandTests { + @Test func defaultsToPython3VersionCommand() { + let command = Python().command() + + #expect(command.executableName == "python3") + #expect(command.arguments == ["--version"]) + } + + @Test func buildsModuleAndCommandInvocations() { + #expect( + Python() + .isolated() + .unbuffered() + .dontWriteBytecode() + .optimize(2) + .option("-X") + .option("dev") + .module("http.server") + .argument("8080") + .command().arguments == [ + "-I", "-u", "-B", "-O", "-O", "-X", "dev", "-m", "http.server", "8080", + ] + ) + + #expect(Python().commandString("print('hi')").command().arguments == ["-c", "print('hi')"]) + } + + @Test func buildsScriptInvocationAndClampsOptimization() { + #expect( + Python().script("tool.py").optimize(-1).arguments(["--input", "data.json"]).command().arguments == [ + "tool.py", "--input", "data.json", + ] + ) + } + + @Test func repeatsOptimizationFlagForOptimizationLevel() { + #expect(Python().script("tool.py").optimize(2).command().arguments == ["-O", "-O", "tool.py"]) + } + + @Test func negativeOptimizationLevelEmitsNoOptimizationFlags() { + #expect(Python().script("tool.py").optimize(-1).command().arguments == ["tool.py"]) + } + + @Test func preservesToolConfigurationOverrides() async throws { + actor Recorder { var command: Command?; func record(_ command: Command) { self.command = command } } + let recorder = Recorder() + let context = ShellContext( + executor: MockExecutor { command, _ in + await recorder.record(command) + return ShellOutput(stdout: "Python 3.13", stderr: "", exitCode: 0) + } + ) + + let output = try await Python(context: context) + .executable("/opt/bin/python3.13") + .workingDirectory("/scripts") + .timeout(5) + .outputLimit(1024) + .version() + .run() + + let command = await recorder.command + #expect(output.stdout == "Python 3.13") + #expect(command?.executableName == "python3") + #expect(command?.executableOverride == "/opt/bin/python3.13") + #expect(command?.workingDirectoryOverride == "/scripts") + #expect(command?.timeoutOverride == 5) + #expect(command?.outputLimitOverride == 1024) + #expect(command?.arguments == ["--version"]) + } +} +#endif diff --git a/Tests/SwiftyShellTests/Terraform/TerraformTests.swift b/Tests/SwiftyShellTests/Terraform/TerraformTests.swift new file mode 100644 index 0000000..8f2deda --- /dev/null +++ b/Tests/SwiftyShellTests/Terraform/TerraformTests.swift @@ -0,0 +1,111 @@ +#if Terraform +import Testing +@testable import SwiftyShell + +struct TerraformCommandTests { + @Test func defaultsToVersionCommand() { + let command = Terraform().command() + + #expect(command.executableName == "terraform") + #expect(command.arguments == ["version"]) + } + + @Test func buildsPlanCommandWithAutomationFlags() { + let command = Terraform() + .chdir("infra") + .plan() + .input(false) + .noColor() + .refresh(false) + .var("region=us-central1") + .varFile("prod.tfvars") + .out("tfplan") + .target("module.api") + .argument("-lock=false") + .command() + + #expect( + command.arguments == [ + "-chdir=infra", + "plan", + "-input=false", + "-no-color", + "-refresh=false", + "-var", "region=us-central1", + "-var-file", "prod.tfvars", + "-out", "tfplan", + "-target", "module.api", + "-lock=false", + ] + ) + } + + @Test func buildsPlanCommandWithVariablesPlanOutputTargetsAndPositionals() { + let command = Terraform() + .plan() + .var("region", "us-central1") + .varFile("prod.tfvars") + .out("plan.out") + .target("module.api") + .positionalArgument("infra") + .command() + + #expect( + command.arguments == [ + "plan", + "-var", "region=us-central1", + "-var-file", "prod.tfvars", + "-out", "plan.out", + "-target", "module.api", + "infra", + ] + ) + } + + @Test func buildsModeledSubcommands() { + #expect(Terraform().initCommand().command().arguments == ["init"]) + #expect( + Terraform().apply().autoApprove().positionalArgument("tfplan").command().arguments == [ + "apply", "-auto-approve", "tfplan", + ] + ) + #expect(Terraform().apply().autoApprove().json().command().arguments == ["apply", "-json", "-auto-approve"]) + #expect(Terraform().destroy().autoApprove().command().arguments == ["destroy", "-auto-approve"]) + #expect(Terraform().validate().command().arguments == ["validate"]) + #expect(Terraform().format().command().arguments == ["fmt"]) + #expect(Terraform().output().json().command().arguments == ["output", "-json"]) + #expect( + Terraform().workspace("select").positionalArgument("prod").command().arguments == [ + "workspace", "select", "prod", + ] + ) + #expect(Terraform().subcommand("state").positionalArguments(["list"]).command().arguments == ["state", "list"]) + } + + @Test func preservesToolConfigurationOverrides() async throws { + actor Recorder { var command: Command?; func record(_ command: Command) { self.command = command } } + let recorder = Recorder() + let context = ShellContext( + executor: MockExecutor { command, _ in + await recorder.record(command) + return ShellOutput(stdout: "Terraform v1.0.0", stderr: "", exitCode: 0) + } + ) + + let output = try await Terraform(context: context) + .executable("/opt/bin/terraform") + .workingDirectory("/infra") + .timeout(5) + .outputLimit(1024) + .run() + + let command = await recorder.command + #expect(output.stdout == "Terraform v1.0.0") + #expect(command?.executableOverride == "/opt/bin/terraform") + #expect(command?.workingDirectoryOverride == "/infra") + #expect(command?.timeoutOverride == 5) + #expect(command?.outputLimitOverride == 1024) + #expect(command?.arguments == ["version"]) + } +} +#endif diff --git a/Tests/SwiftyShellTests/Yarn/YarnTests.swift b/Tests/SwiftyShellTests/Yarn/YarnTests.swift new file mode 100644 index 0000000..6aa82a8 --- /dev/null +++ b/Tests/SwiftyShellTests/Yarn/YarnTests.swift @@ -0,0 +1,74 @@ +#if Yarn +import Testing +@testable import SwiftyShell + +struct YarnCommandTests { + @Test func defaultsToVersionCommand() { + let command = Yarn().command() + + #expect(command.executableName == "yarn") + #expect(command.arguments == ["--version"]) + } + + @Test func buildsRunScriptCommand() { + let command = Yarn() + .runScript("build") + .cwd("Example") + .immutable() + .silent() + .argument("--inspect") + .positionalArguments(["--mode", "production"]) + .command() + + #expect( + command.arguments == [ + "run", + "--cwd", "Example", + "--immutable", + "--silent", + "--inspect", + "build", + "--mode", "production", + ] + ) + } + + @Test func buildsModeledSubcommandsAndFlags() { + #expect(Yarn().install().production().command().arguments == ["install", "--production"]) + #expect(Yarn().add("left-pad", "vite").command().arguments == ["add", "left-pad", "vite"]) + #expect(Yarn().remove(["left-pad"]).command().arguments == ["remove", "left-pad"]) + #expect(Yarn().test().command().arguments == ["test"]) + #expect(Yarn().exec("vite").json().command().arguments == ["exec", "--json", "vite"]) + #expect(Yarn().dlx("create-vite").command().arguments == ["dlx", "create-vite"]) + #expect( + Yarn().subcommand(.workspaces).positionalArgument("list").command().arguments == ["workspaces", "list"] + ) + } + + @Test func preservesToolConfigurationOverrides() async throws { + actor Recorder { var command: Command?; func record(_ command: Command) { self.command = command } } + let recorder = Recorder() + let context = ShellContext( + executor: MockExecutor { command, _ in + await recorder.record(command) + return ShellOutput(stdout: "4.0.0", stderr: "", exitCode: 0) + } + ) + + let output = try await Yarn(context: context) + .executable("/opt/bin/yarn") + .workingDirectory("/app") + .timeout(5) + .outputLimit(1024) + .run() + + let command = await recorder.command + #expect(output.stdout == "4.0.0") + #expect(command?.executableOverride == "/opt/bin/yarn") + #expect(command?.workingDirectoryOverride == "/app") + #expect(command?.timeoutOverride == 5) + #expect(command?.outputLimitOverride == 1024) + #expect(command?.arguments == ["--version"]) + } +} +#endif