Skip to content
46 changes: 41 additions & 5 deletions Sources/LucaCLI/Commands/RunCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> or --file <path>.")
Expand Down Expand Up @@ -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)

}

Expand Down Expand Up @@ -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)"))")
Expand All @@ -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() {
Expand All @@ -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 }
Expand Down
3 changes: 2 additions & 1 deletion Sources/LucaCLI/Utils/FileManagerWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
83 changes: 83 additions & 0 deletions Sources/PipelineCore/Core/EnvFileLoader/EnvFileLoader.swift
Original file line number Diff line number Diff line change
@@ -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..<equalsIndex]).trimmingCharacters(in: .whitespaces)
guard !key.isEmpty else { throw EnvFileLoaderError.invalidFormat }

var value = String(trimmed[trimmed.index(after: equalsIndex)...])
// Strip matching surrounding quotes.
if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
(value.hasPrefix("'") && value.hasSuffix("'")) {
value = String(value.dropFirst().dropLast())
}
result[key] = value
}
return result
}
}
12 changes: 12 additions & 0 deletions Sources/PipelineCore/Core/EnvFileLoader/EnvFileLoading.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// EnvFileLoading.swift

import Foundation

/// Loads environment variables from a flat YAML file.
public protocol EnvFileLoading {
/// Loads environment variables from the specified YAML file.
///
/// - Parameter url: URL to the flat YAML file containing string key-value pairs.
/// - Returns: A dictionary of environment variable names to their string values.
func load(from url: URL) throws -> [String: String]
}
Original file line number Diff line number Diff line change
@@ -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 {}
14 changes: 8 additions & 6 deletions Sources/PipelineCore/Core/PipelineRunner/PipelineRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 && <command>"`.
/// 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 {
Expand Down Expand Up @@ -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
Expand All @@ -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)"))")
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions Sources/PipelineCore/Core/PipelineRunner/PipelineRunning.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [:])
}
}
Loading