diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..0f971f3 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "682277e7540b497651926f1feab560576cebfe00619f90d1a718c9ab02e85378", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "46989693916f56d1186bd59ac15124caef896560", + "version" : "1.3.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 28d1e3e..e73cbc6 100644 --- a/Package.swift +++ b/Package.swift @@ -5,15 +5,31 @@ import PackageDescription let package = Package( name: "DotEnvy", products: [ + .executable( + name: "dotenv-tool", + targets: [ + "CLI", + ] + ), .library( name: "DotEnvy", targets: ["DotEnvy"] ), ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.1"), + ], targets: [ .target( name: "DotEnvy" ), + .executableTarget( + name: "CLI", + dependencies: [ + "DotEnvy", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), .testTarget( name: "DotEnvyTests", dependencies: ["DotEnvy"] diff --git a/README.md b/README.md index 507302c..64b6dcd 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,13 @@ outputs Error on line 1: Unterminated quote ``` + +## Command Line + +There's also a command line tool, `dotenv-tool`. It supports checking dotenv files for syntax errors and converting +them to JSON. To install, run: + +```sh +swift build -c release +cp .build/release/dotenv-tool /usr/local/bin +``` diff --git a/Sources/CLI/CLI.swift b/Sources/CLI/CLI.swift new file mode 100644 index 0000000..1cbdf44 --- /dev/null +++ b/Sources/CLI/CLI.swift @@ -0,0 +1,134 @@ +import ArgumentParser +import DotEnvy +import Foundation + +@main +struct Tool: ParsableCommand { + static var configuration = CommandConfiguration( + commandName: "dotenvy-tool", + abstract: "Tool for working with dotenv files", + subcommands: [Check.self, JSON.self] + ) +} + +struct Check: ParsableCommand { + static var configuration + = CommandConfiguration( + abstract: "Check syntax of input.", + discussion: """ + In case of a syntax error, the error is printed to standard error + and the command exits with failure code \(ExitCode.failure.rawValue). + + If there are no problems reading the input, nothing is printed + and the command exits with \(ExitCode.success.rawValue). + """ + ) + + @Option( + name: [.customShort("i"), .long], + help: "Input. Standard input is used with -. If omitted, try to use .env in cwd" + ) + var input: Input? + + func run() throws { + _ = try loadInput(self.input) + } +} + +struct JSON: ParsableCommand { + static var configuration + = CommandConfiguration( + abstract: "Convert input to JSON.", + discussion: """ + The input is converted to a JSON object. + + In case of a syntax error, the error is printed to standard error and the + command exits with failure code \(ExitCode.failure.rawValue). + + If there are no problems reading the input, the JSON value is printed to + standard output and the command exits with \(ExitCode.success.rawValue). + """ + ) + + @Option( + name: [.customShort("i"), .long], + help: "Input. Standard input is used with -. If omitted, try to use .env in cwd" + ) + var input: Input? + + @Flag(help: "Pretty print JSON") + var pretty: Bool = false + + func run() throws { + let values = try loadInput(self.input) + let json = try JSONSerialization.data( + withJSONObject: values, + options: self.pretty ? [.prettyPrinted, .sortedKeys] : [] + ) + FileHandle.standardOutput.write(json) + FileHandle.standardOutput.write(Data("\n".utf8)) + } +} + +enum Input: ExpressibleByArgument { + case stdin + case fileURL(FileURL) + + init?(argument: String) { + if argument == "-" { + self = .stdin + } else if let fileURL = FileURL(argument: argument) { + self = .fileURL(fileURL) + } else { + return nil + } + } +} + +struct FileURL: ExpressibleByArgument { + var url: URL + + init?(argument: String) { + // the new URL(filePath:directoryHint:) is not available on Linux + let url = URL(fileURLWithPath: argument, isDirectory: false) + guard url.isFileURL else { + return nil + } + self.url = url + } +} + +private func loadInput(_ input: Input?) throws -> [String: String] { + if let input = input { + let string = try readInput(input) + do { + return try DotEnvironment.parse(string: string) + } catch let error as ParseErrorWithLocation { + FileHandle.standardError.write(Data(error.formatError(source: string).utf8)) + FileHandle.standardError.write(Data("\n".utf8)) + throw ExitCode.failure + } + } else { + do { + return try DotEnvironment.loadValues() + } catch let error as LoadError { + FileHandle.standardError.write(Data(error.description.utf8)) + FileHandle.standardError.write(Data("\n".utf8)) + throw ExitCode.failure + } + } +} + +private func readInput(_ input: Input) throws -> String { + let data: Data + switch input { + case .stdin: + data = FileHandle.standardInput.readDataToEndOfFile() + case let .fileURL(fileURL): + data = try Data(contentsOf: fileURL.url) + } + guard let string = String(data: data, encoding: .utf8) else { + throw ValidationError("Input could not be decoded as UTF-8") + } + return string +}