diff --git a/Sources/LucaCLI/Commands/RunCommand.swift b/Sources/LucaCLI/Commands/RunCommand.swift index ca57701..d948748 100644 --- a/Sources/LucaCLI/Commands/RunCommand.swift +++ b/Sources/LucaCLI/Commands/RunCommand.swift @@ -80,6 +80,18 @@ struct RunCommand: AsyncParsableCommand { )) var params: [String] = [] + @Option(name: .customLong("env-file"), help: ArgumentHelp( + "Path to a dotenv file of environment variables injected into every task.", + discussion: """ + The file must contain KEY=VALUE pairs, one per line. Lines starting with # are \ + treated as comments. Surrounding single or double quotes on values are stripped. + Defaults to .env in the current directory when present. \ + An explicit path fails if the file does not exist. + """, + valueName: "path" + )) + var envFile: String? + func validate() throws { guard name != nil || file != nil else { throw ValidationError("Missing required argument. Provide or --file .") @@ -121,18 +133,32 @@ struct RunCommand: AsyncParsableCommand { provided: parsedParams() ) + let envFilePath = envFile.map { URL(fileURLWithPath: $0) } + ?? invocationDirectory.appending(component: ".env") + let isExplicit = envFile != nil + let envFileLoader = EnvFileLoader(fileManager: fileManager) + let envFileEnvironment: [String: String] + do { + envFileEnvironment = try envFileLoader.load(from: envFilePath) + } catch EnvFileLoader.EnvFileLoaderError.fileNotFound(let url) where isExplicit { + throw EnvFileLoader.EnvFileLoaderError.fileNotFound(url) + } catch EnvFileLoader.EnvFileLoaderError.fileNotFound { + envFileEnvironment = [:] + } + if dryRun { let conditionEvaluator = TaskConditionEvaluator() printDryRun(pipeline: pipeline, pipelinePath: pipelinePath, validator: validator, printer: printer, resolvedParams: resolvedParams, providedParams: parsedParams(), - conditionEvaluator: conditionEvaluator) + conditionEvaluator: conditionEvaluator, envFilePath: envFilePath, + envFileEnvironment: envFileEnvironment) return } try validator.validate(pipeline) - let runner = PipelineRunner(printer: printer) - try await runner.run(pipeline, currentDirectoryURL: invocationDirectory, parameters: resolvedParams) + let runner: any PipelineRunning = PipelineRunner(printer: printer) + try await runner.run(pipeline, currentDirectoryURL: invocationDirectory, parameters: resolvedParams, envFileEnvironment: envFileEnvironment) } @@ -168,7 +194,9 @@ struct RunCommand: AsyncParsableCommand { printer: Printing, resolvedParams: [String: String], providedParams: [String: String], - conditionEvaluator: TaskConditionEvaluating + conditionEvaluator: TaskConditionEvaluating, + envFilePath: URL, + envFileEnvironment: [String: String] ) { let displayName = name ?? pipelinePath.lastPathComponent printer.printFormatted("\(.accent("[DRY RUN] Pipeline: \(displayName)"))") @@ -194,6 +222,14 @@ struct RunCommand: AsyncParsableCommand { printer.printFormatted("\(.raw(""))") } + if !envFileEnvironment.isEmpty { + printer.printFormatted("\(.primary("Env file: \(envFilePath.path)"))") + for key in envFileEnvironment.keys.sorted() { + printer.printFormatted("\(.raw(" \(key)"))") + } + printer.printFormatted("\(.raw(""))") + } + let allResults = validator.toolCheckResults(for: pipeline) for (index, task) in pipeline.tasks.enumerated() { @@ -211,7 +247,7 @@ struct RunCommand: AsyncParsableCommand { } if let condition = task.when { - var context: [String: String] = [:] + var context: [String: String] = envFileEnvironment if let pipelineEnv = pipeline.env { context.merge(pipelineEnv) { _, new in new } } if let taskEnv = task.env { context.merge(taskEnv) { _, new in new } } context.merge(resolvedParams) { _, new in new } diff --git a/Sources/LucaCLI/Utils/FileManagerWrapper.swift b/Sources/LucaCLI/Utils/FileManagerWrapper.swift index c393cc8..1ab88c8 100644 --- a/Sources/LucaCLI/Utils/FileManagerWrapper.swift +++ b/Sources/LucaCLI/Utils/FileManagerWrapper.swift @@ -2,8 +2,9 @@ import Foundation import LucaCore +import PipelineCore -public struct FileManagerWrapper: FileManaging, PipelineValidatorFileManaging { +public struct FileManagerWrapper: FileManaging, PipelineValidatorFileManaging, EnvFileLoaderFileManaging { private(set) var fileManager: FileManager diff --git a/Sources/PipelineCore/Core/EnvFileLoader/EnvFileLoader.swift b/Sources/PipelineCore/Core/EnvFileLoader/EnvFileLoader.swift new file mode 100644 index 0000000..8f5acee --- /dev/null +++ b/Sources/PipelineCore/Core/EnvFileLoader/EnvFileLoader.swift @@ -0,0 +1,83 @@ +// EnvFileLoader.swift + +import Foundation + +/// Loads and parses dotenv-style env-var files. +/// +/// The expected file format is one `KEY=VALUE` pair per line: +/// +/// ``` +/// API_KEY=secret123 +/// ENVIRONMENT=staging +/// DEBUG=true +/// PORT="8080" +/// # This is a comment +/// ``` +/// +/// Lines starting with `#` and blank lines are ignored. Surrounding single or double +/// quotes on values are stripped. Any non-blank, non-comment line without `=` is +/// rejected with ``EnvFileLoaderError/invalidFormat``. +public struct EnvFileLoader: EnvFileLoading { + + public enum EnvFileLoaderError: Error, LocalizedError, Equatable { + /// The file at the specified URL does not exist or could not be read. + case fileNotFound(URL) + /// The file contains a line that is not a valid `KEY=VALUE` pair. + case invalidFormat + + public var errorDescription: String? { + switch self { + case .fileNotFound(let url): + return "Env file not found at path: \(url.path)" + case .invalidFormat: + return "Env file must contain KEY=VALUE pairs, one per line. Lines starting with # are treated as comments." + } + } + } + + private let fileManager: any EnvFileLoaderFileManaging + + public init(fileManager: any EnvFileLoaderFileManaging = FileManager.default) { + self.fileManager = fileManager + } + + // MARK: - EnvFileLoading + + /// Loads environment variables from the specified dotenv file. + /// + /// - Parameter url: URL to a dotenv-style file with `KEY=VALUE` pairs. + /// - Returns: A dictionary of environment variable names to their string values. + /// - Throws: ``EnvFileLoaderError/fileNotFound(_:)`` if the file does not exist, + /// or ``EnvFileLoaderError/invalidFormat`` if any non-blank, non-comment line lacks `=`. + public func load(from url: URL) throws -> [String: String] { + guard let data = fileManager.contents(atPath: url.path) else { + throw EnvFileLoaderError.fileNotFound(url) + } + + guard let content = String(data: data, encoding: .utf8) else { + throw EnvFileLoaderError.invalidFormat + } + + var result: [String: String] = [:] + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue } + + guard let equalsIndex = trimmed.firstIndex(of: "=") else { + throw EnvFileLoaderError.invalidFormat + } + + let key = String(trimmed[trimmed.startIndex.. [String: String] +} diff --git a/Sources/PipelineCore/Core/FileManagerProtocols/EnvFileLoaderFileManaging.swift b/Sources/PipelineCore/Core/FileManagerProtocols/EnvFileLoaderFileManaging.swift new file mode 100644 index 0000000..fc0455d --- /dev/null +++ b/Sources/PipelineCore/Core/FileManagerProtocols/EnvFileLoaderFileManaging.swift @@ -0,0 +1,10 @@ +// EnvFileLoaderFileManaging.swift + +import Foundation + +/// File system interface for ``EnvFileLoader``. +public protocol EnvFileLoaderFileManaging { + func contents(atPath path: String) -> Data? +} + +extension FileManager: EnvFileLoaderFileManaging {} diff --git a/Sources/PipelineCore/Core/PipelineRunner/PipelineRunner.swift b/Sources/PipelineCore/Core/PipelineRunner/PipelineRunner.swift index df61bef..4c920e3 100644 --- a/Sources/PipelineCore/Core/PipelineRunner/PipelineRunner.swift +++ b/Sources/PipelineCore/Core/PipelineRunner/PipelineRunner.swift @@ -7,7 +7,7 @@ import Noora /// Executes a ``Pipeline`` task by task, printing headers and streaming subprocess output. /// /// Each task runs via `/usr/bin/env bash -c "set -eo pipefail && "`. -/// Environment variables are merged in order: inherited process env ← pipeline-level env ← task-level env. +/// Environment variables are merged in order: inherited process env ← env-file env ← pipeline-level env ← task-level env. /// Working directory is resolved as: task-level → pipeline-level → invocation directory. /// Tasks with a `when:` field are skipped when the condition evaluates to false. public struct PipelineRunner: PipelineRunning { @@ -43,7 +43,7 @@ public struct PipelineRunner: PipelineRunning { // MARK: - PipelineRunning - public func run(_ pipeline: Pipeline, currentDirectoryURL: URL, parameters: [String: String]) async throws { + public func run(_ pipeline: Pipeline, currentDirectoryURL: URL, parameters: [String: String], envFileEnvironment: [String: String]) async throws { let start = Date() let tasks = pipeline.tasks var executedCount = 0 @@ -52,7 +52,7 @@ public struct PipelineRunner: PipelineRunning { printTaskHeader(index: index + 1, total: tasks.count, name: task.name) if let condition = task.when { - let context = buildContext(parameters: parameters, pipelineEnv: pipeline.env, taskEnv: task.env) + let context = buildContext(parameters: parameters, envFileEnvironment: envFileEnvironment, pipelineEnv: pipeline.env, taskEnv: task.env) let shouldRun = conditionEvaluator.evaluate(condition: condition, context: context) if !shouldRun { printer.printFormatted("⊘ \(.muted("Skipped (when: \(condition) → false)"))") @@ -61,7 +61,7 @@ public struct PipelineRunner: PipelineRunning { } } - let env = mergedEnvironment(pipelineEnv: pipeline.env, taskEnv: task.env) + let env = mergedEnvironment(envFileEnvironment: envFileEnvironment, pipelineEnv: pipeline.env, taskEnv: task.env) let workingDirectory = resolveWorkingDirectory(task: task, pipeline: pipeline, invocationDirectory: currentDirectoryURL) let command = renderCommand(task.command, parameters: parameters) @@ -99,16 +99,18 @@ public struct PipelineRunner: PipelineRunning { printer.printFormatted("\(.muted(prefix))\(.accent(name))\(.muted(" " + padding))") } - private func buildContext(parameters: [String: String], pipelineEnv: [String: String]?, taskEnv: [String: String]?) -> [String: String] { + private func buildContext(parameters: [String: String], envFileEnvironment: [String: String], pipelineEnv: [String: String]?, taskEnv: [String: String]?) -> [String: String] { var context: [String: String] = [:] + context.merge(envFileEnvironment) { _, new in new } if let pipelineEnv { context.merge(pipelineEnv) { _, new in new } } if let taskEnv { context.merge(taskEnv) { _, new in new } } context.merge(parameters) { _, new in new } return context } - private func mergedEnvironment(pipelineEnv: [String: String]?, taskEnv: [String: String]?) -> [String: String] { + private func mergedEnvironment(envFileEnvironment: [String: String], pipelineEnv: [String: String]?, taskEnv: [String: String]?) -> [String: String] { var merged: [String: String] = [:] + merged.merge(envFileEnvironment) { _, new in new } if let pipelineEnv { merged.merge(pipelineEnv) { _, new in new } } if let taskEnv { merged.merge(taskEnv) { _, new in new } } return merged diff --git a/Sources/PipelineCore/Core/PipelineRunner/PipelineRunning.swift b/Sources/PipelineCore/Core/PipelineRunner/PipelineRunning.swift index a8217d8..1788fd7 100644 --- a/Sources/PipelineCore/Core/PipelineRunner/PipelineRunning.swift +++ b/Sources/PipelineCore/Core/PipelineRunner/PipelineRunning.swift @@ -10,12 +10,18 @@ public protocol PipelineRunning { /// - pipeline: The pipeline to execute. /// - currentDirectoryURL: The directory from which `luca run` was invoked; used to resolve relative working-directory paths. /// - parameters: Resolved parameter values used to substitute `${name}` tokens in task commands. - func run(_ pipeline: Pipeline, currentDirectoryURL: URL, parameters: [String: String]) async throws + /// - envFileEnvironment: Variables loaded from an `.env` file; merged before pipeline-level env so pipeline/task env can override them. + func run(_ pipeline: Pipeline, currentDirectoryURL: URL, parameters: [String: String], envFileEnvironment: [String: String]) async throws } public extension PipelineRunning { - /// Convenience overload with no parameter substitution. + /// Convenience overload with no env-file environment. + func run(_ pipeline: Pipeline, currentDirectoryURL: URL, parameters: [String: String]) async throws { + try await run(pipeline, currentDirectoryURL: currentDirectoryURL, parameters: parameters, envFileEnvironment: [:]) + } + + /// Convenience overload with no parameter substitution and no env-file environment. func run(_ pipeline: Pipeline, currentDirectoryURL: URL) async throws { - try await run(pipeline, currentDirectoryURL: currentDirectoryURL, parameters: [:]) + try await run(pipeline, currentDirectoryURL: currentDirectoryURL, parameters: [:], envFileEnvironment: [:]) } } diff --git a/Tests/Core/EnvFileLoaderTests.swift b/Tests/Core/EnvFileLoaderTests.swift new file mode 100644 index 0000000..b7a27cd --- /dev/null +++ b/Tests/Core/EnvFileLoaderTests.swift @@ -0,0 +1,138 @@ +// EnvFileLoaderTests.swift + +import Foundation +import Testing +@testable import LucaFoundation +@testable import PipelineCore + +struct EnvFileLoaderTests { + + private let sut = EnvFileLoader() + + private func makeTempURL() -> URL { + FileManager.default.temporaryDirectory.appending(component: UUID().uuidString + ".env") + } + + // MARK: - Valid dotenv + + @Test + func test_load_validKeyValuePairs() throws { + let url = makeTempURL() + let content = "API_KEY=secret123\nENVIRONMENT=staging\n" + try content.write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let result = try sut.load(from: url) + + #expect(result["API_KEY"] == "secret123") + #expect(result["ENVIRONMENT"] == "staging") + #expect(result.count == 2) + } + + @Test + func test_load_quotedValues_stripsQuotes() throws { + let url = makeTempURL() + let content = "DOUBLE=\"hello world\"\nSINGLE='foo bar'\n" + try content.write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let result = try sut.load(from: url) + + #expect(result["DOUBLE"] == "hello world") + #expect(result["SINGLE"] == "foo bar") + } + + @Test + func test_load_commentLines_areSkipped() throws { + let url = makeTempURL() + let content = "# this is a comment\nKEY=value\n# another comment\n" + try content.write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let result = try sut.load(from: url) + + #expect(result.count == 1) + #expect(result["KEY"] == "value") + } + + @Test + func test_load_emptyLines_areSkipped() throws { + let url = makeTempURL() + let content = "\nKEY=value\n\n" + try content.write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let result = try sut.load(from: url) + + #expect(result.count == 1) + #expect(result["KEY"] == "value") + } + + @Test + func test_load_emptyFile() throws { + let url = makeTempURL() + try "".write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let result = try sut.load(from: url) + + #expect(result.isEmpty) + } + + @Test + func test_load_valueWithEquals_preservesRemainder() throws { + let url = makeTempURL() + let content = "URL=https://example.com?a=1&b=2\n" + try content.write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let result = try sut.load(from: url) + + #expect(result["URL"] == "https://example.com?a=1&b=2") + } + + // MARK: - Missing file + + @Test + func test_load_missingFile_throwsFileNotFound() { + let url = URL(fileURLWithPath: "/nonexistent/path/.env") + + #expect { + try sut.load(from: url) + } throws: { error in + guard let loaderError = error as? EnvFileLoader.EnvFileLoaderError, + case EnvFileLoader.EnvFileLoaderError.fileNotFound = loaderError else { return false } + return true + } + } + + // MARK: - Invalid format + + @Test + func test_load_lineWithoutEquals_throwsInvalidFormat() throws { + let url = makeTempURL() + let content = "INVALID_LINE\n" + try content.write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + #expect(throws: EnvFileLoader.EnvFileLoaderError.invalidFormat) { + try sut.load(from: url) + } + } + + // MARK: - Error descriptions + + @Test + func test_errorDescription_fileNotFound_containsPath() { + let url = URL(fileURLWithPath: "/some/path/.env") + let error = EnvFileLoader.EnvFileLoaderError.fileNotFound(url) + #expect(error.errorDescription?.contains("/some/path/.env") == true) + } + + @Test + func test_errorDescription_invalidFormat_isReadable() { + let error = EnvFileLoader.EnvFileLoaderError.invalidFormat + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.isEmpty == false) + } +} diff --git a/Tests/Core/PipelineRunnerTests.swift b/Tests/Core/PipelineRunnerTests.swift index d336d5b..3adc8b4 100644 --- a/Tests/Core/PipelineRunnerTests.swift +++ b/Tests/Core/PipelineRunnerTests.swift @@ -238,6 +238,65 @@ struct PipelineRunnerTests { #expect(runner.recordedArguments[0][2].hasSuffix("echo ${unknown}")) } + // MARK: - Env file environment + + @Test + func test_run_envFileVarsPassedToSubprocess() async throws { + let runner = SubprocessRunnerMock() + let sut = PipelineRunner(subprocessRunner: runner, conditionEvaluator: TaskConditionEvaluator(), printer: PrinterMock()) + let pipeline = makePipeline(tasks: [makeTask(command: "echo")]) + try await sut.run(pipeline, currentDirectoryURL: invocationDir, parameters: [:], envFileEnvironment: ["ENV_FILE_KEY": "env_file_value"]) + #expect(runner.recordedEnvironments[0]["ENV_FILE_KEY"] == "env_file_value") + } + + @Test + func test_run_pipelineEnvOverridesEnvFile() async throws { + let runner = SubprocessRunnerMock() + let sut = PipelineRunner(subprocessRunner: runner, conditionEvaluator: TaskConditionEvaluator(), printer: PrinterMock()) + let pipeline = makePipeline(tasks: [makeTask(command: "echo")], env: ["KEY": "pipeline"]) + try await sut.run(pipeline, currentDirectoryURL: invocationDir, parameters: [:], envFileEnvironment: ["KEY": "env_file"]) + #expect(runner.recordedEnvironments[0]["KEY"] == "pipeline") + } + + @Test + func test_run_taskEnvOverridesEnvFile() async throws { + let runner = SubprocessRunnerMock() + let sut = PipelineRunner(subprocessRunner: runner, conditionEvaluator: TaskConditionEvaluator(), printer: PrinterMock()) + let task = makeTask(command: "echo", env: ["KEY": "task"]) + let pipeline = makePipeline(tasks: [task]) + try await sut.run(pipeline, currentDirectoryURL: invocationDir, parameters: [:], envFileEnvironment: ["KEY": "env_file"]) + #expect(runner.recordedEnvironments[0]["KEY"] == "task") + } + + @Test + func test_run_emptyEnvFileEnvironment_noEffect() async throws { + let runner = SubprocessRunnerMock() + let sut = PipelineRunner(subprocessRunner: runner, conditionEvaluator: TaskConditionEvaluator(), printer: PrinterMock()) + let pipeline = makePipeline(tasks: [makeTask(command: "echo")]) + try await sut.run(pipeline, currentDirectoryURL: invocationDir, parameters: [:], envFileEnvironment: [:]) + #expect(runner.recordedEnvironments[0].isEmpty) + } + + @Test + func test_run_envFileEnvironment_visibleInWhenCondition_matching() async throws { + let runner = SubprocessRunnerMock() + let sut = PipelineRunner(subprocessRunner: runner, conditionEvaluator: TaskConditionEvaluator(), printer: PrinterMock()) + let task = makeTask(name: "Deploy", command: "deploy.sh", when: "${ENV_FILE_KEY} == active") + let pipeline = makePipeline(tasks: [task]) + try await sut.run(pipeline, currentDirectoryURL: invocationDir, parameters: [:], envFileEnvironment: ["ENV_FILE_KEY": "active"]) + #expect(runner.recordedArguments.count == 1) + } + + @Test + func test_run_envFileEnvironment_visibleInWhenCondition_notMatching() async throws { + let runner = SubprocessRunnerMock() + let sut = PipelineRunner(subprocessRunner: runner, conditionEvaluator: TaskConditionEvaluator(), printer: PrinterMock()) + let task = makeTask(name: "Deploy", command: "deploy.sh", when: "${ENV_FILE_KEY} == active") + let pipeline = makePipeline(tasks: [task]) + try await sut.run(pipeline, currentDirectoryURL: invocationDir, parameters: [:], envFileEnvironment: ["ENV_FILE_KEY": "inactive"]) + #expect(runner.recordedArguments.isEmpty) + } + // MARK: - when: condition @Test