Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ PREFIX ?= $(HOME)/.local
SWIFT ?= swift
SWIFT_TEST_FLAGS ?=
SWIFT_FORMAT ?= xcrun swift-format
CODEM8 ?= codem8

.PHONY: install uninstall test format lint check complexity
.PHONY: build install uninstall test format lint check

install:
build:
$(SWIFT) build --configuration release --product cm

install: build
install -d "$(DESTDIR)$(PREFIX)/bin"
install -m 755 cm.swift "$(DESTDIR)$(PREFIX)/bin/cm"
install -m 755 "$$($(SWIFT) build --configuration release --show-bin-path)/cm" "$(DESTDIR)$(PREFIX)/bin/cm"

uninstall:
rm -f "$(DESTDIR)$(PREFIX)/bin/cm"
Expand All @@ -17,12 +19,9 @@ test:
$(SWIFT) test --disable-xctest $(SWIFT_TEST_FLAGS)

format:
$(SWIFT_FORMAT) format --in-place --recursive cm.swift Package.swift Tests/CommandManagerTests
$(SWIFT_FORMAT) format --in-place --recursive Sources Package.swift Tests/CommandManagerTests

lint:
$(SWIFT_FORMAT) lint --strict --recursive cm.swift Package.swift Tests/CommandManagerTests
$(SWIFT_FORMAT) lint --strict --recursive Sources Package.swift Tests/CommandManagerTests

check: lint test

complexity:
$(CODEM8) --report-complexity -git-branch
7 changes: 1 addition & 6 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,7 @@ let package = Package(
platforms: [.macOS(.v12)],
products: [.executable(name: "cm", targets: ["cm"])],
targets: [
.executableTarget(
name: "cm",
path: ".",
exclude: ["Tests", "examples", "README.md", "Makefile", "LICENSE"],
sources: ["cm.swift"]
),
.executableTarget(name: "cm"),
.testTarget(
name: "CommandManagerTests",
dependencies: ["cm"],
Expand Down
259 changes: 207 additions & 52 deletions README.md

Large diffs are not rendered by default.

94 changes: 94 additions & 0 deletions Sources/cm/CLI/CLI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import Foundation

struct Options {
var configPath: String?
var help = false
var function: String?
var arguments: [String] = []

init(_ arguments: [String]) throws {
var remaining = ArraySlice(arguments)
while let option = remaining.popFirst() {
switch option {
case "--config":
guard configPath == nil, let path = remaining.popFirst(), !path.isEmpty else {
throw CommandError("Use --config once, followed by a configuration file path.")
}
configPath = path
case "--help", "-h":
help = true
default:
guard !option.hasPrefix("-") else {
throw CommandError("Unknown option '\(option)'. Use cm --help.")
}
function = option
self.arguments = Array(remaining)
return
}
}
}
}

func printHelp(_ configuration: Configuration?, path: URL) {
print(
"""
CommandManager \(commandManagerVersion) — run named command sequences

Usage: cm [--config path] [--help|-h] [function [arguments...]]
Configuration: \(path.path)

Entry points:
""")
let entries = configuration?.entryPoints ?? [:]
for name in entries.keys.sorted() {
guard let function = entries[name] else { continue }
print(" \(name)\(function.usage.isEmpty ? "" : " " + function.usage) — \(function.description)")
}
if entries.isEmpty { print(" No entry points configured. Add public commands to the entryPoints section.") }
print("\nUse cm --help <function> for function help. Options precede the function name.")
}

func printFunctionHelp(_ name: String, function: FunctionDefinition) {
print("Usage: cm \(name)\(function.usage.isEmpty ? "" : " " + function.usage)")
print(function.description)
for name in function.options.keys.sorted() {
print(" --\(name) \(function.options[name]!)")
}
}

func main(_ arguments: [String]) throws {
let options = try Options(arguments)
let path = try configurationURL(options.configPath)
if !FileManager.default.fileExists(atPath: path.path), options.configPath == nil {
try handleMissingConfiguration(options, path: path)
return
}
let configuration = try loadConfiguration(at: path)
guard let name = options.function else {
printHelp(configuration, path: path)
return
}
guard let function = configuration.definitions[name] else {
throw CommandError("Unknown function '\(name)'. Run cm to list entry points.")
}
guard configuration.entryPoints[name] != nil else {
throw CommandError("Function '\(name)' is internal; only entry points can be run directly.")
}
if options.help {
printFunctionHelp(name, function: function)
return
}
var directory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)
var environment = ProcessInfo.processInfo.environment
try Runner(configuration: configuration).runEntryPoint(
name, arguments: options.arguments, directory: &directory, environment: &environment)
}

func handleMissingConfiguration(_ options: Options, path: URL) throws {
guard options.function == nil else {
throw CommandError(
"Configuration file not found: \(path.path). Create it from examples/cm.json; run cm --help for usage.")
}
printHelp(nil, path: path)
print("\nConfiguration file not found. Create the directory and copy examples/cm.json here to get started.")
}
34 changes: 34 additions & 0 deletions Sources/cm/CLI/Output.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

func redact(_ text: String, secrets: Set<String>) -> String {
secrets.filter { !$0.isEmpty }.sorted { $0.count > $1.count }.reduce(text) {
$0.replacingOccurrences(of: $1, with: "*****")
}
}

func printCommand(_ executable: String, arguments: [String]) {
let command = ([executable] + arguments).map(quoteArgument).joined(separator: " ")
FileHandle.standardOutput.write(Data("\u{1B}[90m❯ \u{1B}[32m\(command)\u{1B}[0m\n".utf8))
}

func quoteArgument(_ argument: String) -> String {
if argument.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) {
return "$'" + argument.unicodeScalars.map(quoteControlCharacter).joined() + "'"
}
if argument.range(of: "^[A-Za-z0-9_./:@%+=,-]+$", options: .regularExpression) != nil {
return argument
}
return "'" + argument.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}

func quoteControlCharacter(_ character: Unicode.Scalar) -> String {
switch character.value {
case 39: return "\\'"
case 92: return "\\\\"
case 10: return "\\n"
case 13: return "\\r"
case 9: return "\\t"
case 0...31, 127: return String(format: "\\x%02x", character.value)
default: return String(character)
}
}
17 changes: 17 additions & 0 deletions Sources/cm/CommandError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

struct CommandError: Error, CustomStringConvertible {
let description: String
let status: Int32

init(_ message: String, status: Int32 = 1) {
description = message
self.status = status
}
}

func requireArguments(_ arguments: [String], count: Int, target: String) throws {
guard arguments.count == count else {
throw CommandError("\(target) expects \(count) argument(s), received \(arguments.count).")
}
}
Loading
Loading