From 924cef300cb0d10c58b3be9c0d217e9704f293e8 Mon Sep 17 00:00:00 2001 From: b4prog Date: Sat, 26 Sep 2026 05:27:11 +0200 Subject: [PATCH 01/10] [feat] add conditional workflows, runtime values, and command output capture Support boolean options, structured values, argument spreads, and sensitive-value redaction. Add filesystem and JSON builtins and package cm as a modular compiled executable. --- Makefile | 13 +- Package.swift | 7 +- README.md | 145 +++- Sources/cm/Builtins.swift | 191 +++++ Sources/cm/CLI.swift | 94 +++ Sources/cm/CommandError.swift | 17 + Sources/cm/CommandExecution.swift | 50 ++ Sources/cm/Configuration.swift | 222 +++++ Sources/cm/ConfigurationIO.swift | 74 ++ Sources/cm/Output.swift | 34 + Sources/cm/ProcessExecution.swift | 126 +++ Sources/cm/Runner.swift | 102 +++ Sources/cm/RuntimeValue.swift | 111 +++ Sources/cm/Version.swift | 32 + Sources/cm/Workflow.swift | 142 ++++ Sources/cm/main.swift | 12 + .../CommandExecutionTests.swift | 4 +- .../DirectoryAndGitTests.swift | 15 +- Tests/CommandManagerTests/Support.swift | 5 +- Tests/CommandManagerTests/VersionTests.swift | 8 +- Tests/CommandManagerTests/WorkflowTests.swift | 299 +++++++ cm.swift | 791 ------------------ 22 files changed, 1650 insertions(+), 844 deletions(-) create mode 100644 Sources/cm/Builtins.swift create mode 100644 Sources/cm/CLI.swift create mode 100644 Sources/cm/CommandError.swift create mode 100644 Sources/cm/CommandExecution.swift create mode 100644 Sources/cm/Configuration.swift create mode 100644 Sources/cm/ConfigurationIO.swift create mode 100644 Sources/cm/Output.swift create mode 100644 Sources/cm/ProcessExecution.swift create mode 100644 Sources/cm/Runner.swift create mode 100644 Sources/cm/RuntimeValue.swift create mode 100644 Sources/cm/Version.swift create mode 100644 Sources/cm/Workflow.swift create mode 100644 Sources/cm/main.swift create mode 100644 Tests/CommandManagerTests/WorkflowTests.swift delete mode 100755 cm.swift diff --git a/Makefile b/Makefile index 737c0a6..49dd057 100644 --- a/Makefile +++ b/Makefile @@ -4,11 +4,14 @@ 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 complexity -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" @@ -17,10 +20,10 @@ 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 diff --git a/Package.swift b/Package.swift index 84d43f7..6c3c359 100644 --- a/Package.swift +++ b/Package.swift @@ -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"], diff --git a/README.md b/README.md index 87a7294..cb45cae 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ A step can run an executable, call another function from the configuration, or c ## Requirements -- macOS 12 or later with Swift 5.9 or later on `PATH` to run the script. +- macOS 12 or later. +- Swift 6 or later on `PATH` to build, install, or run from source. The installed executable does not invoke the Swift compiler. - Git for the Git assertion built-ins. Apple's Xcode Command Line Tools include Swift and Git. If needed, install them with `xcode-select --install`. @@ -19,14 +20,7 @@ From this repository, run: make install ``` -This installs the executable Swift script as `~/.local/bin/cm`. It does not create or overwrite your configuration. To choose another installation prefix, use `make install PREFIX=/your/prefix`; the executable is placed in that prefix's `bin` directory. - -Alternatively, install the script manually: - -```sh -install -d "$HOME/.local/bin" -install -m 755 cm.swift "$HOME/.local/bin/cm" -``` +This builds a release executable and installs it as `~/.local/bin/cm`. It does not create or overwrite your configuration. To choose another installation prefix, use `make install PREFIX=/your/prefix`; the executable is placed in that prefix's `bin` directory. Ensure `~/.local/bin` is on your `PATH`. For the default macOS shell, add this line to `~/.zshrc` if it is not already configured, then open a new terminal: @@ -34,7 +28,7 @@ Ensure `~/.local/bin` is on your `PATH`. For the default macOS shell, add this l export PATH="$HOME/.local/bin:$PATH" ``` -You can also run the script from the repository with `swift cm.swift` or `./cm.swift`. +You can also run from the repository with `swift run cm`, for example `swift run cm --help`. The former single-file `swift cm.swift` and `./cm.swift` entry points have been replaced by the package executable. ## Configuration location @@ -89,7 +83,7 @@ cm CheckPackage CommandManager cm icons-sync ``` -- `cm` shows the current version (`0.3`) and lists the available entry points and their descriptions. +- `cm` shows the current version (`0.4`) and lists the available entry points and their descriptions. - `Hello` prints a greeting using a required `name` argument. - `GitStatus` prints a short Git status when run inside a Git working tree. - `CheckPackage` enters the named folder, verifies that it is a Git repository root, then builds and tests its Swift package. Run it from the named folder itself or its immediate parent. @@ -111,9 +105,9 @@ cm [--config PATH] [--help | -h] [FUNCTION [ARGUMENTS...]] | `cm Hello Bruno` | Run `Hello` with `name` set to `Bruno`. | | `cm --config ./cm.json Hello Bruno` | Run using the specified configuration. | -Options belong before the function name. Everything after the function name is a function argument, including values such as `--help` or `--config`. For example, `cm Hello --help` greets the literal name `--help`; use `cm --help Hello` for help about the function. +Options belong before the function name. Everything after the function name is a function argument. For functions without declared boolean options, values such as `--help` or `--config` remain literal arguments. For example, `cm Hello --help` greets the literal name `--help`; use `cm --help Hello` for help about the function. -Names are case sensitive. Functions accept exactly the number of arguments declared by their `parameters` array. Quote arguments containing spaces as you normally would in your shell: +Names are case sensitive. Functions accept exactly the number of positional arguments declared by their `parameters` array. Functions can additionally declare boolean `options` as described below. Quote arguments containing spaces as you normally would in your shell: ```sh cm Hello "Bruno Smith" @@ -159,6 +153,8 @@ The root object contains a `functions` object and can contain a `settings` array | `parameters` | No | Ordered names of required positional arguments. Defaults to `[]`. | | `settings` | No | Names of root-level settings available to this function. Defaults to `[]`. | | `steps` | Yes | Steps to run in order. | +| `options` | No | Object mapping boolean option names to help descriptions. Defaults to `{}`. | +| `requireAnyOption` | No | Show function help without running steps when no declared option is selected. Defaults to `false`; applies to CLI entry points. | Function names allow letters, digits, underscores, and hyphens, starting with a letter or underscore: `[A-Za-z_][A-Za-z0-9_-]*`. For example, `brew-update` is a valid entry point or helper name. Parameter and setting names use `[A-Za-z_][A-Za-z0-9_]*`; each list must be unique, and a function cannot use the same name for a parameter and a setting. @@ -170,7 +166,7 @@ Use standard JSON: comments and trailing commas are not supported. ## Step types -Every step has exactly one of `command`, `function`, or `builtin`, plus an optional `args` array of strings. Omitted `args` means `[]`. +Every step has exactly one of `command`, `function`, or `builtin`, plus an optional `args` array. Arguments are strings or explicit array expansions for commands and function calls. Omitted `args` means `[]`. Version 0.4 also supports `when`, `saveAs`, `capture`, `sensitive`, and `label` as described below. ### Run a command @@ -181,11 +177,11 @@ Every step has exactly one of `command`, `function`, or `builtin`, plus an optio } ``` -Executables are resolved using `PATH`, or you can specify an executable path. Commands inherit the environment and standard input, output, and error streams. Each command runs in the entry point's current working directory, shared across its function calls. +Executables are resolved using `PATH`, or you can specify an executable path. Commands inherit the environment and standard input, output, and error streams. Each command runs in its current function's working directory, inherited by nested function calls. Interactive commands share the terminal's foreground process group with `cm`, so confirmation prompts can read your input normally. Terminal signals such as Ctrl+C reach the command as well as `cm`. -Before each configured command runs, CommandManager writes a grey `❯ ` prefix followed by its executable and expanded arguments in green to standard output. The color resets before the command's own output. Arguments are displayed with shell-style quoting when needed, including empty values, spaces, and special characters. For example, the greeting command for `cm Hello "Bruno Smith"` shows the expanded name as `'Bruno Smith'`. Every nonempty setting value in a printed command is replaced with `*****`; the command still receives the original value. These echoes, including their ANSI color sequences, are also present when output is redirected. Internal Git checks performed by built-ins are not echoed. +Before each configured command runs, CommandManager writes a grey `❯ ` prefix followed by its executable and expanded arguments in green to standard output. The color resets before the command's own output. Arguments are displayed with shell-style quoting when needed, including empty values, spaces, and special characters. For example, the greeting command for `cm Hello "Bruno Smith"` shows the expanded name as `'Bruno Smith'`. Every nonempty setting value and registered sensitive runtime value in a printed command is replaced with `*****`; the command still receives the original value. These echoes, including their ANSI color sequences, are also present when output is redirected. Internal Git checks performed by built-ins are not echoed. Arguments are passed directly to the executable. Spaces, `*`, `~`, pipes, redirection, and environment variable syntax have no special shell meaning. For example, `"args": ["*.swift"]` passes one literal argument, and `"args": ["~/Downloads"]` does not expand to your home directory. JSON still requires its own escaping, such as `\n` for a newline. @@ -246,7 +242,7 @@ Built-ins implement operations that need access to CommandManager's execution st ## Arguments and substitution -Use `${parameter}` or a declared `${setting}` inside any step argument to insert the corresponding value. A placeholder can be the whole string or part of it: +Use `${parameter}`, a declared `${setting}`, an option name, or a previously saved runtime value inside any step argument to insert the corresponding scalar value. A placeholder can be the whole string or part of it: ```json { @@ -259,6 +255,59 @@ For a function with a `package` parameter, these arguments contain the package v Substitution applies only to `args`, not to executable names, function names, built-in names, or descriptions. Values remain single arguments even when they contain spaces. Substituted values are not expanded again, and there is no implicit environment-variable expansion. +## Workflow values and conditions (0.4) + +Configurations using these features should set `"minimumVersion": "0.4"`. + +Declare boolean options as a name-to-description object on a function: + +```json +"options": { "build": "Build the package", "check": "Check the package", "all": "Run everything" }, +"requireAnyOption": true +``` + +Invoke with `cm MyFunction --build --check`. Unselected options are false. Names are case sensitive, follow the function-name syntax, and cannot conflict with parameters or declared settings. Unknown `--options` fail. Use `--` to end option parsing when passing a positional value starting with `--`. Functions without declared options retain the previous literal-argument behavior. `--all` has no special built-in meaning: explicitly include it in the relevant conditions. Helper calls may pass declared options in their `args`; helpers do not inherit the caller's option values. + +A step's optional `when` is a variable name or a condition object with exactly one of `any`, `all`, or `not`: + +```json +{ "function": "Build", "when": { "any": ["build", "all"] } } +``` + +Conditions can nest. `any` and `all` require nonempty arrays and short-circuit in order. Values must be JSON booleans or the strings `true`/`false`. The condition is evaluated before arguments, so a skipped step does not attempt to resolve its arguments. `label` supplies a human-readable name included in failure diagnostics. + +### Save and use values + +Value-producing builtins require `saveAs`; it defines a unique function-local identifier: + +```json +{ "builtin": "set", "args": ["release-${name}"], "saveAs": "releaseName" } +``` + +Saved values cannot overwrite parameters, declared settings, options, or earlier outputs. Helpers have their own local values; pass scalar values explicitly through their arguments. Forward references are rejected before execution. A reference to an earlier conditional output is allowed, but fails at runtime if the producing step was skipped. Guard dependent steps with the same condition when needed. + +JSON objects and arrays remain structured. Scalar substitution does not serialize them or split them into words. `jsonGet` selects fields; an explicit spread expands a string array into separate arguments: + +```json +{ "command": "tool", "args": ["run", { "spread": "extraArgs" }, "--verbose"] } +``` + +Each array element remains exactly one argument, including empty strings or values containing spaces. Only string arrays can be spread. Commands and configured function calls support spreads; builtins do not. Dynamic function argument counts are checked at runtime. + +### Capture command output + +Specify both `capture` and `saveAs` on a command: + +```json +{ "command": "tool", "args": ["describe", "--json"], "capture": "json", "saveAs": "details" } +``` + +Capture modes are `text` (exact UTF-8 stdout), `trimmed` (remove leading/trailing whitespace and newlines), and `json` (decode stdout as JSON). Captured stdout is not printed. Stderr and stdin remain attached to the caller, and nonzero exits still abort execution with the original status. Output is collected in a private temporary file, removed on completion or handled failure, avoiding pipe-buffer deadlocks for large output. Uncaptured commands retain their existing terminal behavior. + +### Sensitive values + +Add `"sensitive": true` to a step that saves a result. Its scalar values are registered for redaction in subsequent command echoes, `log` messages, and execution error messages, including across helper calls. Marking a JSON object sensitive registers its scalar descendants. Settings remain redacted automatically. Redaction applies to CommandManager's messages; it does not filter output or stderr printed by external commands. Avoid passing sensitive values to commands that print them. + ## Built-in functions ### `inFolder` — one folder-name argument @@ -321,6 +370,33 @@ Both assertions ignore `GIT_*` environment overrides for their internal checks, Sets an environment variable for the remaining steps of the current entry point, including called functions. Subsequent command steps inherit it and run directly without a shell. When the entry point finishes, CommandManager restores the variable's previous value or removes it if it was previously absent. The variable name must use `[A-Za-z_][A-Za-z0-9_]*`. This changes CommandManager's execution environment only; it cannot modify the terminal process that launched `cm`. +### Workflow builtins (0.4) + +| Builtin | Arguments | Result / behavior | +| --- | --- | --- | +| `set` | value | Save the substituted string using `saveAs`. | +| `inDirectory` | path | Enter an existing absolute or relative directory; restore the caller's directory on function return. | +| `pathJoin` | base, component… | Save a joined path. Requires a nonempty base; later components must be nonempty relative paths. Does not check existence or expand `~`. | +| `assertPath` | path, kind | Require a regular `file` or a `directory`. | +| `gitRoot` | none | Save the current Git working tree root, including linked worktrees. | +| `assertDirectChild` | child, parent | Require existing directories and verify direct parentage after resolving symlinks. | +| `assertGitClean` | none | Require a Git working tree with no staged, unstaged, or untracked changes. Ignored files do not count. | +| `readJson` | path | Read and save a JSON value. | +| `jsonGet` | variable name, JSON pointer | Select and save a required JSON value; missing, null, and whitespace-only strings fail. | +| `log` | message | Print a message with sensitive values redacted. | + +All value-producing builtins require `saveAs`; other builtins reject it. Paths resolve relative to the current function's directory. The new Git builtins, like existing Git assertions, ignore `GIT_*` environment overrides. `assertGitClean` checks the entire working tree even when called from a subdirectory. + +`jsonGet` uses a literal variable name as its first argument, not `${...}`. Its second argument uses JSON pointer syntax: `/items/0/id`, `/posthog-api-key`, or an empty string for the entire value. Escape a key's `/` as `~1` and `~` as `~0`. Array indices are zero-based. Objects and arrays can be selected for further extraction or expansion. + +```json +{ "builtin": "readJson", "args": ["settings.json"], "saveAs": "settingsData", "sensitive": true } +``` + +```json +{ "builtin": "jsonGet", "args": ["settingsData", "/service/token"], "saveAs": "token" } +``` + ## Validation and failures CommandManager validates the whole configuration before running any step, including functions that are not entry points. It rejects unknown fields, invalid names and types, explicit `null` values, duplicate or unknown settings, settings that were not declared by the function using them, unknown function or built-in references, incorrect argument counts, unknown parameter references, and recursive call cycles. Executable names, argument strings, and setting values must not contain NUL characters. Direct recursion and cycles involving several functions are not supported. @@ -331,16 +407,37 @@ Directory changes are scoped to the function that makes them and its nested call ## Add a built-in in Swift -Configured functions require no Swift changes. To add a new state-aware built-in, edit `cm.swift`: +Configured functions require no Swift changes. To add a new state-aware built-in, edit `Sources/cm/Builtins.swift`: -1. Add the operation to the `Builtin` enum and update its `argumentCount` property. -2. Add its execution case in `Runner.executeBuiltin`. -3. Implement the operation using the supplied directory URL and `CommandError` for failures. Update the shared directory URL when the operation should affect later steps anywhere in the entry point's call hierarchy. +1. Add the operation to the `Builtin` enum and update `argumentCount` and `returnsValue`. +2. Add its execution case in `BuiltinExecutor.execute`. +3. Implement the operation using the supplied directory URL and `CommandError` for failures. Return a `RuntimeValue` for value-producing operations. Directory changes affect the current function and nested calls. 4. Add tests for success, invalid arguments, and relevant failures. 5. Reinstall with `make install` to update your installed copy. The new operation can then be referenced by a `"builtin"` step. CommandManager does not load external Swift plugins from the configuration. +## Source layout + +The executable target lives in `Sources/cm/`. Each file owns a specific responsibility: + +| File | Responsibility | +| --- | --- | +| `main.swift` | Start the CLI and translate failures into exit statuses. | +| `CLI.swift` | Parse CLI options, display help, and invoke the entry point. | +| `Configuration.swift` | Define functions/settings and validate the configuration and call graph. | +| `ConfigurationIO.swift` | Locate and decode configuration files with strict JSON diagnostics. | +| `Workflow.swift` | Decode steps, conditions, and argument expansions. | +| `RuntimeValue.swift` | Store structured results and render argument templates. | +| `Runner.swift` | Execute function sequences with scoped variables, directories, and environment. | +| `Builtins.swift` | Define and execute builtin operations, including filesystem and Git checks. | +| `CommandExecution.swift` | Execute configured commands, capture output, and check exit statuses. | +| `ProcessExecution.swift` | Resolve executables and launch/wait for processes with terminal and signal handling. | +| `Output.swift` | Format command echoes and redact sensitive values. | +| `CommandError.swift`, `Version.swift` | Shared errors, argument-count checks, and version compatibility. | + +The runner delegates builtin and external-command execution to separate executors. Their implementation helpers stay private to their files. All files compile into one executable; no source files or plugins are loaded at runtime. + ## Development Tests use Swift Testing, included with Swift 6 or later, and run through Swift Package Manager: @@ -358,11 +455,11 @@ make check make complexity ``` -`swift test --disable-xctest` builds the `cm` executable as a test dependency and runs integration tests, including a smoke test of the standalone Swift script through the interpreter. Tests use temporary configurations and working directories, so they do not need to edit your personal configuration. XCTest and third-party test dependencies are not needed. +`swift test --disable-xctest` builds the `cm` executable as a test dependency and runs integration tests, including a smoke test that runs a copy of the executable outside the source tree. Tests use temporary configurations and working directories, so they do not need to edit your personal configuration. XCTest and third-party test dependencies are not needed. -`Package.swift` uses Swift tools version 6.0 and supports testing and an optional compiled executable via `swift build`. `cm.swift` remains a standalone script compatible with Swift 5.9, and `make install` installs that script. +`Package.swift` uses Swift tools version 6.0. `swift build` builds the debug executable; `make build` builds the release executable. `make install` builds and installs the release executable, preserving existing configuration. Reinstall after changing source files. -`make format` formats `cm.swift`, `Package.swift`, and the Swift tests with `xcrun swift-format`. `make check` checks their formatting and runs the tests. `make complexity` runs `codem8 --report-complexity -git-branch` and requires the separate `codem8` tool. +`make format` formats `Sources/`, `Package.swift`, and the Swift tests with `xcrun swift-format`. `make check` checks their formatting and runs the tests. `make complexity` runs `codem8 --report-complexity -git-branch` and requires the separate `codem8` tool. The installed `codem8` version does not support Swift. The required complexity command therefore analyzes zero source files in this all-Swift project; it does not validate the complexity of the implementation or tests. diff --git a/Sources/cm/Builtins.swift b/Sources/cm/Builtins.swift new file mode 100644 index 0000000..609ad4c --- /dev/null +++ b/Sources/cm/Builtins.swift @@ -0,0 +1,191 @@ +import Foundation + +enum Builtin: String, CaseIterable { + case inFolder, assertGitRoot, assertGitRepository, export + case set, inDirectory, pathJoin, assertPath, gitRoot, assertDirectChild, assertGitClean + case readJson, jsonGet, log + + var argumentCount: Int? { + switch self { + case .inFolder, .set, .inDirectory, .readJson, .log: return 1 + case .export, .assertPath, .assertDirectChild, .jsonGet: return 2 + case .assertGitRoot, .assertGitRepository, .gitRoot, .assertGitClean: return 0 + case .pathJoin: return nil + } + } + + var returnsValue: Bool { + [.set, .pathJoin, .gitRoot, .readJson, .jsonGet].contains(self) + } + + func validateCount(_ args: [String]) throws { + if let count = argumentCount { + try requireArguments(args, count: count, target: "Builtin '\(rawValue)'") + } else if args.isEmpty { + throw CommandError("Builtin '\(rawValue)' requires at least one path component.") + } + } +} + +struct BuiltinExecutor { + func execute( + _ builtin: Builtin, arguments: [String], values: [String: RuntimeValue], directory: inout URL, + environment: inout [String: String], secrets: Set + ) throws -> RuntimeValue? { + try builtin.validateCount(arguments) + switch builtin { + case .inFolder: directory = try folder(named: arguments[0], from: directory) + case .inDirectory: + let destination = resolvedPath(arguments[0], from: directory) + try assertPath(destination, kind: "directory") + directory = destination + case .assertGitRoot: try assertGitRepository(directory, requireRoot: true) + case .assertGitRepository: try assertGitRepository(directory, requireRoot: false) + case .export: try export(name: arguments[0], value: arguments[1], into: &environment) + case .set: return .string(arguments[0]) + case .pathJoin: return .string(try joinedPath(arguments)) + case .assertPath: try assertPath(resolvedPath(arguments[0], from: directory), kind: arguments[1]) + case .gitRoot: + try assertGitRepository(directory, requireRoot: false) + return .string(try gitOutput(["rev-parse", "--show-toplevel"], directory: directory)) + case .assertDirectChild: try assertDirectChild(arguments, directory: directory) + case .assertGitClean: + try assertGitRepository(directory, requireRoot: false) + guard try gitOutput(["status", "--porcelain", "--untracked-files=all"], directory: directory).isEmpty else { + throw CommandError( + "Git working tree has local changes; commit, stash, or discard them before updating.") + } + case .readJson: + return try decodeJSON(Data(contentsOf: resolvedPath(arguments[0], from: directory))) + case .jsonGet: return try jsonValue(arguments, values: values) + case .log: FileHandle.standardOutput.write(Data((redact(arguments[0], secrets: secrets) + "\n").utf8)) + } + return nil + } + + private func resolvedPath(_ path: String, from directory: URL) -> URL { + URL(fileURLWithPath: path, relativeTo: directory).absoluteURL.resolvingSymlinksInPath().standardizedFileURL + } + + private func joinedPath(_ components: [String]) throws -> String { + guard let first = components.first, !first.isEmpty else { + throw CommandError("pathJoin requires a nonempty base path.") + } + guard components.dropFirst().allSatisfy({ !$0.isEmpty && !$0.hasPrefix("/") }) else { + throw CommandError("pathJoin requires nonempty relative components after its base path.") + } + return components.dropFirst().reduce(first) { ($0 as NSString).appendingPathComponent($1) } + } + + private func assertPath(_ path: URL, kind: String) throws { + guard ["file", "directory"].contains(kind) else { + throw CommandError("assertPath kind must be file or directory.") + } + let attributes = try FileManager.default.attributesOfItem(atPath: path.path) + let expected: FileAttributeType = kind == "directory" ? .typeDirectory : .typeRegular + guard attributes[.type] as? FileAttributeType == expected else { + throw CommandError("Expected \(kind) at '\(path.path)'.") + } + } + + private func assertDirectChild(_ arguments: [String], directory: URL) throws { + let child = resolvedPath(arguments[0], from: directory) + let parent = resolvedPath(arguments[1], from: directory) + try assertPath(child, kind: "directory") + try assertPath(parent, kind: "directory") + guard child != parent, child.deletingLastPathComponent() == parent else { + throw CommandError("'\(child.path)' must be directly inside '\(parent.path)'.") + } + } + + private func jsonValue(_ arguments: [String], values: [String: RuntimeValue]) throws -> RuntimeValue { + guard var value = values[arguments[0]] else { throw CommandError("Missing JSON variable '\(arguments[0])'.") } + let pointer = arguments[1] + guard pointer.isEmpty || pointer.hasPrefix("/") else { + throw CommandError("jsonGet requires an RFC 6901 JSON pointer, such as /items/0/id.") + } + for component in pointer.split(separator: "/", omittingEmptySubsequences: false).dropFirst() { + let key = try jsonPointerKey(String(component)) + switch value { + case .object(let object): + guard let field = object[key] else { throw CommandError("JSON field not found at '\(pointer)'.") } + value = field + case .array(let array): + guard let index = Int(key), String(index) == key, array.indices.contains(index) else { + throw CommandError("Invalid JSON array index at '\(pointer)'.") + } + value = array[index] + default: throw CommandError("Cannot traverse JSON value at '\(pointer)'.") + } + } + if case .null = value { throw CommandError("Required JSON value is null at '\(pointer)'.") } + if case .string(let text) = value, text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw CommandError("Required JSON value is empty at '\(pointer)'.") + } + return value + } + + private func jsonPointerKey(_ component: String) throws -> String { + guard component.range(of: "~(?![01])", options: .regularExpression) == nil else { + throw CommandError("Invalid JSON pointer escape.") + } + return component.replacingOccurrences(of: "~1", with: "/").replacingOccurrences(of: "~0", with: "~") + } + + private func folder(named name: String, from directory: URL) throws -> URL { + guard !name.isEmpty, name != ".", name != "..", !name.contains("/"), !name.contains("\0") else { + throw CommandError("inFolder requires a single folder name, not a path: '\(name)'.") + } + if directory.lastPathComponent == name { return directory } + let child = directory.appendingPathComponent(name, isDirectory: true) + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: child.path, isDirectory: &isDirectory), isDirectory.boolValue + else { + throw CommandError( + "inFolder: '\(name)' is neither the current folder nor a child directory of '\(directory.path)'.") + } + return child + } + + private func export(name: String, value: String, into environment: inout [String: String]) throws { + guard isIdentifier(name) else { + throw CommandError( + "export requires an environment variable name using letters, digits, and underscores, starting with a letter or underscore." + ) + } + environment[name] = value + } + + private func assertGitRepository(_ directory: URL, requireRoot: Bool) throws { + let inside = try gitOutput(["rev-parse", "--is-inside-work-tree"], directory: directory) + guard inside == "true" else { + throw CommandError("Expected a Git repository working tree at '\(directory.path)'.") + } + guard requireRoot else { return } + let root = try gitOutput(["rev-parse", "--show-toplevel"], directory: directory) + let rootURL = URL(fileURLWithPath: root).resolvingSymlinksInPath().standardizedFileURL + guard rootURL == directory.resolvingSymlinksInPath().standardizedFileURL else { + throw CommandError( + "Expected the Git repository root; current folder is '\(directory.path)', root is '\(root)'.") + } + } + + private func gitOutput(_ arguments: [String], directory: URL) throws -> String { + let process = try makeProcess("git", arguments: arguments, directory: directory) + // Inspect the directory itself, independent of a calling Git hook or alias's repository overrides. + process.environment = ProcessInfo.processInfo.environment.filter { !$0.key.hasPrefix("GIT_") } + let output = Pipe() + process.standardInput = FileHandle.nullDevice + process.standardOutput = output + process.standardError = FileHandle.nullDevice + try start(process, executable: "git") + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + throw CommandError("Expected a Git repository working tree at '\(directory.path)'.") + } + var text = String(decoding: data, as: UTF8.self) + if text.hasSuffix("\n") { text.removeLast() } + return text + } +} diff --git a/Sources/cm/CLI.swift b/Sources/cm/CLI.swift new file mode 100644 index 0000000..5116a0b --- /dev/null +++ b/Sources/cm/CLI.swift @@ -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?.functions.filter { $0.value.entryPoint } ?? [:] + 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. Set entryPoint to true to expose a function.") } + print("\nUse cm --help 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.functions[name] else { + throw CommandError("Unknown function '\(name)'. Run cm to list entry points.") + } + guard function.entryPoint 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.") +} diff --git a/Sources/cm/CommandError.swift b/Sources/cm/CommandError.swift new file mode 100644 index 0000000..f54cf1e --- /dev/null +++ b/Sources/cm/CommandError.swift @@ -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).") + } +} diff --git a/Sources/cm/CommandExecution.swift b/Sources/cm/CommandExecution.swift new file mode 100644 index 0000000..02a9e37 --- /dev/null +++ b/Sources/cm/CommandExecution.swift @@ -0,0 +1,50 @@ +import Foundation + +struct CommandExecutor { + func execute( + _ executable: String, arguments: [String], capture: CaptureMode?, directory: URL, + environment: [String: String], secrets: Set + ) throws -> RuntimeValue? { + printCommand(executable, arguments: arguments.map { redact($0, secrets: secrets) }) + if let capture { + return try captureCommand( + executable, arguments: arguments, mode: capture, directory: directory, environment: environment) + } + let status = try runInheritedCommand( + executable, arguments: arguments, directory: directory, environment: environment) + try checkCommandStatus(status, executable: executable) + return nil + } + + private func captureCommand( + _ executable: String, arguments: [String], mode: CaptureMode, directory: URL, environment: [String: String] + ) throws -> RuntimeValue { + let temporary = FileManager.default.temporaryDirectory.appendingPathComponent("cm-capture-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: temporary, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: temporary) } + let output = temporary.appendingPathComponent("stdout") + guard FileManager.default.createFile(atPath: output.path, contents: nil, attributes: [.posixPermissions: 0o600]) + else { + throw CommandError("Cannot create capture output file.") + } + let handle = try FileHandle(forWritingTo: output) + defer { try? handle.close() } + let status = try runInheritedCommand( + executable, arguments: arguments, directory: directory, environment: environment, + stdoutFD: handle.fileDescriptor) + try checkCommandStatus(status, executable: executable) + let data = try Data(contentsOf: output) + if mode == .json { return try decodeJSON(data) } + guard let text = String(data: data, encoding: .utf8) else { + throw CommandError("Captured output is not valid UTF-8.") + } + return .string(mode == .trimmed ? text.trimmingCharacters(in: .whitespacesAndNewlines) : text) + } + + private func checkCommandStatus(_ status: Int32, executable: String) throws { + guard status == 0 else { + throw CommandError("Command '\(executable)' failed with exit status \(status).", status: status) + } + } +} diff --git a/Sources/cm/Configuration.swift b/Sources/cm/Configuration.swift new file mode 100644 index 0000000..7bbff96 --- /dev/null +++ b/Sources/cm/Configuration.swift @@ -0,0 +1,222 @@ +import Foundation + +func isIdentifier(_ value: String) -> Bool { + value.range(of: "\\A[A-Za-z_][A-Za-z0-9_]*\\z", options: .regularExpression) != nil +} + +func isFunctionName(_ value: String) -> Bool { + value.range(of: "\\A[A-Za-z_][A-Za-z0-9_-]*\\z", options: .regularExpression) != nil +} + +struct FunctionDefinition: Decodable { + let description: String + let entryPoint: Bool + let parameters: [String] + let settings: [String] + let steps: [Step] + let options: [String: String] + let requireAnyOption: Bool + + enum CodingKeys: String, CodingKey { + case description, entryPoint, parameters, settings, steps, options, requireAnyOption + } + + init(from decoder: Decoder) throws { + try rejectUnknownKeys( + decoder, + allowed: ["description", "entryPoint", "parameters", "settings", "steps", "options", "requireAnyOption"]) + let container = try decoder.container(keyedBy: CodingKeys.self) + description = try container.decode(String.self, forKey: .description) + entryPoint = try container.decodeIfDefined(Bool.self, forKey: .entryPoint) ?? false + parameters = try container.decodeIfDefined([String].self, forKey: .parameters) ?? [] + settings = try container.decodeIfDefined([String].self, forKey: .settings) ?? [] + steps = try container.decode([Step].self, forKey: .steps) + options = try container.decodeIfDefined([String: String].self, forKey: .options) ?? [:] + requireAnyOption = try container.decodeIfDefined(Bool.self, forKey: .requireAnyOption) ?? false + } + + var usage: String { + (parameters.map { "<\($0)>" } + options.keys.sorted().map { "[--\($0)]" }).joined(separator: " ") + } +} + +struct SettingDefinition: Decodable { + let name: String + let value: String + + enum CodingKeys: String, CodingKey { + case name, value + } + + init(from decoder: Decoder) throws { + try rejectUnknownKeys(decoder, allowed: ["name", "value"]) + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + value = try container.decode(String.self, forKey: .value) + } +} + +struct Configuration: Decodable { + let settings: [SettingDefinition] + let functions: [String: FunctionDefinition] + + enum CodingKeys: String, CodingKey { + case minimumVersion, settings, functions + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try validateMinimumVersion(container.decodeIfDefined(String.self, forKey: .minimumVersion)) + try rejectUnknownKeys(decoder, allowed: ["minimumVersion", "settings", "functions"]) + settings = try container.decodeIfDefined([SettingDefinition].self, forKey: .settings) ?? [] + functions = try container.decode([String: FunctionDefinition].self, forKey: .functions) + } + + func validate() throws { + try validateSettings() + for name in functions.keys.sorted() { + guard let function = functions[name] else { continue } + try validateDefinition(name, function: function) + try validateSteps(name, function: function) + } + var visited = Set() + for name in functions.keys.sorted() { + try validateCycles(name, path: [], visited: &visited) + } + } + + private func validateSettings() throws { + let names = settings.map(\.name) + guard names.allSatisfy(isIdentifier), Set(names).count == names.count else { + throw CommandError( + "Settings must have unique names using letters, digits, and underscores, starting with a letter or underscore." + ) + } + try validateProcessArguments(settings.map(\.value)) + } + + private func validateDefinition(_ name: String, function: FunctionDefinition) throws { + guard isFunctionName(name) else { + throw CommandError( + "Invalid function name '\(name)'; use letters, digits, underscores, and hyphens, starting with a letter or underscore." + ) + } + guard !function.description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CommandError("Function '\(name)' must have a nonempty description.") + } + guard function.parameters.allSatisfy(isIdentifier), + Set(function.parameters).count == function.parameters.count + else { + throw CommandError( + "Function '\(name)' must have unique parameter names using letters, digits, and underscores, starting with a letter or underscore." + ) + } + guard function.settings.allSatisfy(isIdentifier), Set(function.settings).count == function.settings.count else { + throw CommandError( + "Function '\(name)' must have unique setting names using letters, digits, and underscores, starting with a letter or underscore." + ) + } + guard Set(function.parameters).isDisjoint(with: function.settings) else { + throw CommandError("Function '\(name)' cannot use the same name for a parameter and a setting.") + } + let definedSettings = Set(settings.map(\.name)) + guard Set(function.settings).isSubset(of: definedSettings) else { + let unknown = Set(function.settings).subtracting(definedSettings).sorted().joined(separator: ", ") + throw CommandError("Function '\(name)' uses unknown setting(s): \(unknown).") + } + } + + private func validateSteps(_ name: String, function: FunctionDefinition) throws { + var names = Set(function.parameters + function.settings) + for option in function.options.keys { + guard isFunctionName(option), !names.contains(option) else { + throw CommandError("Invalid or conflicting option '\(option)' in '\(name)'.") + } + names.insert(option) + } + guard !function.requireAnyOption || !function.options.isEmpty else { + throw CommandError("requireAnyOption needs declared options in '\(name)'.") + } + for (index, step) in function.steps.enumerated() { + do { + try validateTarget(step) + try step.when?.validate(names) + for argument in step.args { try argument.validate(names) } + if case .builtin("jsonGet") = step.target { + guard let source = step.args.first?.literal, names.contains(source) else { + throw CommandError("jsonGet requires a previously defined variable name as its first argument.") + } + } + if let output = step.saveAs { + guard isIdentifier(output), !names.contains(output) else { + throw CommandError("Output '\(output)' must be a unique local identifier.") + } + names.insert(output) + } + } catch { + throw CommandError("Function '\(name)', step \(index + 1): \(error)") + } + } + } + + private func validateTarget(_ step: Step) throws { + switch step.target { + case .command(let executable): + try validateProcessArguments([executable]) + guard !executable.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CommandError("Command executable must not be empty.") + } + guard (step.capture != nil) == (step.saveAs != nil) else { + throw CommandError("Command capture and saveAs must be specified together.") + } + case .function(let name): + guard let function = functions[name] else { throw CommandError("Unknown function '\(name)'.") } + guard step.capture == nil, step.saveAs == nil else { + throw CommandError("Function calls cannot capture output or use saveAs.") + } + if function.options.isEmpty, step.args.allSatisfy({ $0.literal != nil }) { + try requireArguments( + step.args.compactMap(\.literal), count: function.parameters.count, target: "Function '\(name)'") + } + case .builtin(let name): try validateBuiltinStep(step, name: name) + } + guard !step.sensitive || step.saveAs != nil else { + throw CommandError("sensitive requires a saved result.") + } + if let label = step.label { + try validateProcessArguments([label]) + guard !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CommandError("Step label must not be empty.") + } + } + } + + private func validateBuiltinStep(_ step: Step, name: String) throws { + guard let builtin = Builtin(rawValue: name) else { + throw CommandError( + "Unknown builtin '\(name)'. Available: \(Builtin.allCases.map(\.rawValue).joined(separator: ", ")).") + } + guard step.capture == nil, builtin.returnsValue == (step.saveAs != nil) else { + throw CommandError( + "Builtin '\(name)' \(builtin.returnsValue ? "requires" : "does not accept") saveAs; capture is only for commands." + ) + } + guard step.args.allSatisfy({ $0.literal != nil }) else { + throw CommandError("Array expansion is supported only for commands and function calls.") + } + try builtin.validateCount(step.args.compactMap(\.literal)) + } + + private func validateCycles(_ name: String, path: [String], visited: inout Set) throws { + guard !path.contains(name) else { + throw CommandError("Function call cycle: \((path + [name]).joined(separator: " -> ")).") + } + guard !visited.contains(name), let function = functions[name] else { return } + for step in function.steps { + if case .function(let callee) = step.target { + try validateCycles(callee, path: path + [name], visited: &visited) + } + } + visited.insert(name) + } +} diff --git a/Sources/cm/ConfigurationIO.swift b/Sources/cm/ConfigurationIO.swift new file mode 100644 index 0000000..9db11d2 --- /dev/null +++ b/Sources/cm/ConfigurationIO.swift @@ -0,0 +1,74 @@ +import Foundation + +struct JSONKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + + init?(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { + return nil + } +} + +extension KeyedDecodingContainer { + func decodeIfDefined(_ type: Value.Type, forKey key: Key) throws -> Value? { + guard contains(key) else { return nil } + return try decode(type, forKey: key) + } +} + +func rejectUnknownKeys(_ decoder: Decoder, allowed: Set) throws { + let container = try decoder.container(keyedBy: JSONKey.self) + let unknown = Set(container.allKeys.map(\.stringValue)).subtracting(allowed) + guard unknown.isEmpty else { + let location = decoder.codingPath.map(\.stringValue).joined(separator: ".") + throw CommandError( + "Unknown JSON field(s) at \(location.isEmpty ? "root" : location): \(unknown.sorted().joined(separator: ", "))." + ) + } +} + +func configurationURL(_ explicitPath: String?) throws -> URL { + if let explicitPath { + let path = (explicitPath as NSString).expandingTildeInPath + return URL(fileURLWithPath: path).standardizedFileURL + } + let directory = try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false + ) + return directory.appendingPathComponent("CommandManager/cm.json") +} + +func loadConfiguration(at url: URL) throws -> Configuration { + do { + let data = try Data(contentsOf: url) + let configuration = try JSONDecoder().decode(Configuration.self, from: data) + try configuration.validate() + return configuration + } catch let error as CommandError { + throw CommandError("Invalid configuration '\(url.path)': \(error)") + } catch let error as DecodingError { + throw CommandError("Invalid JSON configuration '\(url.path)': \(describeDecodingError(error))") + } catch { + throw CommandError("Cannot read configuration '\(url.path)': \(error.localizedDescription)") + } +} + +func describeDecodingError(_ error: DecodingError) -> String { + switch error { + case .keyNotFound(let key, let context): + return "Missing '\(key.stringValue)' at \(decodingLocation(context))." + case .typeMismatch(_, let context), .valueNotFound(_, let context), .dataCorrupted(let context): + return "\(decodingLocation(context)): \(context.debugDescription)" + @unknown default: + return String(describing: error) + } +} + +func decodingLocation(_ context: DecodingError.Context) -> String { + let path = context.codingPath.map(\.stringValue).joined(separator: ".") + return path.isEmpty ? "root" : path +} diff --git a/Sources/cm/Output.swift b/Sources/cm/Output.swift new file mode 100644 index 0000000..b0ed466 --- /dev/null +++ b/Sources/cm/Output.swift @@ -0,0 +1,34 @@ +import Foundation + +func redact(_ text: String, secrets: Set) -> 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) + } +} diff --git a/Sources/cm/ProcessExecution.swift b/Sources/cm/ProcessExecution.swift new file mode 100644 index 0000000..d47ef7d --- /dev/null +++ b/Sources/cm/ProcessExecution.swift @@ -0,0 +1,126 @@ +import Darwin +import Foundation + +/// Inherit the caller's process group so terminal reads and terminal signals work normally. +func runInheritedCommand( + _ executable: String, arguments: [String], directory: URL, environment: [String: String], stdoutFD: Int32? = nil +) throws -> Int32 { + try validateProcessArguments([executable] + arguments) + let path = try executableURL(executable, directory: directory, environment: environment).path + var actions: posix_spawn_file_actions_t? + try checkSpawn(posix_spawn_file_actions_init(&actions), executable: executable) + defer { posix_spawn_file_actions_destroy(&actions) } + try checkSpawn(addWorkingDirectory(&actions, path: directory.path), executable: executable) + if let stdoutFD { + try checkSpawn(posix_spawn_file_actions_adddup2(&actions, stdoutFD, STDOUT_FILENO), executable: executable) + try checkSpawn(posix_spawn_file_actions_addclose(&actions, stdoutFD), executable: executable) + } + var pid: pid_t = 0 + var attributes: posix_spawnattr_t? + try checkSpawn(posix_spawnattr_init(&attributes), executable: executable) + defer { posix_spawnattr_destroy(&attributes) } + var defaults = sigset_t() + sigemptyset(&defaults) + sigaddset(&defaults, SIGINT) + sigaddset(&defaults, SIGQUIT) + try checkSpawn(posix_spawnattr_setsigdefault(&attributes, &defaults), executable: executable) + try checkSpawn(posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETSIGDEF)), executable: executable) + // Like system(3): let the foreground child handle terminal interrupts while cm waits. + let previousInterrupt = signal(SIGINT, SIG_IGN) + let previousQuit = signal(SIGQUIT, SIG_IGN) + defer { + signal(SIGINT, previousInterrupt) + signal(SIGQUIT, previousQuit) + } + try withCStringArray([executable] + arguments) { argv in + try withCStringArray(environment.map { "\($0.key)=\($0.value)" }) { environment in + // No SETPGROUP flag: unlike Foundation.Process, keep the existing foreground job. + try checkSpawn(posix_spawn(&pid, path, &actions, &attributes, argv, environment), executable: executable) + } + } + return try waitForCommand(pid) +} + +func addWorkingDirectory(_ actions: inout posix_spawn_file_actions_t?, path: String) -> Int32 { + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + return posix_spawn_file_actions_addchdir(&actions, path) + } else { + return posix_spawn_file_actions_addchdir_np(&actions, path) + } + #else + return posix_spawn_file_actions_addchdir_np(&actions, path) + #endif +} + +func withCStringArray( + _ strings: [String], body: (UnsafeMutablePointer?>) throws -> Result +) throws -> Result { + var pointers = strings.map { strdup($0) } + defer { + for pointer in pointers { free(pointer) } + } + guard pointers.allSatisfy({ $0 != nil }) else { throw CommandError("Cannot allocate command arguments.") } + pointers.append(nil) + return try pointers.withUnsafeMutableBufferPointer { try body($0.baseAddress!) } +} + +func checkSpawn(_ status: Int32, executable: String) throws { + guard status == 0 else { + throw CommandError("Cannot run command '\(executable)': \(String(cString: strerror(status)))", status: 126) + } +} + +func waitForCommand(_ pid: pid_t) throws -> Int32 { + var status: Int32 = 0 + while waitpid(pid, &status, 0) == -1 { + guard errno == EINTR else { + throw CommandError("Cannot wait for command: \(String(cString: strerror(errno)))") + } + } + // Darwin's wait status macros are not imported into Swift. + let signal = status & 0x7f + return signal == 0 ? (status >> 8) & 0xff : min(128 + signal, 255) +} + +func makeProcess(_ executable: String, arguments: [String], directory: URL) throws -> Process { + try validateProcessArguments([executable] + arguments) + let process = Process() + process.currentDirectoryURL = directory + process.executableURL = try executableURL(executable, directory: directory) + process.arguments = arguments + return process +} + +func validateProcessArguments(_ arguments: [String]) throws { + guard !arguments.contains(where: { $0.contains("\0") }) else { + throw CommandError("Command names and arguments cannot contain a NUL character.") + } +} + +func executableURL(_ executable: String, directory: URL, environment: [String: String]? = nil) throws -> URL { + if executable.contains("/") { + return URL(fileURLWithPath: executable, relativeTo: directory).absoluteURL + } + let searchPath = (environment ?? ProcessInfo.processInfo.environment)["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" + for component in searchPath.components(separatedBy: ":") { + let base = component.isEmpty ? directory : URL(fileURLWithPath: component, relativeTo: directory) + let candidate = base.appendingPathComponent(executable) + if isExecutableFile(candidate) { return candidate.absoluteURL } + } + throw CommandError("Command '\(executable)' was not found in PATH.", status: 127) +} + +func isExecutableFile(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + && !isDirectory.boolValue && FileManager.default.isExecutableFile(atPath: url.path) +} + +func start(_ process: Process, executable: String) throws { + do { + try process.run() + } catch { + throw CommandError("Cannot run command '\(executable)': \(error.localizedDescription)", status: 126) + } +} diff --git a/Sources/cm/Runner.swift b/Sources/cm/Runner.swift new file mode 100644 index 0000000..04cc878 --- /dev/null +++ b/Sources/cm/Runner.swift @@ -0,0 +1,102 @@ +import Foundation + +/// Each function inherits its caller's directory and restores it when the function returns. +struct Runner { + let configuration: Configuration + + func runEntryPoint( + _ name: String, arguments: [String], directory: inout URL, environment: inout [String: String] + ) throws { + let initialEnvironment = environment + defer { environment = initialEnvironment } + var secrets = Set(configuration.settings.map(\.value).filter { !$0.isEmpty }) + guard let function = configuration.functions[name] else { throw CommandError("Unknown function '\(name)'.") } + let bindings = try bindArguments(arguments, function: function) + if function.requireAnyOption && !function.options.keys.contains(where: { bindings[$0] == "true" }) { + printFunctionHelp(name, function: function) + return + } + do { + try run(name, arguments: arguments, directory: &directory, environment: &environment, secrets: &secrets) + } catch let error as CommandError { + throw CommandError(redact(error.description, secrets: secrets), status: error.status) + } + } + + private func bindArguments(_ arguments: [String], function: FunctionDefinition) throws -> [String: String] { + var bindings = Dictionary(uniqueKeysWithValues: function.options.keys.map { ($0, "false") }) + var positional: [String] = [] + var parseOptions = !function.options.isEmpty + for argument in arguments { + if parseOptions && argument == "--" { + parseOptions = false + } else if parseOptions && argument.hasPrefix("--") { + let name = String(argument.dropFirst(2)) + guard function.options[name] != nil else { + throw CommandError("Unknown function option '\(argument)'.") + } + bindings[name] = "true" + } else { + positional.append(argument) + } + } + try requireArguments(positional, count: function.parameters.count, target: "Function") + bindings.merge( + Dictionary(uniqueKeysWithValues: zip(function.parameters, positional)), uniquingKeysWith: { _, new in new }) + return bindings + } + + private func run( + _ name: String, arguments: [String], directory: inout URL, environment: inout [String: String], + secrets: inout Set + ) throws { + guard let function = configuration.functions[name] else { throw CommandError("Unknown function '\(name)'.") } + var functionDirectory = directory + var values = try bindArguments(arguments, function: function) + .merging(settingValues(for: function), uniquingKeysWith: { _, setting in setting }).mapValues( + RuntimeValue.string) + for (index, step) in function.steps.enumerated() { + do { + guard try step.when?.evaluate(values) ?? true else { continue } + let args = try step.args.flatMap { try $0.render(values) } + try validateProcessArguments(args) + let result = try execute( + step, arguments: args, values: values, directory: &functionDirectory, environment: &environment, + secrets: &secrets) + if let output = step.saveAs, let result { + values[output] = result + if step.sensitive { secrets.formUnion(result.secretStrings.filter { !$0.isEmpty }) } + } + } catch { + let status = (error as? CommandError)?.status ?? 1 + let context = step.label.map { " (\($0))" } ?? "" + throw CommandError("\(name), step \(index + 1)\(context): \(error)", status: status) + } + } + } + + private func settingValues(for function: FunctionDefinition) -> [String: String] { + let values = Dictionary(uniqueKeysWithValues: configuration.settings.map { ($0.name, $0.value) }) + return Dictionary(uniqueKeysWithValues: function.settings.map { ($0, values[$0]!) }) + } + + private func execute( + _ step: Step, arguments: [String], values: [String: RuntimeValue], directory: inout URL, + environment: inout [String: String], secrets: inout Set + ) throws -> RuntimeValue? { + switch step.target { + case .command(let executable): + return try CommandExecutor().execute( + executable, arguments: arguments, capture: step.capture, directory: directory, environment: environment, + secrets: secrets) + case .function(let name): + try run(name, arguments: arguments, directory: &directory, environment: &environment, secrets: &secrets) + return nil + case .builtin(let name): + guard let builtin = Builtin(rawValue: name) else { throw CommandError("Unknown builtin '\(name)'.") } + return try BuiltinExecutor().execute( + builtin, arguments: arguments, values: values, directory: &directory, environment: &environment, + secrets: secrets) + } + } +} diff --git a/Sources/cm/RuntimeValue.swift b/Sources/cm/RuntimeValue.swift new file mode 100644 index 0000000..763ab6a --- /dev/null +++ b/Sources/cm/RuntimeValue.swift @@ -0,0 +1,111 @@ +import Foundation + +/// Structured results stay structured until explicitly selected or expanded. +indirect enum RuntimeValue: Decodable { + case string(String) + case bool(Bool) + case number(Decimal) + case array([RuntimeValue]) + case object([String: RuntimeValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([RuntimeValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: RuntimeValue].self) { + self = .object(value) + } else { + self = .number(try container.decode(Decimal.self)) + } + } + + func text() throws -> String { + switch self { + case .string(let value): return value + case .bool(let value): return value ? "true" : "false" + case .number(let value): return NSDecimalNumber(decimal: value).stringValue + default: throw CommandError("Expected a scalar value; select a JSON field or explicitly expand a string array.") + } + } + + var secretStrings: [String] { + switch self { + case .array(let values): return values.flatMap(\.secretStrings) + case .object(let values): return values.values.flatMap(\.secretStrings) + case .null: return [] + default: return (try? text()).map { [$0] } ?? [] + } + } +} + +/// Arguments are templates, never shell programs. Inserted values are not parsed again. +struct ArgumentTemplate { + enum Part { + case text(String) + case parameter(String) + } + + private var parts: [Part] = [] + + init(_ source: String) throws { + var remaining = source[...] + while let dollar = remaining.firstIndex(of: "$") { + parts.append(.text(String(remaining[..) throws { + for case .parameter(let name) in parts { + guard parameters.contains(name) else { + throw CommandError("Unknown parameter '${\(name)}'. Use $$ to escape a literal dollar sign.") + } + } + } + + func renderRuntime(values: [String: RuntimeValue]) throws -> String { + try parts.map { part in + switch part { + case .text(let value): + return value + case .parameter(let name): + guard let value = values[name] else { + throw CommandError("Missing parameter '\(name)'.") + } + return try value.text() + } + }.joined() + } +} + +func decodeJSON(_ data: Data) throws -> RuntimeValue { + do { return try JSONDecoder().decode(RuntimeValue.self, from: data) } catch { + throw CommandError("Cannot decode JSON result.") + } +} diff --git a/Sources/cm/Version.swift b/Sources/cm/Version.swift new file mode 100644 index 0000000..9bea8ce --- /dev/null +++ b/Sources/cm/Version.swift @@ -0,0 +1,32 @@ +import Foundation + +let commandManagerVersion = "0.4" + +struct Version: Comparable { + private let components: [UInt] + + init(_ text: String) throws { + let parts = text.split(separator: ".", omittingEmptySubsequences: false) + let numbers = parts.compactMap { UInt($0) } + guard text.range(of: "\\A[0-9]+\\.[0-9]+(?:\\.[0-9]+)?\\z", options: .regularExpression) != nil, + numbers.count == parts.count + else { + throw CommandError( + "Invalid version '\(text)'; expected major.minor or major.minor.patch using nonnegative integers.") + } + components = numbers + Array(repeating: 0, count: 3 - numbers.count) + } + + static func < (lhs: Version, rhs: Version) -> Bool { + lhs.components.lexicographicallyPrecedes(rhs.components) + } +} + +func validateMinimumVersion(_ minimum: String?) throws { + guard let minimum else { return } + guard try Version(commandManagerVersion) >= Version(minimum) else { + throw CommandError( + "This configuration requires CommandManager \(minimum) or later; installed version is \(commandManagerVersion). Upgrade cm before using this configuration." + ) + } +} diff --git a/Sources/cm/Workflow.swift b/Sources/cm/Workflow.swift new file mode 100644 index 0000000..9b6c5ae --- /dev/null +++ b/Sources/cm/Workflow.swift @@ -0,0 +1,142 @@ +import Foundation + +struct Step: Decodable { + enum Target { + case command(String) + case function(String) + case builtin(String) + } + + let target: Target + let args: [StepArgument] + let when: Condition? + let saveAs: String? + let capture: CaptureMode? + let sensitive: Bool + let label: String? + + enum CodingKeys: String, CodingKey { + case command, function, builtin, args, when, saveAs, capture, sensitive, label + } + + init(from decoder: Decoder) throws { + try rejectUnknownKeys( + decoder, + allowed: ["command", "function", "builtin", "args", "when", "saveAs", "capture", "sensitive", "label"]) + let container = try decoder.container(keyedBy: CodingKeys.self) + let targets: [Target] = try [ + container.decodeIfDefined(String.self, forKey: .command).map(Target.command), + container.decodeIfDefined(String.self, forKey: .function).map(Target.function), + container.decodeIfDefined(String.self, forKey: .builtin).map(Target.builtin), + ].compactMap { $0 } + guard targets.count == 1, let target = targets.first else { + throw CommandError("Each step must specify exactly one of command, function, or builtin.") + } + self.target = target + args = try container.decodeIfDefined([StepArgument].self, forKey: .args) ?? [] + when = try container.decodeIfDefined(Condition.self, forKey: .when) + saveAs = try container.decodeIfDefined(String.self, forKey: .saveAs) + capture = try container.decodeIfDefined(CaptureMode.self, forKey: .capture) + sensitive = try container.decodeIfDefined(Bool.self, forKey: .sensitive) ?? false + label = try container.decodeIfDefined(String.self, forKey: .label) + } +} + +enum CaptureMode: String, Decodable { + case text, trimmed, json +} + +enum StepArgument: Decodable { + case template(String) + case spread(String) + + init(from decoder: Decoder) throws { + if let value = try? decoder.singleValueContainer().decode(String.self) { + self = .template(value) + } else { + try rejectUnknownKeys(decoder, allowed: ["spread"]) + let container = try decoder.container(keyedBy: JSONKey.self) + self = .spread(try container.decode(String.self, forKey: JSONKey(stringValue: "spread")!)) + } + } + + func validate(_ names: Set) throws { + switch self { + case .template(let text): + try validateProcessArguments([text]) + try ArgumentTemplate(text).validate(parameters: names) + case .spread(let name): + guard names.contains(name) else { throw CommandError("Unknown array '\(name)'.") } + } + } + + func render(_ values: [String: RuntimeValue]) throws -> [String] { + switch self { + case .template(let text): return [try ArgumentTemplate(text).renderRuntime(values: values)] + case .spread(let name): + guard case .array(let array) = values[name] else { throw CommandError("Expected string array '\(name)'.") } + return try array.map { value in + guard case .string(let text) = value else { + throw CommandError("Array '\(name)' must contain only strings.") + } + return text + } + } + } + + var literal: String? { + if case .template(let value) = self { return value } + return nil + } +} + +indirect enum Condition: Decodable { + case value(String) + case any([Condition]) + case all([Condition]) + case not(Condition) + + init(from decoder: Decoder) throws { + if let name = try? decoder.singleValueContainer().decode(String.self) { + self = .value(name) + return + } + try rejectUnknownKeys(decoder, allowed: ["any", "all", "not"]) + let container = try decoder.container(keyedBy: JSONKey.self) + guard container.allKeys.count == 1, let key = container.allKeys.first else { + throw CommandError("A condition requires exactly one of any, all, or not.") + } + switch key.stringValue { + case "any": self = .any(try container.decode([Condition].self, forKey: key)) + case "all": self = .all(try container.decode([Condition].self, forKey: key)) + default: self = .not(try container.decode(Condition.self, forKey: key)) + } + } + + func validate(_ names: Set) throws { + switch self { + case .value(let name): + guard names.contains(name) else { throw CommandError("Unknown condition value '\(name)'.") } + case .any(let conditions), .all(let conditions): + guard !conditions.isEmpty else { throw CommandError("Condition lists must not be empty.") } + for condition in conditions { try condition.validate(names) } + case .not(let condition): try condition.validate(names) + } + } + + func evaluate(_ values: [String: RuntimeValue]) throws -> Bool { + switch self { + case .value(let name): + guard let value = values[name] else { throw CommandError("Missing condition value '\(name)'.") } + switch value { + case .bool(let bool): return bool + case .string("true"): return true + case .string("false"): return false + default: throw CommandError("Condition '\(name)' must be a boolean or the string true/false.") + } + case .any(let conditions): return try conditions.contains { try $0.evaluate(values) } + case .all(let conditions): return try conditions.allSatisfy { try $0.evaluate(values) } + case .not(let condition): return try !condition.evaluate(values) + } + } +} diff --git a/Sources/cm/main.swift b/Sources/cm/main.swift new file mode 100644 index 0000000..7bafe34 --- /dev/null +++ b/Sources/cm/main.swift @@ -0,0 +1,12 @@ +import Darwin +import Foundation + +do { + try main(Array(CommandLine.arguments.dropFirst())) +} catch let error as CommandError { + FileHandle.standardError.write(Data("cm: \(error)\n".utf8)) + exit(error.status) +} catch { + FileHandle.standardError.write(Data("cm: \(error.localizedDescription)\n".utf8)) + exit(1) +} diff --git a/Tests/CommandManagerTests/CommandExecutionTests.swift b/Tests/CommandManagerTests/CommandExecutionTests.swift index 719bb5c..d21f685 100644 --- a/Tests/CommandManagerTests/CommandExecutionTests.swift +++ b/Tests/CommandManagerTests/CommandExecutionTests.swift @@ -12,7 +12,7 @@ final class CommandExecutionTests: CMTestCase { for arguments in invocations { let result = try runCM(arguments) assertSuccess(result) - #expect(result.stdout.hasPrefix("CommandManager 0.3 —")) + #expect(result.stdout.hasPrefix("CommandManager 0.4 —")) let alpha = try #require(result.stdout.range(of: "Alpha")) let zulu = try #require(result.stdout.range(of: "Zulu")) #expect(alpha.lowerBound < zulu.lowerBound) @@ -47,7 +47,7 @@ final class CommandExecutionTests: CMTestCase { func testMissingDefaultConfigurationExplainsSetup() throws { let result = try runCM(useConfig: false) let output = result.stdout + result.stderr - #expect(result.stdout.hasPrefix("CommandManager 0.3 —")) + #expect(result.stdout.hasPrefix("CommandManager 0.4 —")) #expect(output.contains("cm.json")) #expect(output.contains("Application Support")) #expect(!(output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) diff --git a/Tests/CommandManagerTests/DirectoryAndGitTests.swift b/Tests/CommandManagerTests/DirectoryAndGitTests.swift index 508f576..c8166f1 100644 --- a/Tests/CommandManagerTests/DirectoryAndGitTests.swift +++ b/Tests/CommandManagerTests/DirectoryAndGitTests.swift @@ -167,15 +167,12 @@ final class DirectoryAndGitTests: CMTestCase { assertSuccess(try runCM(["main"], cwd: child, environment: environment), output: "") } - @Test func testRunsWithTheSwiftInterpreter() throws { - try configure(["main": function([printStep("interpreted")])]) - let interpreter = ProcessInfo.processInfo.environment["SWIFT"] ?? "swift" + @Test func testExecutableRunsOutsideTheSourceTree() throws { + try configure(["main": function([printStep("standalone")])]) + let installed = directory.appendingPathComponent("cm") + try FileManager.default.copyItem(at: executableURL(), to: installed) let result = try runProcess( - interpreter, - arguments: [ - "-module-cache-path", directory.appendingPathComponent("module-cache").path, - Self.source.path, "--config", config.path, "main", - ], cwd: directory, timeout: 180) - assertSuccess(result, output: "interpreted\n") + installed.path, arguments: ["--config", config.path, "main"], cwd: directory) + assertSuccess(result, output: "standalone\n") } } diff --git a/Tests/CommandManagerTests/Support.swift b/Tests/CommandManagerTests/Support.swift index e1432c0..4f750f3 100644 --- a/Tests/CommandManagerTests/Support.swift +++ b/Tests/CommandManagerTests/Support.swift @@ -82,9 +82,8 @@ func runProcess( class CMTestCase { let directory: URL - static let source = URL(fileURLWithPath: #filePath) + static let repository = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() - .appendingPathComponent("cm.swift") var config: URL { directory.appendingPathComponent("cm.json") } var marker: URL { directory.appendingPathComponent("must-not-exist") } @@ -129,7 +128,7 @@ class CMTestCase { } parent.deleteLastPathComponent() } - let candidate = Self.source.deletingLastPathComponent().appendingPathComponent(".build/debug/cm") + let candidate = Self.repository.appendingPathComponent(".build/debug/cm") guard FileManager.default.isExecutableFile(atPath: candidate.path) else { throw TestProcessError.missingBinary } diff --git a/Tests/CommandManagerTests/VersionTests.swift b/Tests/CommandManagerTests/VersionTests.swift index 59177ee..7269277 100644 --- a/Tests/CommandManagerTests/VersionTests.swift +++ b/Tests/CommandManagerTests/VersionTests.swift @@ -3,26 +3,26 @@ import Testing final class VersionTests: CMTestCase { @Test func testCompatibleMinimumVersionsAreAccepted() throws { - for minimum in ["0.0", "0.1", "0.1.999", "0.2", "0.2.0", "0.02", "0.3"] { + for minimum in ["0.0", "0.1", "0.1.999", "0.2", "0.2.0", "0.02", "0.3", "0.3.1", "0.4", "0.4.0"] { try writeConfiguration(minimum: minimum, steps: [printStep("compatible")]) assertSuccess(try runCM(["main"]), output: "compatible\n") } } @Test func testNewerMinimumVersionsFailBeforeExecution() throws { - for minimum in ["0.3.1", "0.4", "0.10", "1.0", "10.0"] { + for minimum in ["0.4.1", "0.5", "0.10", "1.0", "10.0"] { try writeConfiguration(minimum: minimum, steps: [markerStep()]) let result = try runCM(["main"]) assertFailure(result) #expect(result.stderr.contains("requires CommandManager \(minimum) or later")) - #expect(result.stderr.contains("installed version is 0.3")) + #expect(result.stderr.contains("installed version is 0.4")) #expect(result.stdout.isEmpty) #expect(!FileManager.default.fileExists(atPath: marker.path)) } } @Test func testNewerMinimumVersionAlsoFailsForHelp() throws { - try writeConfiguration(minimum: "0.4", steps: []) + try writeConfiguration(minimum: "0.5", steps: []) assertFailure(try runCM()) assertFailure(try runCM(["--help"])) } diff --git a/Tests/CommandManagerTests/WorkflowTests.swift b/Tests/CommandManagerTests/WorkflowTests.swift new file mode 100644 index 0000000..438a12f --- /dev/null +++ b/Tests/CommandManagerTests/WorkflowTests.swift @@ -0,0 +1,299 @@ +import Foundation +import Testing + +final class WorkflowTests: CMTestCase { + @Test func testOptionsConditionsAndNoSelectionHelp() throws { + var main = function([ + ["builtin": "log", "args": ["build"], "when": ["any": ["all", "build"]]], + ["builtin": "log", "args": ["check"], "when": ["all": ["check", ["not": "build"]]]], + ]) + main["options"] = ["build": "Build the package", "check": "Check the package", "all": "Build everything"] + main["requireAnyOption"] = true + try configure(["main": main]) + assertSuccess(try runCM(["main", "--build"]), output: "build\n") + assertSuccess(try runCM(["main", "--check"]), output: "check\n") + assertSuccess(try runCM(["main", "--all"]), output: "build\n") + assertSuccess(try runCM(["main", "--build", "--check"]), output: "build\n") + let help = try runCM(["main"]) + assertSuccess(help) + #expect(help.stdout.contains("--build Build the package")) + assertFailure(try runCM(["main", "--unknown"])) + } + + @Test func testOptionTerminatorAndExplicitHelperOptions() throws { + var helper = function( + [ + ["builtin": "log", "args": ["${name}"], "when": "verbose"] + ], parameters: ["name"], entry: false) + helper["options"] = ["verbose": "Print name"] + try configure([ + "main": function([["function": "helper", "args": ["--verbose", "--", "--literal"]]]), + "helper": helper, + ]) + assertSuccess(try runCM(["main"]), output: "--literal\n") + } + + @Test func testCaptureTextTrimmedJSONAndStderr() throws { + try configure([ + "main": function([ + ["command": "/usr/bin/printf", "args": [" hello\n"], "capture": "text", "saveAs": "raw"], + ["command": "/usr/bin/printf", "args": [" hello\n"], "capture": "trimmed", "saveAs": "trimmed"], + [ + "command": "/bin/sh", + "args": ["-c", "printf '{\"items\":[{\"id\":9007199254740993}]}'; printf diagnostic >&2"], + "capture": "json", "saveAs": "data", + ], + ["builtin": "jsonGet", "args": ["data", "/items/0/id"], "saveAs": "id"], + printStep("${raw}|${trimmed}|${id}"), + ]) + ]) + let result = try runCM(["main"]) + assertSuccess(result, output: " hello\n|hello|9007199254740993\n") + #expect(result.stderr == "diagnostic") + } + + @Test func testFailedCaptureStopsBeforeLaterCommandsAndPreservesStatus() throws { + try configure([ + "main": function([ + [ + "command": "/bin/sh", "args": ["-c", "printf partial; exit 17"], "capture": "trimmed", + "saveAs": "result", "label": "Read account", + ], + markerStep(), + ]) + ]) + let result = try runCM(["main"]) + #expect(result.status == 17) + #expect(result.stderr.contains("Read account")) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + + @Test func testLargeCaptureDoesNotDeadlock() throws { + try configure([ + "main": function([ + [ + "command": "/usr/bin/head", "args": ["-c", "2000000", "/dev/zero"], "capture": "text", + "saveAs": "large", + ], + ["builtin": "log", "args": ["finished"]], + ]) + ]) + assertSuccess(try runCM(["main"]), output: "finished\n") + } + + @Test func testJSONReadingPointersAndArrayExpansionPreserveArguments() throws { + let data: [String: Any] = ["a/b": ["~key": ["two words", "", "$(touch must-not-exist)", "*.swift"]]] + let file = directory.appendingPathComponent("data.json") + try JSONSerialization.data(withJSONObject: data).write(to: file) + try configure([ + "main": function([ + ["builtin": "readJson", "args": ["data.json"], "saveAs": "data"], + ["builtin": "jsonGet", "args": ["data", "/a~1b/~0key"], "saveAs": "arguments"], + ["command": "/usr/bin/printf", "args": ["<%s>\n", ["spread": "arguments"]]], + ]) + ]) + assertSuccess(try runCM(["main"]), output: "\n<>\n<$(touch must-not-exist)>\n<*.swift>\n") + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + + @Test func testMissingInvalidAndEmptyJSONValuesFail() throws { + for pointer in ["/missing", "/null", "/empty", "/items/01", "/items/4", "/items/-1", "/bad~2key", "items"] { + try configure([ + "main": function([ + [ + "command": "/usr/bin/printf", "args": ["%s", "{\"null\":null,\"empty\":\" \",\"items\":[1]}"], + "capture": "json", "saveAs": "data", + ], + ["builtin": "jsonGet", "args": ["data", pointer], "saveAs": "value"], + markerStep(), + ]) + ]) + assertFailure(try runCM(["main"])) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + try configure([ + "main": function([ + ["command": "/usr/bin/printf", "args": ["invalid JSON"], "capture": "json", "saveAs": "data"], + markerStep(), + ]) + ]) + assertFailure(try runCM(["main"])) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + + @Test func testConditionalOutputsFailClearlyWhenMissing() throws { + var main = function([ + ["builtin": "set", "args": ["ready"], "saveAs": "value", "when": "enabled"], + printStep("${value}"), + markerStep(), + ]) + main["options"] = ["enabled": "Enable value"] + try configure(["main": main]) + let result = try runCM(["main"]) + assertFailure(result) + #expect(result.stderr.contains("Missing parameter 'value'")) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + assertSuccess(try runCM(["main", "--enabled"])) + } + + @Test func testVariablesAreLocalAndExplicitlyPassed() throws { + try configure([ + "main": function([ + ["builtin": "set", "args": ["parent"], "saveAs": "value"], + ["function": "helper", "args": ["${value}"]], + ["builtin": "log", "args": ["${value}"]], + ]), + "helper": function( + [ + ["builtin": "set", "args": ["child-${input}"], "saveAs": "value"], + ["builtin": "log", "args": ["${value}"]], + ], parameters: ["input"], entry: false), + ]) + assertSuccess(try runCM(["main"]), output: "child-parent\nparent\n") + } + + @Test func testSensitiveResultsAreRedactedInLogsEchoesAndErrors() throws { + let file = directory.appendingPathComponent("secret.json") + try Data(#"{"token":"private-token"}"#.utf8).write(to: file) + try configure([ + "main": function([ + ["builtin": "readJson", "args": ["secret.json"], "saveAs": "data", "sensitive": true], + ["builtin": "jsonGet", "args": ["data", "/token"], "saveAs": "token"], + ["builtin": "log", "args": ["token=${token}"]], + ["command": "/bin/test", "args": ["${token}", "=", "private-token"]], + ["builtin": "inDirectory", "args": ["${token}"]], + ]) + ]) + let result = try runCM(["main"]) + assertFailure(result) + #expect(!result.stdout.contains("private-token")) + #expect(!result.stderr.contains("private-token")) + #expect(result.stdout.contains("token=*****")) + } + + @Test func testPathsAndScopedDirectoryRestoration() throws { + let child = directory.appendingPathComponent("child") + try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) + try configure([ + "main": function([ + ["builtin": "pathJoin", "args": [directory.path, "child"], "saveAs": "child"], + ["builtin": "assertPath", "args": ["${child}", "directory"]], + ["builtin": "assertPath", "args": [config.path, "file"]], + ["builtin": "assertDirectChild", "args": ["${child}", directory.path]], + ["function": "helper", "args": ["${child}"]], + ["command": "/bin/pwd"], + ]), + "helper": function( + [ + ["builtin": "inDirectory", "args": ["${path}"]], + ["command": "/bin/pwd"], + ], parameters: ["path"], entry: false), + ]) + assertSuccess(try runCM(["main"]), output: "\(child.path)\n\(directory.path)\n") + try configure(["main": function([["builtin": "assertDirectChild", "args": [directory.path, directory.path]]])]) + assertFailure(try runCM(["main"])) + } + + @Test func testGitRootAndCleanAssertionIncludingUntrackedFiles() throws { + let repository = directory.appendingPathComponent("repository") + let nested = repository.appendingPathComponent("nested") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try initializeRepository(cwd: repository) + try configure([ + "main": function([ + ["builtin": "gitRoot", "saveAs": "root"], + ["builtin": "log", "args": ["${root}"]], + ["builtin": "assertGitClean"], + ]) + ]) + assertSuccess(try runCM(["main"], cwd: nested), output: "\(repository.path)\n") + try Data("changed".utf8).write(to: repository.appendingPathComponent("untracked")) + assertFailure(try runCM(["main"], cwd: nested)) + try git(["add", "untracked"], cwd: repository) + assertFailure(try runCM(["main"], cwd: nested)) + } + + @Test func testInvalidWorkflowSchemaIsRejectedBeforeExecution() throws { + let invalid: [[String: Any]] = [ + ["builtin": "set", "args": ["value"]], + ["builtin": "log", "args": ["value"], "saveAs": "result"], + ["command": "/bin/echo", "saveAs": "result"], + ["command": "/bin/echo", "capture": "text"], + ["command": "/bin/echo", "capture": "invalid", "saveAs": "result"], + ["builtin": "set", "args": ["value"], "saveAs": "bad name"], + ["builtin": "log", "args": ["value"], "when": "unknown"], + ["builtin": "log", "args": ["value"], "when": ["any": []]], + ["builtin": "log", "args": ["value"], "when": ["any": [], "not": "unknown"]], + ["command": "/bin/echo", "args": [["spread": "missing"]]], + ["builtin": "jsonGet", "args": ["missing", "/id"], "saveAs": "id"], + ["builtin": "log", "args": ["value"], "sensitive": true], + ["builtin": "pathJoin", "saveAs": "path"], + ] + for step in invalid { + try assertInvalidConfiguration(["main": function([markerStep(), step])]) + } + } + + @Test func testInvalidArraySpreadsAndStructuredSubstitutionFail() throws { + let variants: [(String, [Any])] = [ + ("[1]", [["spread": "data"]]), + ("{}", [["spread": "data"]]), + ("[]", ["${data}"]), + (#"["\u0000"]"#, [["spread": "data"]]), + ] + for (json, args) in variants { + try configure([ + "main": function([ + ["command": "/usr/bin/printf", "args": ["%s", json], "capture": "json", "saveAs": "data"], + ["command": "/usr/bin/touch", "args": args], + markerStep(), + ]) + ]) + assertFailure(try runCM(["main"])) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + } + + @Test func testCapturedValueExportsAndStdinArePreserved() throws { + try configure([ + "main": function([ + ["command": "/bin/cat", "capture": "trimmed", "saveAs": "input", "sensitive": true], + ["builtin": "export", "args": ["CM_CAPTURED", "${input}"]], + [ + "command": "/bin/sh", "args": ["-c", "printf '%s' \"$CM_CAPTURED\""], "capture": "text", + "saveAs": "copied", + ], + ["builtin": "log", "args": ["${copied}"]], + ]) + ]) + assertSuccess(try runCM(["main"], input: "input-value\n"), output: "*****\n") + } + + @Test func testJSONBooleanConditionAndSkippedArguments() throws { + try configure([ + "main": function([ + ["command": "/usr/bin/printf", "args": ["false"], "capture": "json", "saveAs": "enabled"], + ["builtin": "set", "args": ["unused"], "saveAs": "missing", "when": "enabled"], + ["builtin": "log", "args": ["${missing}"], "when": "enabled"], + ["builtin": "log", "args": ["done"], "when": ["not": "enabled"]], + ]) + ]) + assertSuccess(try runCM(["main"]), output: "done\n") + } + + @Test func testForwardAndDuplicateOutputsAreRejected() throws { + try assertInvalidConfiguration([ + "main": function([ + markerStep(), printStep("${later}"), + ["builtin": "set", "args": ["value"], "saveAs": "later"], + ]) + ]) + try assertInvalidConfiguration([ + "main": function([ + markerStep(), + ["builtin": "set", "args": ["one"], "saveAs": "value"], + ["builtin": "set", "args": ["two"], "saveAs": "value"], + ]) + ]) + } +} diff --git a/cm.swift b/cm.swift deleted file mode 100755 index 6d358ec..0000000 --- a/cm.swift +++ /dev/null @@ -1,791 +0,0 @@ -#!/usr/bin/env swift -import Darwin -import Foundation - -let commandManagerVersion = "0.3" - -struct CommandError: Error, CustomStringConvertible { - let description: String - let status: Int32 - - init(_ message: String, status: Int32 = 1) { - description = message - self.status = status - } -} - -struct Version: Comparable { - private let components: [UInt] - - init(_ text: String) throws { - let parts = text.split(separator: ".", omittingEmptySubsequences: false) - let numbers = parts.compactMap { UInt($0) } - guard text.range(of: "\\A[0-9]+\\.[0-9]+(?:\\.[0-9]+)?\\z", options: .regularExpression) != nil, - numbers.count == parts.count - else { - throw CommandError( - "Invalid version '\(text)'; expected major.minor or major.minor.patch using nonnegative integers.") - } - components = numbers + Array(repeating: 0, count: 3 - numbers.count) - } - - static func < (lhs: Version, rhs: Version) -> Bool { - lhs.components.lexicographicallyPrecedes(rhs.components) - } -} - -func validateMinimumVersion(_ minimum: String?) throws { - guard let minimum else { return } - guard try Version(commandManagerVersion) >= Version(minimum) else { - throw CommandError( - "This configuration requires CommandManager \(minimum) or later; installed version is \(commandManagerVersion). Upgrade cm before using this configuration." - ) - } -} - -struct JSONKey: CodingKey { - let stringValue: String - let intValue: Int? = nil - - init?(stringValue: String) { - self.stringValue = stringValue - } - - init?(intValue: Int) { - return nil - } -} - -extension KeyedDecodingContainer { - func decodeIfDefined(_ type: Value.Type, forKey key: Key) throws -> Value? { - guard contains(key) else { return nil } - return try decode(type, forKey: key) - } -} - -func rejectUnknownKeys(_ decoder: Decoder, allowed: Set) throws { - let container = try decoder.container(keyedBy: JSONKey.self) - let unknown = Set(container.allKeys.map(\.stringValue)).subtracting(allowed) - guard unknown.isEmpty else { - let location = decoder.codingPath.map(\.stringValue).joined(separator: ".") - throw CommandError( - "Unknown JSON field(s) at \(location.isEmpty ? "root" : location): \(unknown.sorted().joined(separator: ", "))." - ) - } -} - -func isIdentifier(_ value: String) -> Bool { - value.range(of: "\\A[A-Za-z_][A-Za-z0-9_]*\\z", options: .regularExpression) != nil -} - -func isFunctionName(_ value: String) -> Bool { - value.range(of: "\\A[A-Za-z_][A-Za-z0-9_-]*\\z", options: .regularExpression) != nil -} - -struct FunctionDefinition: Decodable { - let description: String - let entryPoint: Bool - let parameters: [String] - let settings: [String] - let steps: [Step] - - enum CodingKeys: String, CodingKey { - case description, entryPoint, parameters, settings, steps - } - - init(from decoder: Decoder) throws { - try rejectUnknownKeys(decoder, allowed: ["description", "entryPoint", "parameters", "settings", "steps"]) - let container = try decoder.container(keyedBy: CodingKeys.self) - description = try container.decode(String.self, forKey: .description) - entryPoint = try container.decodeIfDefined(Bool.self, forKey: .entryPoint) ?? false - parameters = try container.decodeIfDefined([String].self, forKey: .parameters) ?? [] - settings = try container.decodeIfDefined([String].self, forKey: .settings) ?? [] - steps = try container.decode([Step].self, forKey: .steps) - } - - var usage: String { - parameters.map { "<\($0)>" }.joined(separator: " ") - } -} - -struct SettingDefinition: Decodable { - let name: String - let value: String - - enum CodingKeys: String, CodingKey { - case name, value - } - - init(from decoder: Decoder) throws { - try rejectUnknownKeys(decoder, allowed: ["name", "value"]) - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) - value = try container.decode(String.self, forKey: .value) - } -} - -struct Step: Decodable { - enum Target { - case command(String) - case function(String) - case builtin(String) - } - - let target: Target - let args: [String] - - enum CodingKeys: String, CodingKey { - case command, function, builtin, args - } - - init(from decoder: Decoder) throws { - try rejectUnknownKeys(decoder, allowed: ["command", "function", "builtin", "args"]) - let container = try decoder.container(keyedBy: CodingKeys.self) - let targets: [Target] = try [ - container.decodeIfDefined(String.self, forKey: .command).map(Target.command), - container.decodeIfDefined(String.self, forKey: .function).map(Target.function), - container.decodeIfDefined(String.self, forKey: .builtin).map(Target.builtin), - ].compactMap { $0 } - guard targets.count == 1, let target = targets.first else { - throw CommandError("Each step must specify exactly one of command, function, or builtin.") - } - self.target = target - args = try container.decodeIfDefined([String].self, forKey: .args) ?? [] - } -} - -enum Builtin: String, CaseIterable { - case inFolder - case assertGitRoot - case assertGitRepository - case export - - var argumentCount: Int { - switch self { - case .inFolder: - return 1 - case .export: - return 2 - case .assertGitRoot, .assertGitRepository: - return 0 - } - } -} - -/// Arguments are templates, never shell programs. Inserted values are not parsed again. -struct ArgumentTemplate { - enum Part { - case text(String) - case parameter(String) - } - - private var parts: [Part] = [] - - init(_ source: String) throws { - var remaining = source[...] - while let dollar = remaining.firstIndex(of: "$") { - parts.append(.text(String(remaining[..) throws { - for case .parameter(let name) in parts { - guard parameters.contains(name) else { - throw CommandError("Unknown parameter '${\(name)}'. Use $$ to escape a literal dollar sign.") - } - } - } - - func render(values: [String: String]) throws -> String { - try parts.map { part in - switch part { - case .text(let value): - return value - case .parameter(let name): - guard let value = values[name] else { - throw CommandError("Missing parameter '\(name)'.") - } - return value - } - }.joined() - } -} - -struct Configuration: Decodable { - let settings: [SettingDefinition] - let functions: [String: FunctionDefinition] - - enum CodingKeys: String, CodingKey { - case minimumVersion, settings, functions - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - try validateMinimumVersion(container.decodeIfDefined(String.self, forKey: .minimumVersion)) - try rejectUnknownKeys(decoder, allowed: ["minimumVersion", "settings", "functions"]) - settings = try container.decodeIfDefined([SettingDefinition].self, forKey: .settings) ?? [] - functions = try container.decode([String: FunctionDefinition].self, forKey: .functions) - } - - func validate() throws { - try validateSettings() - for name in functions.keys.sorted() { - guard let function = functions[name] else { continue } - try validateDefinition(name, function: function) - try validateSteps(name, function: function) - } - var visited = Set() - for name in functions.keys.sorted() { - try validateCycles(name, path: [], visited: &visited) - } - } - - private func validateSettings() throws { - let names = settings.map(\.name) - guard names.allSatisfy(isIdentifier), Set(names).count == names.count else { - throw CommandError( - "Settings must have unique names using letters, digits, and underscores, starting with a letter or underscore." - ) - } - try validateProcessArguments(settings.map(\.value)) - } - - private func validateDefinition(_ name: String, function: FunctionDefinition) throws { - guard isFunctionName(name) else { - throw CommandError( - "Invalid function name '\(name)'; use letters, digits, underscores, and hyphens, starting with a letter or underscore." - ) - } - guard !function.description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw CommandError("Function '\(name)' must have a nonempty description.") - } - guard function.parameters.allSatisfy(isIdentifier), - Set(function.parameters).count == function.parameters.count - else { - throw CommandError( - "Function '\(name)' must have unique parameter names using letters, digits, and underscores, starting with a letter or underscore." - ) - } - guard function.settings.allSatisfy(isIdentifier), Set(function.settings).count == function.settings.count else { - throw CommandError( - "Function '\(name)' must have unique setting names using letters, digits, and underscores, starting with a letter or underscore." - ) - } - guard Set(function.parameters).isDisjoint(with: function.settings) else { - throw CommandError("Function '\(name)' cannot use the same name for a parameter and a setting.") - } - let definedSettings = Set(settings.map(\.name)) - guard Set(function.settings).isSubset(of: definedSettings) else { - let unknown = Set(function.settings).subtracting(definedSettings).sorted().joined(separator: ", ") - throw CommandError("Function '\(name)' uses unknown setting(s): \(unknown).") - } - } - - private func validateSteps(_ name: String, function: FunctionDefinition) throws { - for (index, step) in function.steps.enumerated() { - do { - try validateTarget(step) - for argument in step.args { - try ArgumentTemplate(argument).validate(parameters: Set(function.parameters + function.settings)) - } - } catch { - throw CommandError("Function '\(name)', step \(index + 1): \(error)") - } - } - } - - private func validateTarget(_ step: Step) throws { - try validateProcessArguments(step.args) - switch step.target { - case .command(let executable): - try validateProcessArguments([executable]) - guard !executable.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw CommandError("Command executable must not be empty.") - } - case .function(let name): - guard let function = functions[name] else { - throw CommandError("Unknown function '\(name)'.") - } - try requireArguments(step.args, count: function.parameters.count, target: "Function '\(name)'") - case .builtin(let name): - guard let builtin = Builtin(rawValue: name) else { - throw CommandError( - "Unknown builtin '\(name)'. Available: \(Builtin.allCases.map(\.rawValue).joined(separator: ", "))." - ) - } - try requireArguments(step.args, count: builtin.argumentCount, target: "Builtin '\(name)'") - } - } - - private func validateCycles(_ name: String, path: [String], visited: inout Set) throws { - guard !path.contains(name) else { - throw CommandError("Function call cycle: \((path + [name]).joined(separator: " -> ")).") - } - guard !visited.contains(name), let function = functions[name] else { return } - for step in function.steps { - if case .function(let callee) = step.target { - try validateCycles(callee, path: path + [name], visited: &visited) - } - } - visited.insert(name) - } -} - -func requireArguments(_ arguments: [String], count: Int, target: String) throws { - guard arguments.count == count else { - throw CommandError("\(target) expects \(count) argument(s), received \(arguments.count).") - } -} - -/// Each function inherits its caller's directory and restores it when the function returns. -struct Runner { - let configuration: Configuration - - func runEntryPoint( - _ name: String, arguments: [String], directory: inout URL, environment: inout [String: String] - ) throws { - let initialEnvironment = environment - defer { environment = initialEnvironment } - try run(name, arguments: arguments, directory: &directory, environment: &environment) - } - - func run(_ name: String, arguments: [String], directory: inout URL, environment: inout [String: String]) throws { - guard let function = configuration.functions[name] else { - throw CommandError("Unknown function '\(name)'.") - } - try requireArguments(arguments, count: function.parameters.count, target: "Function '\(name)'") - var functionDirectory = directory - let values = Dictionary(uniqueKeysWithValues: zip(function.parameters, arguments)) - .merging(settingValues(for: function), uniquingKeysWith: { _, setting in setting }) - for (index, step) in function.steps.enumerated() { - do { - let args = try step.args.map { try ArgumentTemplate($0).render(values: values) } - try execute(step.target, arguments: args, directory: &functionDirectory, environment: &environment) - } catch let error as CommandError { - throw CommandError("\(name), step \(index + 1): \(error)", status: error.status) - } - } - } - - private func settingValues(for function: FunctionDefinition) -> [String: String] { - let values = Dictionary(uniqueKeysWithValues: configuration.settings.map { ($0.name, $0.value) }) - return Dictionary(uniqueKeysWithValues: function.settings.map { ($0, values[$0]!) }) - } - - private func execute( - _ target: Step.Target, arguments: [String], directory: inout URL, environment: inout [String: String] - ) throws { - switch target { - case .command(let executable): - try executeCommand(executable, arguments: arguments, directory: directory, environment: environment) - case .function(let name): - try run(name, arguments: arguments, directory: &directory, environment: &environment) - case .builtin(let name): - guard let builtin = Builtin(rawValue: name) else { - throw CommandError("Unknown builtin '\(name)'.") - } - try executeBuiltin(builtin, arguments: arguments, directory: &directory, environment: &environment) - } - } - - private func executeBuiltin( - _ builtin: Builtin, arguments: [String], directory: inout URL, environment: inout [String: String] - ) throws { - switch builtin { - case .inFolder: - directory = try folder(named: arguments[0], from: directory) - case .assertGitRoot: - try assertGitRepository(directory, requireRoot: true) - case .assertGitRepository: - try assertGitRepository(directory, requireRoot: false) - case .export: - try export(name: arguments[0], value: arguments[1], into: &environment) - } - } - - private func folder(named name: String, from directory: URL) throws -> URL { - guard !name.isEmpty, name != ".", name != "..", !name.contains("/"), !name.contains("\0") else { - throw CommandError("inFolder requires a single folder name, not a path: '\(name)'.") - } - if directory.lastPathComponent == name { return directory } - let child = directory.appendingPathComponent(name, isDirectory: true) - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: child.path, isDirectory: &isDirectory), isDirectory.boolValue - else { - throw CommandError( - "inFolder: '\(name)' is neither the current folder nor a child directory of '\(directory.path)'.") - } - return child - } - - private func export(name: String, value: String, into environment: inout [String: String]) throws { - guard isIdentifier(name) else { - throw CommandError( - "export requires an environment variable name using letters, digits, and underscores, starting with a letter or underscore." - ) - } - environment[name] = value - } - - private func executeCommand( - _ executable: String, arguments: [String], directory: URL, environment: [String: String] - ) throws { - printCommand(executable, arguments: redactedArguments(arguments)) - let status = try runInheritedCommand( - executable, arguments: arguments, directory: directory, environment: environment) - guard status == 0 else { - throw CommandError("Command '\(executable)' failed with exit status \(status).", status: status) - } - } - - private func redactedArguments(_ arguments: [String]) -> [String] { - let settings = configuration.settings.map(\.value).filter { !$0.isEmpty }.sorted { $0.count > $1.count } - return arguments.map { argument in - settings.reduce(argument) { redacted, setting in - redacted.replacingOccurrences(of: setting, with: "*****") - } - } - } - - private func assertGitRepository(_ directory: URL, requireRoot: Bool) throws { - let inside = try gitOutput(["rev-parse", "--is-inside-work-tree"], directory: directory) - guard inside == "true" else { - throw CommandError("Expected a Git repository working tree at '\(directory.path)'.") - } - guard requireRoot else { return } - let root = try gitOutput(["rev-parse", "--show-toplevel"], directory: directory) - let rootURL = URL(fileURLWithPath: root).resolvingSymlinksInPath().standardizedFileURL - guard rootURL == directory.resolvingSymlinksInPath().standardizedFileURL else { - throw CommandError( - "Expected the Git repository root; current folder is '\(directory.path)', root is '\(root)'.") - } - } - - private func gitOutput(_ arguments: [String], directory: URL) throws -> String { - let process = try makeProcess("git", arguments: arguments, directory: directory) - // Inspect the directory itself, independent of a calling Git hook or alias's repository overrides. - process.environment = ProcessInfo.processInfo.environment.filter { !$0.key.hasPrefix("GIT_") } - let output = Pipe() - process.standardInput = FileHandle.nullDevice - process.standardOutput = output - process.standardError = FileHandle.nullDevice - try start(process, executable: "git") - let data = output.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - throw CommandError("Expected a Git repository working tree at '\(directory.path)'.") - } - var text = String(decoding: data, as: UTF8.self) - if text.hasSuffix("\n") { text.removeLast() } - return text - } -} - -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) - } -} - -/// Inherit the caller's process group so terminal reads and terminal signals work normally. -func runInheritedCommand( - _ executable: String, arguments: [String], directory: URL, environment: [String: String] -) throws -> Int32 { - try validateProcessArguments([executable] + arguments) - let path = try executableURL(executable, directory: directory, environment: environment).path - var actions: posix_spawn_file_actions_t? - try checkSpawn(posix_spawn_file_actions_init(&actions), executable: executable) - defer { posix_spawn_file_actions_destroy(&actions) } - try checkSpawn(addWorkingDirectory(&actions, path: directory.path), executable: executable) - var pid: pid_t = 0 - var attributes: posix_spawnattr_t? - try checkSpawn(posix_spawnattr_init(&attributes), executable: executable) - defer { posix_spawnattr_destroy(&attributes) } - var defaults = sigset_t() - sigemptyset(&defaults) - sigaddset(&defaults, SIGINT) - sigaddset(&defaults, SIGQUIT) - try checkSpawn(posix_spawnattr_setsigdefault(&attributes, &defaults), executable: executable) - try checkSpawn(posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETSIGDEF)), executable: executable) - // Like system(3): let the foreground child handle terminal interrupts while cm waits. - let previousInterrupt = signal(SIGINT, SIG_IGN) - let previousQuit = signal(SIGQUIT, SIG_IGN) - defer { - signal(SIGINT, previousInterrupt) - signal(SIGQUIT, previousQuit) - } - try withCStringArray([executable] + arguments) { argv in - try withCStringArray(environment.map { "\($0.key)=\($0.value)" }) { environment in - // No SETPGROUP flag: unlike Foundation.Process, keep the existing foreground job. - try checkSpawn(posix_spawn(&pid, path, &actions, &attributes, argv, environment), executable: executable) - } - } - return try waitForCommand(pid) -} - -func addWorkingDirectory(_ actions: inout posix_spawn_file_actions_t?, path: String) -> Int32 { - #if compiler(>=6.2) - if #available(macOS 26.0, *) { - return posix_spawn_file_actions_addchdir(&actions, path) - } else { - return posix_spawn_file_actions_addchdir_np(&actions, path) - } - #else - return posix_spawn_file_actions_addchdir_np(&actions, path) - #endif -} - -func withCStringArray( - _ strings: [String], body: (UnsafeMutablePointer?>) throws -> Result -) throws -> Result { - var pointers = strings.map { strdup($0) } - defer { - for pointer in pointers { free(pointer) } - } - guard pointers.allSatisfy({ $0 != nil }) else { throw CommandError("Cannot allocate command arguments.") } - pointers.append(nil) - return try pointers.withUnsafeMutableBufferPointer { try body($0.baseAddress!) } -} - -func checkSpawn(_ status: Int32, executable: String) throws { - guard status == 0 else { - throw CommandError("Cannot run command '\(executable)': \(String(cString: strerror(status)))", status: 126) - } -} - -func waitForCommand(_ pid: pid_t) throws -> Int32 { - var status: Int32 = 0 - while waitpid(pid, &status, 0) == -1 { - guard errno == EINTR else { - throw CommandError("Cannot wait for command: \(String(cString: strerror(errno)))") - } - } - // Darwin's wait status macros are not imported into Swift. - let signal = status & 0x7f - return signal == 0 ? (status >> 8) & 0xff : min(128 + signal, 255) -} - -func makeProcess(_ executable: String, arguments: [String], directory: URL) throws -> Process { - try validateProcessArguments([executable] + arguments) - let process = Process() - process.currentDirectoryURL = directory - process.executableURL = try executableURL(executable, directory: directory) - process.arguments = arguments - return process -} - -func validateProcessArguments(_ arguments: [String]) throws { - guard !arguments.contains(where: { $0.contains("\0") }) else { - throw CommandError("Command names and arguments cannot contain a NUL character.") - } -} - -func executableURL(_ executable: String, directory: URL, environment: [String: String]? = nil) throws -> URL { - if executable.contains("/") { - return URL(fileURLWithPath: executable, relativeTo: directory).absoluteURL - } - let searchPath = (environment ?? ProcessInfo.processInfo.environment)["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" - for component in searchPath.components(separatedBy: ":") { - let base = component.isEmpty ? directory : URL(fileURLWithPath: component, relativeTo: directory) - let candidate = base.appendingPathComponent(executable) - if isExecutableFile(candidate) { return candidate.absoluteURL } - } - throw CommandError("Command '\(executable)' was not found in PATH.", status: 127) -} - -func isExecutableFile(_ url: URL) -> Bool { - var isDirectory: ObjCBool = false - return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) - && !isDirectory.boolValue && FileManager.default.isExecutableFile(atPath: url.path) -} - -func start(_ process: Process, executable: String) throws { - do { - try process.run() - } catch { - throw CommandError("Cannot run command '\(executable)': \(error.localizedDescription)", status: 126) - } -} - -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 configurationURL(_ explicitPath: String?) throws -> URL { - if let explicitPath { - let path = (explicitPath as NSString).expandingTildeInPath - return URL(fileURLWithPath: path).standardizedFileURL - } - let directory = try FileManager.default.url( - for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false - ) - return directory.appendingPathComponent("CommandManager/cm.json") -} - -func loadConfiguration(at url: URL) throws -> Configuration { - do { - let data = try Data(contentsOf: url) - let configuration = try JSONDecoder().decode(Configuration.self, from: data) - try configuration.validate() - return configuration - } catch let error as CommandError { - throw CommandError("Invalid configuration '\(url.path)': \(error)") - } catch let error as DecodingError { - throw CommandError("Invalid JSON configuration '\(url.path)': \(describeDecodingError(error))") - } catch { - throw CommandError("Cannot read configuration '\(url.path)': \(error.localizedDescription)") - } -} - -func describeDecodingError(_ error: DecodingError) -> String { - switch error { - case .keyNotFound(let key, let context): - return "Missing '\(key.stringValue)' at \(decodingLocation(context))." - case .typeMismatch(_, let context), .valueNotFound(_, let context), .dataCorrupted(let context): - return "\(decodingLocation(context)): \(context.debugDescription)" - @unknown default: - return String(describing: error) - } -} - -func decodingLocation(_ context: DecodingError.Context) -> String { - let path = context.codingPath.map(\.stringValue).joined(separator: ".") - return path.isEmpty ? "root" : path -} - -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?.functions.filter { $0.value.entryPoint } ?? [:] - 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. Set entryPoint to true to expose a function.") } - print("\nUse cm --help 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) -} - -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.functions[name] else { - throw CommandError("Unknown function '\(name)'. Run cm to list entry points.") - } - guard function.entryPoint 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.") -} - -do { - try main(Array(CommandLine.arguments.dropFirst())) -} catch let error as CommandError { - FileHandle.standardError.write(Data("cm: \(error)\n".utf8)) - exit(error.status) -} catch { - FileHandle.standardError.write(Data("cm: \(error.localizedDescription)\n".utf8)) - exit(1) -} From 0cb3cd3f39b5b27fd0c9b103823660dcad9de675 Mon Sep 17 00:00:00 2001 From: b4prog Date: Sat, 26 Sep 2026 05:39:07 +0200 Subject: [PATCH 02/10] [test] cover CLI and workflow edge cases and isolate configuration tests --- .../CommandExecutionTests.swift | 26 +- .../CoverageRegressionTests.swift | 326 ++++++++++++++++++ 2 files changed, 339 insertions(+), 13 deletions(-) create mode 100644 Tests/CommandManagerTests/CoverageRegressionTests.swift diff --git a/Tests/CommandManagerTests/CommandExecutionTests.swift b/Tests/CommandManagerTests/CommandExecutionTests.swift index d21f685..21c2add 100644 --- a/Tests/CommandManagerTests/CommandExecutionTests.swift +++ b/Tests/CommandManagerTests/CommandExecutionTests.swift @@ -37,20 +37,20 @@ final class CommandExecutionTests: CMTestCase { assertFailure(try runCM()) } - @Test( - .enabled( - if: !FileManager.default.fileExists( - atPath: - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/CommandManager/cm.json").path - ), "Run only when no personal configuration exists.")) - func testMissingDefaultConfigurationExplainsSetup() throws { - let result = try runCM(useConfig: false) - let output = result.stdout + result.stderr + @Test func testMissingDefaultConfigurationExplainsSetup() throws { + var environment = ProcessInfo.processInfo.environment + environment["HOME"] = directory.path + environment["CFFIXED_USER_HOME"] = directory.path + let result = try runCM(environment: environment, useConfig: false) + assertSuccess(result) #expect(result.stdout.hasPrefix("CommandManager 0.4 —")) - #expect(output.contains("cm.json")) - #expect(output.contains("Application Support")) - #expect(!(output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) + #expect( + result.stdout.contains("/\(directory.lastPathComponent)/Library/Application Support/CommandManager/cm.json") + ) + #expect(result.stdout.contains("Configuration file not found.")) + let failure = try runCM(["main"], environment: environment, useConfig: false) + #expect(failure.status == 1) + #expect(failure.stderr.contains("Configuration file not found:")) } @Test func testUnknownFunctionAndInternalFunctionCannotRun() throws { diff --git a/Tests/CommandManagerTests/CoverageRegressionTests.swift b/Tests/CommandManagerTests/CoverageRegressionTests.swift new file mode 100644 index 0000000..4492909 --- /dev/null +++ b/Tests/CommandManagerTests/CoverageRegressionTests.swift @@ -0,0 +1,326 @@ +import Foundation +import Testing + +final class CoverageRegressionTests: CMTestCase { + @Test func testParameterlessFunctionHelpDoesNotExecuteSteps() throws { + try configure(["main": function([markerStep()])]) + let result = try runCM(["--help", "main"]) + assertSuccess(result) + #expect(result.stdout.hasPrefix("Usage: cm main\n")) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + + @Test func testExecutableSearchUsesDefaultAndEmptyPathComponents() throws { + var environment = ProcessInfo.processInfo.environment + environment.removeValue(forKey: "PATH") + try configure(["main": function([["command": "printf", "args": ["default path"]]])]) + assertSuccess(try runCM(["main"], environment: environment), output: "default path") + let executable = directory.appendingPathComponent("local-command") + try Data("#!/bin/sh\nprintf 'local path'\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + environment["PATH"] = "" + try configure(["main": function([["command": "local-command"]])]) + assertSuccess(try runCM(["main"], environment: environment), output: "local path") + } + + @Test func testGitProbeLaunchFailurePreservesStatus() throws { + let executable = directory.appendingPathComponent("git") + try Data("#!\(directory.path)/missing-interpreter\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = directory.path + try configure(["main": function([["builtin": "assertGitRepository"], markerStep()])]) + let result = try runCM(["main"], environment: environment) + #expect(result.status == 126) + #expect(result.stderr.contains("Cannot run command 'git'")) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + + @Test func testJSONGetReportsSkippedSourceAndRendersFalse() throws { + var main = function([ + ["command": "/usr/bin/printf", "args": ["false"], "capture": "json", "saveAs": "flag"], + ["builtin": "log", "args": ["${flag}"]], + ["builtin": "set", "args": ["unused"], "saveAs": "missing", "when": "enabled"], + ["builtin": "jsonGet", "args": ["missing", ""], "saveAs": "result"], markerStep(), + ]) + main["options"] = ["enabled": "Enable source"] + try configure(["main": main]) + let result = try runCM(["main"]) + assertFailure(result) + #expect(result.outputWithoutEcho == "false\n") + #expect(result.stderr.contains("Missing JSON variable 'missing'")) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + + @Test func testInvalidCLIOptionsExplainTheFailure() throws { + for arguments in [["--config"], ["--config", ""], ["--config", config.path, "--config", config.path]] { + let result = try runCM(arguments, useConfig: false) + #expect(result.status == 1) + #expect(result.stderr.contains("Use --config once, followed by a configuration file path.")) + } + let result = try runCM(["--unknown"], useConfig: false) + #expect(result.status == 1) + #expect(result.stderr.contains("Unknown option '--unknown'")) + } + + @Test func testDefaultConfigurationIsLoadedFromAnIsolatedHome() throws { + let destination = directory.appendingPathComponent("Library/Application Support/CommandManager/cm.json") + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + try configure(["main": function([printStep("isolated configuration")])]) + try FileManager.default.copyItem(at: config, to: destination) + var environment = ProcessInfo.processInfo.environment + environment["HOME"] = directory.path + environment["CFFIXED_USER_HOME"] = directory.path + assertSuccess( + try runCM(["main"], environment: environment, useConfig: false), output: "isolated configuration\n") + } + + @Test func testInvalidFunctionOptionsAndSettingsFailBeforeExecution() throws { + let variants: [([String: Any], String)] = [ + (["options": ["bad name": "Invalid"]], "Invalid or conflicting option"), + (["parameters": ["value"], "options": ["value": "Conflict"]], "Invalid or conflicting option"), + (["settings": ["token"], "options": ["token": "Conflict"]], "Invalid or conflicting option"), + (["requireAnyOption": true], "requireAnyOption needs declared options"), + (["settings": ["token", "token"]], "must have unique setting names"), + (["settings": ["bad name"]], "must have unique setting names"), + ] + for (fields, message) in variants { + let main = function([markerStep()]).merging(fields, uniquingKeysWith: { _, new in new }) + try configure(["main": main], settings: [["name": "token", "value": "secret"]]) + try expectFailure(message) + } + } + + @Test func testInvalidStepCombinationsFailBeforeExecution() throws { + let variants: [([String: Any], String)] = [ + (["function": "helper", "capture": "text", "saveAs": "value"], "Function calls cannot capture"), + (["function": "helper", "saveAs": "value"], "Function calls cannot capture"), + (["builtin": "log", "args": ["hello"], "label": " "], "Step label must not be empty"), + (["builtin": "log", "args": ["hello"], "label": "bad\0label"], "cannot contain a NUL"), + (["builtin": "log", "args": [["spread": "value"]]], "Array expansion is supported only"), + ( + ["builtin": "set", "args": ["hello"], "capture": "text", "saveAs": "value"], + "capture is only for commands" + ), + ] + for (step, message) in variants { + try configure(["main": function([markerStep(), step]), "helper": function([], entry: false)]) + try expectFailure(message) + } + } + + @Test func testPathAndEnvironmentValidationStopsLaterCommands() throws { + let variants: [([String: Any], String)] = [ + (["builtin": "pathJoin", "args": [""], "saveAs": "path"], "requires a nonempty base path"), + ( + ["builtin": "pathJoin", "args": ["base", ""], "saveAs": "path"], "requires nonempty relative components" + ), + ( + ["builtin": "pathJoin", "args": ["base", "/absolute"], "saveAs": "path"], + "requires nonempty relative components" + ), + (["builtin": "assertPath", "args": [directory.path, "link"]], "kind must be file or directory"), + (["builtin": "assertPath", "args": [directory.path, "file"]], "Expected file"), + (["builtin": "assertPath", "args": [config.path, "directory"]], "Expected directory"), + (["builtin": "export", "args": ["BAD-NAME", "value"]], "export requires an environment variable name"), + ] + for (step, message) in variants { + try configure(["main": function([step, markerStep()])]) + try expectFailure(message) + } + } + + @Test func testNonExecutableCommandPreservesLaunchFailureStatus() throws { + let executable = directory.appendingPathComponent("not-executable") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: executable.path) + try configure(["main": function([["command": executable.path], markerStep()])]) + try expectFailure("Cannot run command", status: 126) + } + + @Test func testMissingCommandPreservesNotFoundStatus() throws { + try configure(["main": function([["command": "cm-nonexistent-command"], markerStep()])]) + try expectFailure("was not found in PATH", status: 127) + } + + @Test func testCaptureRejectsInvalidUTF8AndCleansTemporaryFiles() throws { + let bytes = directory.appendingPathComponent("invalid-utf8") + try Data([0xff, 0xfe]).write(to: bytes) + for mode in ["text", "trimmed"] { + try assertCaptureCleanup( + command: "/bin/cat", arguments: [bytes.path], mode: mode, status: 1, message: "not valid UTF-8") + } + } + + @Test func testCaptureCleansTemporaryFilesAfterSuccessAndCommandFailure() throws { + try assertCaptureCleanup(command: "/usr/bin/printf", arguments: ["hello"], mode: "text", status: 0) + try assertCaptureCleanup( + command: "/bin/sh", arguments: ["-c", "printf partial; exit 17"], mode: "text", status: 17) + try assertCaptureCleanup( + command: "/usr/bin/printf", arguments: ["invalid"], mode: "json", status: 1, + message: "Cannot decode JSON result") + } + + @Test func testSensitiveArraysAndOverlappingSecretsAreRedactedAcrossHelpers() throws { + let secrets = directory.appendingPathComponent("secrets.json") + try Data(#"["private","private-token",{"nested":[true,42,null,""]}]"#.utf8).write(to: secrets) + try configure([ + "main": function([ + ["builtin": "readJson", "args": [secrets.path], "saveAs": "data", "sensitive": true], + ["function": "helper"], + ]), + "helper": function( + [ + ["builtin": "log", "args": ["private-token|private|true|42|visible"]], + ["command": "/usr/bin/true", "args": ["private-token", "private", "true", "42"]], + ["builtin": "inDirectory", "args": ["private-token"]], + ], entry: false), + ]) + let result = try runCM(["main"]) + assertFailure(result) + #expect(result.outputWithoutEcho == "*****|*****|*****|*****|visible\n") + #expect(result.stdout.contains("'*****' '*****' '*****' '*****'")) + #expect(!result.stderr.contains("private")) + #expect(!result.stderr.contains("-token")) + #expect(result.stderr.contains("*****")) + } + + @Test func testControlCharactersRoundTripThroughCommandEchoes() throws { + let values = ["tab\tvalue", "carriage\rreturn", "escape\u{1b}value", "delete\u{7f}", "quote'\nand\\slash"] + try configure(["main": function([printStep("${value}")], parameters: ["value"])]) + for value in values { + let result = try runCM(["main", value]) + assertSuccess(result, output: value + "\n") + let prefix = "\u{1B}[90m❯ \u{1B}[32m" + let start = try #require(result.stdout.range(of: prefix, options: .anchored)) + let end = try #require(result.stdout.range(of: "\u{1B}[0m\n")) + let echo = String(result.stdout[start.upperBound..<${second}>"]]], parameters: ["first", "second"], + entry: false), + ]) + let result = try runCM(["main"]) + if succeeds { + assertSuccess(result, output: "<>\n") + } else { + assertFailure(result) + #expect(result.stderr.contains("expects 2 argument(s)")) + #expect(result.outputWithoutEcho.isEmpty) + } + } + } + + @Test func testGitCleanIgnoresIgnoredFilesButRejectsTrackedEdits() throws { + let repository = directory.appendingPathComponent("repository") + try FileManager.default.createDirectory(at: repository, withIntermediateDirectories: true) + try initializeRepository(cwd: repository) + try Data("ignored\n".utf8).write(to: repository.appendingPathComponent(".gitignore")) + let tracked = repository.appendingPathComponent("tracked") + try Data("original".utf8).write(to: tracked) + try git(["add", "."], cwd: repository) + try git(["-c", "commit.gpgsign=false", "commit", "-m", "Track fixtures"], cwd: repository) + try Data("ignored".utf8).write(to: repository.appendingPathComponent("ignored")) + try configure(["main": function([["builtin": "assertGitClean"], printStep("clean")])]) + assertSuccess(try runCM(["main"], cwd: repository), output: "clean\n") + try Data("modified".utf8).write(to: tracked) + let result = try runCM(["main"], cwd: repository) + assertFailure(result) + #expect(result.stderr.contains("Git working tree has local changes")) + #expect(result.outputWithoutEcho.isEmpty) + } + + private func expectFailure(_ message: String, status: Int32 = 1, sourceLocation: SourceLocation = #_sourceLocation) + throws + { + let result = try runCM(["main"]) + #expect(result.status == status, "\(result.stderr)", sourceLocation: sourceLocation) + #expect(result.stderr.contains(message), "\(result.stderr)", sourceLocation: sourceLocation) + #expect(!FileManager.default.fileExists(atPath: marker.path), sourceLocation: sourceLocation) + } + + private func assertCaptureCleanup( + command: String, arguments: [String], mode: String, status: Int32, message: String? = nil + ) throws { + let temporary = directory.appendingPathComponent("capture-temp") + try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) + var environment = ProcessInfo.processInfo.environment + environment["TMPDIR"] = temporary.path + "/" + try configure([ + "main": function([ + ["command": command, "args": arguments, "capture": mode, "saveAs": "result"], + ["builtin": "log", "args": ["${result}"]], + ]) + ]) + let result = try runCM(["main"], environment: environment) + #expect(result.status == status, "\(result.stderr)") + if let message { #expect(result.stderr.contains(message)) } + #expect(result.outputWithoutEcho == (status == 0 ? "hello\n" : "")) + #expect(try FileManager.default.contentsOfDirectory(atPath: temporary.path).isEmpty) + } +} From 9f1eee08a5518eed70de704c91f8f64266961bb7 Mon Sep 17 00:00:00 2001 From: b4prog Date: Sat, 26 Sep 2026 05:45:27 +0200 Subject: [PATCH 03/10] [refactor] organize cm sources into responsibility-based folders --- README.md | 22 +++++++++---------- Sources/cm/{ => CLI}/CLI.swift | 0 Sources/cm/{ => CLI}/Output.swift | 0 .../{ => Configuration}/Configuration.swift | 0 .../{ => Configuration}/ConfigurationIO.swift | 0 Sources/cm/{ => Execution}/Builtins.swift | 0 .../cm/{ => Execution}/CommandExecution.swift | 0 .../cm/{ => Execution}/ProcessExecution.swift | 0 Sources/cm/{ => Execution}/Runner.swift | 0 Sources/cm/{ => Workflow}/RuntimeValue.swift | 0 Sources/cm/{ => Workflow}/Workflow.swift | 0 11 files changed, 11 insertions(+), 11 deletions(-) rename Sources/cm/{ => CLI}/CLI.swift (100%) rename Sources/cm/{ => CLI}/Output.swift (100%) rename Sources/cm/{ => Configuration}/Configuration.swift (100%) rename Sources/cm/{ => Configuration}/ConfigurationIO.swift (100%) rename Sources/cm/{ => Execution}/Builtins.swift (100%) rename Sources/cm/{ => Execution}/CommandExecution.swift (100%) rename Sources/cm/{ => Execution}/ProcessExecution.swift (100%) rename Sources/cm/{ => Execution}/Runner.swift (100%) rename Sources/cm/{ => Workflow}/RuntimeValue.swift (100%) rename Sources/cm/{ => Workflow}/Workflow.swift (100%) diff --git a/README.md b/README.md index cb45cae..312ca1a 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,7 @@ Directory changes are scoped to the function that makes them and its nested call ## Add a built-in in Swift -Configured functions require no Swift changes. To add a new state-aware built-in, edit `Sources/cm/Builtins.swift`: +Configured functions require no Swift changes. To add a new state-aware built-in, edit `Sources/cm/Execution/Builtins.swift`: 1. Add the operation to the `Builtin` enum and update `argumentCount` and `returnsValue`. 2. Add its execution case in `BuiltinExecutor.execute`. @@ -424,16 +424,16 @@ The executable target lives in `Sources/cm/`. Each file owns a specific responsi | File | Responsibility | | --- | --- | | `main.swift` | Start the CLI and translate failures into exit statuses. | -| `CLI.swift` | Parse CLI options, display help, and invoke the entry point. | -| `Configuration.swift` | Define functions/settings and validate the configuration and call graph. | -| `ConfigurationIO.swift` | Locate and decode configuration files with strict JSON diagnostics. | -| `Workflow.swift` | Decode steps, conditions, and argument expansions. | -| `RuntimeValue.swift` | Store structured results and render argument templates. | -| `Runner.swift` | Execute function sequences with scoped variables, directories, and environment. | -| `Builtins.swift` | Define and execute builtin operations, including filesystem and Git checks. | -| `CommandExecution.swift` | Execute configured commands, capture output, and check exit statuses. | -| `ProcessExecution.swift` | Resolve executables and launch/wait for processes with terminal and signal handling. | -| `Output.swift` | Format command echoes and redact sensitive values. | +| `CLI/CLI.swift` | Parse CLI options, display help, and invoke the entry point. | +| `Configuration/Configuration.swift` | Define functions/settings and validate the configuration and call graph. | +| `Configuration/ConfigurationIO.swift` | Locate and decode configuration files with strict JSON diagnostics. | +| `Workflow/Workflow.swift` | Decode steps, conditions, and argument expansions. | +| `Workflow/RuntimeValue.swift` | Store structured results and render argument templates. | +| `Execution/Runner.swift` | Execute function sequences with scoped variables, directories, and environment. | +| `Execution/Builtins.swift` | Define and execute builtin operations, including filesystem and Git checks. | +| `Execution/CommandExecution.swift` | Execute configured commands, capture output, and check exit statuses. | +| `Execution/ProcessExecution.swift` | Resolve executables and launch/wait for processes with terminal and signal handling. | +| `CLI/Output.swift` | Format command echoes and redact sensitive values. | | `CommandError.swift`, `Version.swift` | Shared errors, argument-count checks, and version compatibility. | The runner delegates builtin and external-command execution to separate executors. Their implementation helpers stay private to their files. All files compile into one executable; no source files or plugins are loaded at runtime. diff --git a/Sources/cm/CLI.swift b/Sources/cm/CLI/CLI.swift similarity index 100% rename from Sources/cm/CLI.swift rename to Sources/cm/CLI/CLI.swift diff --git a/Sources/cm/Output.swift b/Sources/cm/CLI/Output.swift similarity index 100% rename from Sources/cm/Output.swift rename to Sources/cm/CLI/Output.swift diff --git a/Sources/cm/Configuration.swift b/Sources/cm/Configuration/Configuration.swift similarity index 100% rename from Sources/cm/Configuration.swift rename to Sources/cm/Configuration/Configuration.swift diff --git a/Sources/cm/ConfigurationIO.swift b/Sources/cm/Configuration/ConfigurationIO.swift similarity index 100% rename from Sources/cm/ConfigurationIO.swift rename to Sources/cm/Configuration/ConfigurationIO.swift diff --git a/Sources/cm/Builtins.swift b/Sources/cm/Execution/Builtins.swift similarity index 100% rename from Sources/cm/Builtins.swift rename to Sources/cm/Execution/Builtins.swift diff --git a/Sources/cm/CommandExecution.swift b/Sources/cm/Execution/CommandExecution.swift similarity index 100% rename from Sources/cm/CommandExecution.swift rename to Sources/cm/Execution/CommandExecution.swift diff --git a/Sources/cm/ProcessExecution.swift b/Sources/cm/Execution/ProcessExecution.swift similarity index 100% rename from Sources/cm/ProcessExecution.swift rename to Sources/cm/Execution/ProcessExecution.swift diff --git a/Sources/cm/Runner.swift b/Sources/cm/Execution/Runner.swift similarity index 100% rename from Sources/cm/Runner.swift rename to Sources/cm/Execution/Runner.swift diff --git a/Sources/cm/RuntimeValue.swift b/Sources/cm/Workflow/RuntimeValue.swift similarity index 100% rename from Sources/cm/RuntimeValue.swift rename to Sources/cm/Workflow/RuntimeValue.swift diff --git a/Sources/cm/Workflow.swift b/Sources/cm/Workflow/Workflow.swift similarity index 100% rename from Sources/cm/Workflow.swift rename to Sources/cm/Workflow/Workflow.swift From f172453dfe270a692deffb7ee5a30c5792a79067 Mon Sep 17 00:00:00 2001 From: b4prog Date: Sat, 26 Sep 2026 05:51:56 +0200 Subject: [PATCH 04/10] [chore] remove make complexity target and related documentation --- Makefile | 6 +----- README.md | 7 ++----- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 49dd057..e48c404 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,8 @@ PREFIX ?= $(HOME)/.local SWIFT ?= swift SWIFT_TEST_FLAGS ?= SWIFT_FORMAT ?= xcrun swift-format -CODEM8 ?= codem8 -.PHONY: build install uninstall test format lint check complexity +.PHONY: build install uninstall test format lint check build: $(SWIFT) build --configuration release --product cm @@ -26,6 +25,3 @@ lint: $(SWIFT_FORMAT) lint --strict --recursive Sources Package.swift Tests/CommandManagerTests check: lint test - -complexity: - $(CODEM8) --report-complexity -git-branch diff --git a/README.md b/README.md index 312ca1a..83ef449 100644 --- a/README.md +++ b/README.md @@ -452,18 +452,15 @@ The equivalent `make test` target and other development checks are: make test make format make check -make complexity ``` `swift test --disable-xctest` builds the `cm` executable as a test dependency and runs integration tests, including a smoke test that runs a copy of the executable outside the source tree. Tests use temporary configurations and working directories, so they do not need to edit your personal configuration. XCTest and third-party test dependencies are not needed. `Package.swift` uses Swift tools version 6.0. `swift build` builds the debug executable; `make build` builds the release executable. `make install` builds and installs the release executable, preserving existing configuration. Reinstall after changing source files. -`make format` formats `Sources/`, `Package.swift`, and the Swift tests with `xcrun swift-format`. `make check` checks their formatting and runs the tests. `make complexity` runs `codem8 --report-complexity -git-branch` and requires the separate `codem8` tool. +`make format` formats `Sources/`, `Package.swift`, and the Swift tests with `xcrun swift-format`. `make check` checks their formatting and runs the tests. -The installed `codem8` version does not support Swift. The required complexity command therefore analyzes zero source files in this all-Swift project; it does not validate the complexity of the implementation or tests. - -Keep function bodies free of empty lines, and run the branch complexity report after changing code. +Keep function bodies free of empty lines. To remove the default installation: From 4b5286c9e0fe363b270b9904cbbb0f2bfa97ea39 Mon Sep 17 00:00:00 2001 From: b4prog Date: Sat, 26 Sep 2026 06:00:43 +0200 Subject: [PATCH 05/10] [docs] clarify CommandManager's purpose and workflow capabilities --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 83ef449..b45543b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # CommandManager -CommandManager is a small command runner written in Swift. Describe reusable functions in a JSON file, then run an entry point with `cm MyFunction`. Each function runs its steps in order and stops as soon as a command, another function, or a built-in operation fails. +CommandManager turns recurring command-line tasks into reusable workflows you can run with a single command. Use it to automate routines such as building and testing a project, checking a Git repository, or syncing and generating assets. -A step can run an executable, call another function from the configuration, or call a special function implemented in Swift. Commands use separate executable and argument fields, so arguments containing spaces stay intact. +Define your workflows as named functions in a JSON configuration, then run an entry point with `cm MyFunction`. Functions combine commands, reusable helper functions, and built-in operations into an ordered sequence of steps. Parameters, shared settings, conditional steps, and captured command output let you adapt a workflow to different inputs without duplicating its definition. + +CommandManager is written in Swift and runs on macOS. It handles the details of passing arguments, managing working directories and environment variables, and showing which commands are running. If a step fails, the workflow stops so later steps do not run on an unsuccessful result. ## Requirements From 74b3a5583b5bd01b71e167ae33587e7cac4f215e Mon Sep 17 00:00:00 2001 From: b4prog Date: Sat, 26 Sep 2026 06:07:22 +0200 Subject: [PATCH 06/10] [docs] remove feature introduction version references from README --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b45543b..98f1219 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ Use standard JSON: comments and trailing commas are not supported. ## Step types -Every step has exactly one of `command`, `function`, or `builtin`, plus an optional `args` array. Arguments are strings or explicit array expansions for commands and function calls. Omitted `args` means `[]`. Version 0.4 also supports `when`, `saveAs`, `capture`, `sensitive`, and `label` as described below. +Every step has exactly one of `command`, `function`, or `builtin`, plus an optional `args` array. Arguments are strings or explicit array expansions for commands and function calls. Omitted `args` means `[]`. Steps also support `when`, `saveAs`, `capture`, `sensitive`, and `label` as described below. ### Run a command @@ -257,9 +257,7 @@ For a function with a `package` parameter, these arguments contain the package v Substitution applies only to `args`, not to executable names, function names, built-in names, or descriptions. Values remain single arguments even when they contain spaces. Substituted values are not expanded again, and there is no implicit environment-variable expansion. -## Workflow values and conditions (0.4) - -Configurations using these features should set `"minimumVersion": "0.4"`. +## Workflow values and conditions Declare boolean options as a name-to-description object on a function: @@ -372,7 +370,7 @@ Both assertions ignore `GIT_*` environment overrides for their internal checks, Sets an environment variable for the remaining steps of the current entry point, including called functions. Subsequent command steps inherit it and run directly without a shell. When the entry point finishes, CommandManager restores the variable's previous value or removes it if it was previously absent. The variable name must use `[A-Za-z_][A-Za-z0-9_]*`. This changes CommandManager's execution environment only; it cannot modify the terminal process that launched `cm`. -### Workflow builtins (0.4) +### Workflow builtins | Builtin | Arguments | Result / behavior | | --- | --- | --- | From 64d4bb1b25b81a832ed8991287dfd1386ef35b1c Mon Sep 17 00:00:00 2001 From: b4prog Date: Sat, 26 Sep 2026 06:33:35 +0200 Subject: [PATCH 07/10] [refactor] separate public entryPoints from internal functions in configuration Update validation, execution, help, tests, and documentation. Migrate the standard example to alphabetically sorted sections. --- README.md | 77 +++++++--- Sources/cm/CLI/CLI.swift | 8 +- Sources/cm/Configuration/Configuration.swift | 38 +++-- Sources/cm/Execution/Runner.swift | 4 +- .../CommandExecutionTests.swift | 82 +++++------ .../ConfigurationTests.swift | 46 +++--- .../CoverageRegressionTests.swift | 49 +++--- .../DirectoryAndGitTests.swift | 34 +++-- .../CommandManagerTests/EntryPointTests.swift | 99 +++++++++++++ Tests/CommandManagerTests/Support.swift | 17 ++- Tests/CommandManagerTests/VersionTests.swift | 2 +- Tests/CommandManagerTests/WorkflowTests.swift | 66 +++++---- examples/cm.json | 139 +++++++++++------- 13 files changed, 419 insertions(+), 242 deletions(-) create mode 100644 Tests/CommandManagerTests/EntryPointTests.swift diff --git a/README.md b/README.md index 98f1219..35a2508 100644 --- a/README.md +++ b/README.md @@ -66,16 +66,17 @@ The optional root-level `minimumVersion` field specifies the oldest compatible C ```json { - "minimumVersion": "0.3", + "minimumVersion": "0.4", + "entryPoints": {}, "functions": {} } ``` -Use a string in `major.minor` or `major.minor.patch` form, with nonnegative integer components. Versions are compared numerically: `0.10` is newer than `0.2`, and `0.2` equals `0.2.0`. Prerelease and build suffixes are not supported. If the requirement exceeds the running version, `cm` reports the required and installed versions and exits before executing any commands, including when help is requested. Omitting the field keeps existing configurations valid; an explicit `null` or a malformed version is an error. +Use a string in `major.minor` or `major.minor.patch` form, with nonnegative integer components. Versions are compared numerically: `0.10` is newer than `0.2`, and `0.2` equals `0.2.0`. Prerelease and build suffixes are not supported. If the requirement exceeds the running version, `cm` reports the required and installed versions and exits before executing any commands, including when help is requested. Omitting the field skips the version requirement; an explicit `null` or a malformed version is an error. ## Quick start -The example configuration defines three entry points: +The example configuration defines four entry points: ```sh cm @@ -115,43 +116,56 @@ Names are case sensitive. Functions accept exactly the number of positional argu cm Hello "Bruno Smith" ``` -## Define functions and settings +## Define entry points, functions, and settings -The root object contains a `functions` object and can contain a `settings` array. Settings are named string values shared by the configuration, but a function must declare the settings it uses: +The root object separates public commands in `entryPoints` from internal helpers in `functions`, and can contain a `settings` array. Both definition sections are optional and default to empty objects. Keep `entryPoints` before `functions` and sort names alphabetically within each section for readability; JSON field order does not affect execution. Settings are named string values shared by the configuration, but a function must declare the settings it uses: ```json { "settings": [ - { "name": "FIGMA_TOKEN", "value": "replace-with-your-token" } + { + "name": "FIGMA_TOKEN", + "value": "replace-with-your-token" + } ], - "functions": { + "entryPoints": { "icons-sync": { "description": "Sync Figma icons and generate Unify icons.", - "entryPoint": true, - "settings": ["FIGMA_TOKEN"], + "settings": [ + "FIGMA_TOKEN" + ], "steps": [ { "builtin": "export", - "args": ["FIGMA_TOKEN", "${FIGMA_TOKEN}"] + "args": [ + "FIGMA_TOKEN", + "${FIGMA_TOKEN}" + ] }, { "command": "node", - "args": ["scripts/sync-figma-icons.mjs"] + "args": [ + "scripts/sync-figma-icons.mjs" + ] }, { "command": "npx", - "args": ["nx", "run", "unify:generate-unify-icons"] + "args": [ + "nx", + "run", + "unify:generate-unify-icons" + ] } ] } - } + }, + "functions": {} } ``` | Field | Required | Meaning | | --- | --- | --- | | `description` | Yes | A nonempty description displayed in help. | -| `entryPoint` | No | `true` allows direct CLI invocation. Defaults to `false`. | | `parameters` | No | Ordered names of required positional arguments. Defaults to `[]`. | | `settings` | No | Names of root-level settings available to this function. Defaults to `[]`. | | `steps` | Yes | Steps to run in order. | @@ -162,7 +176,9 @@ Function names allow letters, digits, underscores, and hyphens, starting with a The `icons-sync` example exports `FIGMA_TOKEN` and then runs the icon synchronization and generation commands as separate steps, without invoking a shell. Settings are substituted exactly like parameters, but only in a function that lists them. Called functions declare their own settings; settings are not inherited from their caller. Store configuration files containing secrets with appropriate filesystem permissions. -An entry point can call other entry points or internal functions. A function without `"entryPoint": true` is internal: it cannot be invoked directly with `cm` and is omitted from the entry point list. This separates the public commands you use from the helpers they share. +Definitions in `entryPoints` are available through `cm` and appear in help. Definitions in `functions` are internal: they cannot be invoked directly or requested through function help. Both sections use the fields above, and a `function` step can call a definition in either section. Names must be unique across both sections; duplicate names are rejected before execution. + +To migrate an older configuration, move definitions with `"entryPoint": true` into `entryPoints`, leave internal helpers in `functions`, and remove every `entryPoint` field. The old field is rejected with migration guidance. The examples require version `0.4` and use the new layout. Use standard JSON: comments and trailing commas are not supported. @@ -211,19 +227,34 @@ To call a helper with required arguments: ```json { - "functions": { + "entryPoints": { "BuildRelease": { "description": "Build a Swift package in release mode.", - "entryPoint": true, "steps": [ - { "function": "Build", "args": ["release"] } + { + "function": "Build", + "args": [ + "release" + ] + } ] - }, + } + }, + "functions": { "Build": { "description": "Build using the requested configuration.", - "parameters": ["configuration"], + "parameters": [ + "configuration" + ], "steps": [ - { "command": "swift", "args": ["build", "--configuration", "${configuration}"] } + { + "command": "swift", + "args": [ + "build", + "--configuration", + "${configuration}" + ] + } ] } } @@ -399,7 +430,7 @@ All value-producing builtins require `saveAs`; other builtins reject it. Paths r ## Validation and failures -CommandManager validates the whole configuration before running any step, including functions that are not entry points. It rejects unknown fields, invalid names and types, explicit `null` values, duplicate or unknown settings, settings that were not declared by the function using them, unknown function or built-in references, incorrect argument counts, unknown parameter references, and recursive call cycles. Executable names, argument strings, and setting values must not contain NUL characters. Direct recursion and cycles involving several functions are not supported. +CommandManager validates both `entryPoints` and `functions` before running any step, including unused definitions. It rejects unknown fields, invalid names and types, explicit `null` values, duplicate or unknown settings, settings that were not declared by the function using them, unknown function or built-in references, incorrect argument counts, unknown parameter references, and recursive call cycles. Executable names, argument strings, and setting values must not contain NUL characters. Direct recursion and cycles involving several functions are not supported. Every step must succeed before the next begins. A command with a nonzero exit status aborts the current function and every caller; later steps do not run. CommandManager preserves the failing command's exit status. Configuration errors and built-in failures also exit unsuccessfully with a diagnostic. @@ -425,7 +456,7 @@ The executable target lives in `Sources/cm/`. Each file owns a specific responsi | --- | --- | | `main.swift` | Start the CLI and translate failures into exit statuses. | | `CLI/CLI.swift` | Parse CLI options, display help, and invoke the entry point. | -| `Configuration/Configuration.swift` | Define functions/settings and validate the configuration and call graph. | +| `Configuration/Configuration.swift` | Define entry points, functions, and settings; validate names and the combined call graph. | | `Configuration/ConfigurationIO.swift` | Locate and decode configuration files with strict JSON diagnostics. | | `Workflow/Workflow.swift` | Decode steps, conditions, and argument expansions. | | `Workflow/RuntimeValue.swift` | Store structured results and render argument templates. | diff --git a/Sources/cm/CLI/CLI.swift b/Sources/cm/CLI/CLI.swift index 5116a0b..67fafa0 100644 --- a/Sources/cm/CLI/CLI.swift +++ b/Sources/cm/CLI/CLI.swift @@ -39,12 +39,12 @@ func printHelp(_ configuration: Configuration?, path: URL) { Entry points: """) - let entries = configuration?.functions.filter { $0.value.entryPoint } ?? [:] + 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. Set entryPoint to true to expose a function.") } + if entries.isEmpty { print(" No entry points configured. Add public commands to the entryPoints section.") } print("\nUse cm --help for function help. Options precede the function name.") } @@ -68,10 +68,10 @@ func main(_ arguments: [String]) throws { printHelp(configuration, path: path) return } - guard let function = configuration.functions[name] else { + guard let function = configuration.definitions[name] else { throw CommandError("Unknown function '\(name)'. Run cm to list entry points.") } - guard function.entryPoint else { + guard configuration.entryPoints[name] != nil else { throw CommandError("Function '\(name)' is internal; only entry points can be run directly.") } if options.help { diff --git a/Sources/cm/Configuration/Configuration.swift b/Sources/cm/Configuration/Configuration.swift index 7bbff96..4b51b1b 100644 --- a/Sources/cm/Configuration/Configuration.swift +++ b/Sources/cm/Configuration/Configuration.swift @@ -10,7 +10,6 @@ func isFunctionName(_ value: String) -> Bool { struct FunctionDefinition: Decodable { let description: String - let entryPoint: Bool let parameters: [String] let settings: [String] let steps: [Step] @@ -18,16 +17,21 @@ struct FunctionDefinition: Decodable { let requireAnyOption: Bool enum CodingKeys: String, CodingKey { - case description, entryPoint, parameters, settings, steps, options, requireAnyOption + case description, parameters, settings, steps, options, requireAnyOption } init(from decoder: Decoder) throws { + let keys = try decoder.container(keyedBy: JSONKey.self) + if keys.allKeys.contains(where: { $0.stringValue == "entryPoint" }) { + throw CommandError( + "The entryPoint field is no longer supported. Move public definitions into entryPoints and keep internal helpers in functions." + ) + } try rejectUnknownKeys( decoder, - allowed: ["description", "entryPoint", "parameters", "settings", "steps", "options", "requireAnyOption"]) + allowed: ["description", "parameters", "settings", "steps", "options", "requireAnyOption"]) let container = try decoder.container(keyedBy: CodingKeys.self) description = try container.decode(String.self, forKey: .description) - entryPoint = try container.decodeIfDefined(Bool.self, forKey: .entryPoint) ?? false parameters = try container.decodeIfDefined([String].self, forKey: .parameters) ?? [] settings = try container.decodeIfDefined([String].self, forKey: .settings) ?? [] steps = try container.decode([Step].self, forKey: .steps) @@ -58,29 +62,39 @@ struct SettingDefinition: Decodable { struct Configuration: Decodable { let settings: [SettingDefinition] + let entryPoints: [String: FunctionDefinition] let functions: [String: FunctionDefinition] + let definitions: [String: FunctionDefinition] enum CodingKeys: String, CodingKey { - case minimumVersion, settings, functions + case minimumVersion, settings, entryPoints, functions } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) try validateMinimumVersion(container.decodeIfDefined(String.self, forKey: .minimumVersion)) - try rejectUnknownKeys(decoder, allowed: ["minimumVersion", "settings", "functions"]) + try rejectUnknownKeys(decoder, allowed: ["minimumVersion", "settings", "entryPoints", "functions"]) settings = try container.decodeIfDefined([SettingDefinition].self, forKey: .settings) ?? [] - functions = try container.decode([String: FunctionDefinition].self, forKey: .functions) + entryPoints = try container.decodeIfDefined([String: FunctionDefinition].self, forKey: .entryPoints) ?? [:] + functions = try container.decodeIfDefined([String: FunctionDefinition].self, forKey: .functions) ?? [:] + let duplicates = Set(entryPoints.keys).intersection(functions.keys).sorted() + guard duplicates.isEmpty else { + throw CommandError( + "Names must be unique across entryPoints and functions; duplicates: \(duplicates.joined(separator: ", "))." + ) + } + definitions = entryPoints.merging(functions, uniquingKeysWith: { entry, _ in entry }) } func validate() throws { try validateSettings() - for name in functions.keys.sorted() { - guard let function = functions[name] else { continue } + for name in definitions.keys.sorted() { + guard let function = definitions[name] else { continue } try validateDefinition(name, function: function) try validateSteps(name, function: function) } var visited = Set() - for name in functions.keys.sorted() { + for name in definitions.keys.sorted() { try validateCycles(name, path: [], visited: &visited) } } @@ -170,7 +184,7 @@ struct Configuration: Decodable { throw CommandError("Command capture and saveAs must be specified together.") } case .function(let name): - guard let function = functions[name] else { throw CommandError("Unknown function '\(name)'.") } + guard let function = definitions[name] else { throw CommandError("Unknown function '\(name)'.") } guard step.capture == nil, step.saveAs == nil else { throw CommandError("Function calls cannot capture output or use saveAs.") } @@ -211,7 +225,7 @@ struct Configuration: Decodable { guard !path.contains(name) else { throw CommandError("Function call cycle: \((path + [name]).joined(separator: " -> ")).") } - guard !visited.contains(name), let function = functions[name] else { return } + guard !visited.contains(name), let function = definitions[name] else { return } for step in function.steps { if case .function(let callee) = step.target { try validateCycles(callee, path: path + [name], visited: &visited) diff --git a/Sources/cm/Execution/Runner.swift b/Sources/cm/Execution/Runner.swift index 04cc878..d90a977 100644 --- a/Sources/cm/Execution/Runner.swift +++ b/Sources/cm/Execution/Runner.swift @@ -10,7 +10,7 @@ struct Runner { let initialEnvironment = environment defer { environment = initialEnvironment } var secrets = Set(configuration.settings.map(\.value).filter { !$0.isEmpty }) - guard let function = configuration.functions[name] else { throw CommandError("Unknown function '\(name)'.") } + guard let function = configuration.entryPoints[name] else { throw CommandError("Unknown function '\(name)'.") } let bindings = try bindArguments(arguments, function: function) if function.requireAnyOption && !function.options.keys.contains(where: { bindings[$0] == "true" }) { printFunctionHelp(name, function: function) @@ -50,7 +50,7 @@ struct Runner { _ name: String, arguments: [String], directory: inout URL, environment: inout [String: String], secrets: inout Set ) throws { - guard let function = configuration.functions[name] else { throw CommandError("Unknown function '\(name)'.") } + guard let function = configuration.definitions[name] else { throw CommandError("Unknown function '\(name)'.") } var functionDirectory = directory var values = try bindArguments(arguments, function: function) .merging(settingValues(for: function), uniquingKeysWith: { _, setting in setting }).mapValues( diff --git a/Tests/CommandManagerTests/CommandExecutionTests.swift b/Tests/CommandManagerTests/CommandExecutionTests.swift index 21c2add..5f2113e 100644 --- a/Tests/CommandManagerTests/CommandExecutionTests.swift +++ b/Tests/CommandManagerTests/CommandExecutionTests.swift @@ -3,11 +3,11 @@ import Testing final class CommandExecutionTests: CMTestCase { @Test func testHelpListsSortedEntryPointsAndHidesHelpers() throws { - try configure([ - "Zulu": function([], description: "Last public function"), - "hiddenHelper": function([], entry: false, description: "Secret helper"), - "Alpha": function([], parameters: ["name"], description: "First public function"), - ]) + try configure( + [ + "Zulu": function([], description: "Last public function"), + "Alpha": function([], parameters: ["name"], description: "First public function"), + ], functions: ["hiddenHelper": function([], description: "Secret helper")]) let invocations: [[String]] = [[], ["--help"], ["-h"]] for arguments in invocations { let result = try runCM(arguments) @@ -54,15 +54,15 @@ final class CommandExecutionTests: CMTestCase { } @Test func testUnknownFunctionAndInternalFunctionCannotRun() throws { - try configure(["helper": function([markerStep()], entry: false)]) + try configure([:], functions: ["helper": function([markerStep()])]) for name in ["unknown", "helper"] { assertFailure(try runCM([name])) #expect(!(FileManager.default.fileExists(atPath: marker.path))) } } - @Test func testOmittedEntryPointDefaultsToInternal() throws { - try configure(["helper": ["description": "An internal function", "steps": []]]) + @Test func testFunctionsSectionIsInternal() throws { + try configure(functions: ["helper": ["description": "An internal function", "steps": []]]) let result = try runCM() assertSuccess(result) #expect(!(result.stdout.contains("helper"))) @@ -118,10 +118,9 @@ final class CommandExecutionTests: CMTestCase { @Test func testCalledFunctionsUseTheirOwnDeclaredSettings() throws { try configure( - [ - "main": function([["function": "helper"]]), - "helper": function([printStep("${FIGMA_TOKEN}")], settings: ["FIGMA_TOKEN"], entry: false), - ], settings: [["name": "FIGMA_TOKEN", "value": "secret-token"]]) + ["main": function([["function": "helper"]])], + functions: ["helper": function([printStep("${FIGMA_TOKEN}")], settings: ["FIGMA_TOKEN"])], + settings: [["name": "FIGMA_TOKEN", "value": "secret-token"]]) assertSuccess(try runCM(["main"]), output: "secret-token\n") } @@ -141,46 +140,46 @@ final class CommandExecutionTests: CMTestCase { } @Test func testEachNestedCommandIsEchoedOnce() throws { - try configure([ - "main": function([printStep("first"), ["function": "helper"]]), - "helper": function([printStep("second")], entry: false), - ]) + try configure( + ["main": function([printStep("first"), ["function": "helper"]])], + functions: ["helper": function([printStep("second")])]) let result = try runCM(["main"]) assertSuccess(result, output: "first\nsecond\n") #expect(result.stdout.components(separatedBy: "\u{1B}[90m❯ \u{1B}[32m").count - 1 == 2) } @Test func testNestedFunctionsReceiveTheirOwnArguments() throws { - try configure([ - "main": function( - [["function": "helper", "args": ["${outer}"]], printStep("${outer}")], - parameters: ["outer"] - ), - "helper": function([printStep("nested ${inner}")], parameters: ["inner"], entry: false), - ]) + try configure( + [ + "main": function( + [["function": "helper", "args": ["${outer}"]], printStep("${outer}")], + parameters: ["outer"] + ) + ], functions: ["helper": function([printStep("nested ${inner}")], parameters: ["inner"])]) assertSuccess(try runCM(["main", "value"]), output: "nested value\nvalue\n") } @Test func testStepsRunInOrderAndAllowOmittedArguments() throws { - try configure([ - "main": function([ - ["command": "/usr/bin/true"], - printStep("one"), - ["function": "helper"], - printStep("three"), - ]), - "helper": ["description": "Helper", "steps": [printStep("two")]], - ]) + try configure( + [ + "main": function([ + ["command": "/usr/bin/true"], + printStep("one"), + ["function": "helper"], + printStep("three"), + ]) + ], functions: ["helper": ["description": "Helper", "steps": [printStep("two")]]]) assertSuccess(try runCM(["main"]), output: "one\ntwo\nthree\n") } @Test func testFailedCommandPreservesStatusAndStopsCallers() throws { - try configure([ - "main": function([["function": "helper"], markerStep()]), - "helper": function( - [["command": "/bin/sh", "args": ["-c", "exit 23"]], markerStep()], entry: false - ), - ]) + try configure( + ["main": function([["function": "helper"], markerStep()])], + functions: [ + "helper": function( + [["command": "/bin/sh", "args": ["-c", "exit 23"]], markerStep()] + ) + ]) let result = try runCM(["main"]) #expect(result.status == 23) #expect(result.stdout.components(separatedBy: "\u{1B}[90m❯ \u{1B}[32m").count - 1 == 1) @@ -246,10 +245,9 @@ final class CommandExecutionTests: CMTestCase { } @Test func testHyphenatedEntryPointsAndHelperNames() throws { - try configure([ - "brew-update": function([["function": "print-message", "args": ["${value}"]]], parameters: ["value"]), - "print-message": function([printStep("${message}")], parameters: ["message"], entry: false), - ]) + try configure( + ["brew-update": function([["function": "print-message", "args": ["${value}"]]], parameters: ["value"])], + functions: ["print-message": function([printStep("${message}")], parameters: ["message"])]) let help = try runCM() assertSuccess(help) #expect(help.stdout.contains("brew-update ")) diff --git a/Tests/CommandManagerTests/ConfigurationTests.swift b/Tests/CommandManagerTests/ConfigurationTests.swift index 5af8c2a..f26d144 100644 --- a/Tests/CommandManagerTests/ConfigurationTests.swift +++ b/Tests/CommandManagerTests/ConfigurationTests.swift @@ -8,9 +8,9 @@ final class ConfigurationTests: CMTestCase { var invalidStep = markerStep() invalidStep["unexpected"] = true let variants: [[String: Any]] = [ - ["functions": ["main": function([markerStep()])], "unexpected": true], - ["functions": ["main": invalidFunction]], - ["functions": ["main": function([invalidStep])]], + ["entryPoints": ["main": function([markerStep()])], "unexpected": true], + ["entryPoints": ["main": invalidFunction]], + ["entryPoints": ["main": function([invalidStep])]], ] for variant in variants { try JSONSerialization.data(withJSONObject: variant).write(to: config) @@ -50,18 +50,14 @@ final class ConfigurationTests: CMTestCase { ["builtin": "export", "args": ["VARIABLE"]], ] for step in invalidSteps { - try assertInvalidConfiguration([ - "main": function([markerStep(), step]), - "helper": function([], parameters: ["name"], entry: false), - ]) + try assertInvalidConfiguration( + ["main": function([markerStep(), step])], functions: ["helper": function([], parameters: ["name"])]) } } @Test func testUnusedFunctionsAreValidatedBeforeCommandsRun() throws { - try assertInvalidConfiguration([ - "main": function([markerStep()]), - "unused": function([printStep("${unknown}")], entry: false), - ]) + try assertInvalidConfiguration( + ["main": function([markerStep()])], functions: ["unused": function([printStep("${unknown}")])]) } @Test func testInvalidAndDuplicateParameterNamesAreRejected() throws { @@ -100,30 +96,26 @@ final class ConfigurationTests: CMTestCase { @Test func testInvalidFunctionNamesAndEmptyDescriptionsAreRejected() throws { for name in ["", "two words", "--option", "1number", "name\n"] { - try assertInvalidConfiguration([ - "main": function([markerStep()]), - name: function([], entry: false), - ]) + try assertInvalidConfiguration(["main": function([markerStep()])], functions: [name: function([])]) } try assertInvalidConfiguration(["main": function([markerStep()], description: " \n\t")]) } @Test func testDirectAndIndirectCyclesAreRejectedBeforeExecution() throws { try assertInvalidConfiguration(["main": function([markerStep(), ["function": "main"]])]) - try assertInvalidConfiguration([ - "main": function([markerStep(), ["function": "helper"]]), - "helper": function([["function": "main"]], entry: false), - ]) + try assertInvalidConfiguration( + ["main": function([markerStep(), ["function": "helper"]])], + functions: ["helper": function([["function": "main"]])]) } @Test func testInvalidJSONAndFieldTypesFail() throws { let documents = [ "{", "[]", - #"{"functions": []}"#, - #"{"functions": {"main": {"description": 3, "steps": []}}}"#, - #"{"functions": {"main": {"description": "Missing steps"}}}"#, - #"{"functions": {"main": {"description": "Invalid flag", "entryPoint": "yes", "steps": []}}}"#, + #"{"entryPoints": []}"#, + #"{"entryPoints": {"main": {"description": 3, "steps": []}}}"#, + #"{"entryPoints": {"main": {"description": "Missing steps"}}}"#, + #"{"entryPoints": {"main": {"description": "Invalid flag", "entryPoint": "yes", "steps": []}}}"#, ] for document in documents { try document.write(to: config, atomically: true, encoding: .utf8) @@ -132,7 +124,7 @@ final class ConfigurationTests: CMTestCase { } @Test func testExplicitNullFunctionFieldsAreRejected() throws { - for key in ["entryPoint", "parameters", "description", "steps"] { + for key in ["parameters", "description", "steps"] { var invalidFunction = function([markerStep()]) invalidFunction[key] = NSNull() try assertInvalidConfiguration(["main": invalidFunction]) @@ -160,10 +152,8 @@ final class ConfigurationTests: CMTestCase { ["builtin": "inFolder", "args": ["child\0ignored"]], ] for step in steps { - try assertInvalidConfiguration([ - "main": function([markerStep(), step]), - "helper": function([], parameters: ["value"], entry: false), - ]) + try assertInvalidConfiguration( + ["main": function([markerStep(), step])], functions: ["helper": function([], parameters: ["value"])]) } } diff --git a/Tests/CommandManagerTests/CoverageRegressionTests.swift b/Tests/CommandManagerTests/CoverageRegressionTests.swift index 4492909..34f5107 100644 --- a/Tests/CommandManagerTests/CoverageRegressionTests.swift +++ b/Tests/CommandManagerTests/CoverageRegressionTests.swift @@ -105,7 +105,7 @@ final class CoverageRegressionTests: CMTestCase { ), ] for (step, message) in variants { - try configure(["main": function([markerStep(), step]), "helper": function([], entry: false)]) + try configure(["main": function([markerStep(), step])], functions: ["helper": function([])]) try expectFailure(message) } } @@ -165,18 +165,21 @@ final class CoverageRegressionTests: CMTestCase { @Test func testSensitiveArraysAndOverlappingSecretsAreRedactedAcrossHelpers() throws { let secrets = directory.appendingPathComponent("secrets.json") try Data(#"["private","private-token",{"nested":[true,42,null,""]}]"#.utf8).write(to: secrets) - try configure([ - "main": function([ - ["builtin": "readJson", "args": [secrets.path], "saveAs": "data", "sensitive": true], - ["function": "helper"], - ]), - "helper": function( - [ - ["builtin": "log", "args": ["private-token|private|true|42|visible"]], - ["command": "/usr/bin/true", "args": ["private-token", "private", "true", "42"]], - ["builtin": "inDirectory", "args": ["private-token"]], - ], entry: false), - ]) + try configure( + [ + "main": function([ + ["builtin": "readJson", "args": [secrets.path], "saveAs": "data", "sensitive": true], + ["function": "helper"], + ]) + ], + functions: [ + "helper": function( + [ + ["builtin": "log", "args": ["private-token|private|true|42|visible"]], + ["command": "/usr/bin/true", "args": ["private-token", "private", "true", "42"]], + ["builtin": "inDirectory", "args": ["private-token"]], + ]) + ]) let result = try runCM(["main"]) assertFailure(result) #expect(result.outputWithoutEcho == "*****|*****|*****|*****|visible\n") @@ -256,15 +259,17 @@ final class CoverageRegressionTests: CMTestCase { @Test func testFunctionArraySpreadsCheckDynamicArity() throws { for (json, succeeds) in [(#"["two words",""]"#, true), ("[]", false), (#"["one"]"#, false)] { - try configure([ - "main": function([ - ["command": "/usr/bin/printf", "args": ["%s", json], "capture": "json", "saveAs": "args"], - ["function": "helper", "args": [["spread": "args"]]], - ]), - "helper": function( - [["builtin": "log", "args": ["<${first}><${second}>"]]], parameters: ["first", "second"], - entry: false), - ]) + try configure( + [ + "main": function([ + ["command": "/usr/bin/printf", "args": ["%s", json], "capture": "json", "saveAs": "args"], + ["function": "helper", "args": [["spread": "args"]]], + ]) + ], + functions: [ + "helper": function( + [["builtin": "log", "args": ["<${first}><${second}>"]]], parameters: ["first", "second"]) + ]) let result = try runCM(["main"]) if succeeds { assertSuccess(result, output: "<>\n") diff --git a/Tests/CommandManagerTests/DirectoryAndGitTests.swift b/Tests/CommandManagerTests/DirectoryAndGitTests.swift index c8166f1..c7d936c 100644 --- a/Tests/CommandManagerTests/DirectoryAndGitTests.swift +++ b/Tests/CommandManagerTests/DirectoryAndGitTests.swift @@ -29,22 +29,24 @@ final class DirectoryAndGitTests: CMTestCase { @Test func testDirectoryChangesAreScopedToFunctionsAndTheirCallees() throws { let grandchild = try makeDirectory("child/grandchild") let child = grandchild.deletingLastPathComponent() - try configure([ - "main": function([ - ["command": "/bin/pwd"], ["function": "helper"], ["command": "/bin/pwd"], - ]), - "helper": function( - [ - ["builtin": "inFolder", "args": ["child"]], ["command": "/bin/pwd"], - ["function": "nested"], ["command": "/bin/pwd"], - ], - entry: false), - "nested": function( - [ - ["builtin": "inFolder", "args": ["grandchild"]], - ["command": "/bin/pwd"], - ], entry: false), - ]) + try configure( + [ + "main": function([ + ["command": "/bin/pwd"], ["function": "helper"], ["command": "/bin/pwd"], + ]) + ], + functions: [ + "helper": function( + [ + ["builtin": "inFolder", "args": ["child"]], ["command": "/bin/pwd"], + ["function": "nested"], ["command": "/bin/pwd"], + ]), + "nested": function( + [ + ["builtin": "inFolder", "args": ["grandchild"]], + ["command": "/bin/pwd"], + ]), + ]) let expected = [directory.path, child.path, grandchild.path, child.path, directory.path] assertSuccess(try runCM(["main"]), output: expected.joined(separator: "\n") + "\n") } diff --git a/Tests/CommandManagerTests/EntryPointTests.swift b/Tests/CommandManagerTests/EntryPointTests.swift new file mode 100644 index 0000000..272c32b --- /dev/null +++ b/Tests/CommandManagerTests/EntryPointTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing + +final class EntryPointTests: CMTestCase { + @Test func testEntryPointsCanCallHelpersAndOtherEntryPoints() throws { + try configure( + [ + "main": function([["function": "helper", "args": ["value"]]]), + "public-leaf": function([printStep("${value}")], parameters: ["value"]), + ], + functions: [ + "helper": function([["function": "public-leaf", "args": ["${value}"]]], parameters: ["value"]) + ]) + assertSuccess(try runCM(["main"]), output: "value\n") + assertSuccess(try runCM(["public-leaf", "direct"]), output: "direct\n") + let help = try runCM() + #expect(help.stdout.contains("public-leaf")) + #expect(!help.stdout.contains("helper")) + assertFailure(try runCM(["--help", "helper"])) + } + + @Test func testDuplicateNamesAcrossSectionsFailBeforeExecution() throws { + try configure(["main": function([markerStep()])], functions: ["main": function([])]) + let result = try runCM(["main"]) + assertFailure(result) + #expect(result.stderr.contains("unique across entryPoints and functions")) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + + @Test func testLegacyEntryPointFieldExplainsMigrationInEitherSection() throws { + for section in ["entryPoints", "functions"] { + for value in [true, false] { + var legacy = function([markerStep()]) + legacy["entryPoint"] = value + try JSONSerialization.data(withJSONObject: [section: ["main": legacy]]).write(to: config) + let result = try runCM(["main"]) + assertFailure(result) + #expect(result.stderr.contains("Move public definitions into entryPoints")) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + } + } + + @Test func testDefinitionSectionsCanBeOmittedButNotNullOrWrongTypes() throws { + let valid: [[String: Any]] = [ + [:], + ["entryPoints": ["main": function([])]], + ["functions": ["helper": function([])]], + ] + for document in valid { + try JSONSerialization.data(withJSONObject: document).write(to: config) + assertSuccess(try runCM()) + } + let invalid: [Any] = [NSNull(), [], "invalid", true] + for section in ["entryPoints", "functions"] { + for value in invalid { + try JSONSerialization.data(withJSONObject: [section: value]).write(to: config) + assertFailure(try runCM()) + } + } + } + + @Test func testCyclesAndUnusedInvalidEntryPointsAreRejected() throws { + try assertInvalidConfiguration( + [ + "main": function([markerStep(), ["function": "helper"]]), + "other": function([["function": "main"]]), + ], functions: ["helper": function([["function": "other"]])]) + try assertInvalidConfiguration([ + "main": function([markerStep()]), + "unused": function([["function": "missing"]]), + ]) + } + + @Test func testTrackedExampleUsesOrderedSectionsAndValidates() throws { + let example = Self.repository.appendingPathComponent("examples/cm.json") + let text = try String(contentsOf: example, encoding: .utf8) + let document = try #require(JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) + let entries = try #require(document["entryPoints"] as? [String: Any]) + let helpers = try #require(document["functions"] as? [String: Any]) + let entryPosition = try #require(text.range(of: "\"entryPoints\":")) + let functionPosition = try #require(text.range(of: "\"functions\":")) + #expect(entryPosition.lowerBound < functionPosition.lowerBound) + for definitions in [entries, helpers] { + let positions = try definitions.keys.sorted().map { name in + try #require(text.range(of: "\"\(name)\": {")) + } + #expect(positions.map(\.lowerBound) == positions.map(\.lowerBound).sorted()) + for value in definitions.values { + let definition = try #require(value as? [String: Any]) + #expect(definition["entryPoint"] == nil) + } + } + let result = try runCM(["--config", example.path, "--help"], useConfig: false) + assertSuccess(result) + #expect(!result.stdout.contains("BuildAndTest")) + #expect(result.stdout.contains("CheckPackage")) + } +} diff --git a/Tests/CommandManagerTests/Support.swift b/Tests/CommandManagerTests/Support.swift index 4f750f3..75691a5 100644 --- a/Tests/CommandManagerTests/Support.swift +++ b/Tests/CommandManagerTests/Support.swift @@ -103,18 +103,22 @@ class CMTestCase { } func function( - _ steps: [[String: Any]], parameters: [String] = [], settings: [String] = [], entry: Bool = true, + _ steps: [[String: Any]], parameters: [String] = [], settings: [String] = [], description: String = "Example function" ) -> [String: Any] { [ - "description": description, "entryPoint": entry, "parameters": parameters, "settings": settings, + "description": description, "parameters": parameters, "settings": settings, "steps": steps, ] } - func configure(_ functions: [String: [String: Any]], settings: [[String: Any]] = []) throws { + func configure( + _ entryPoints: [String: [String: Any]] = [:], functions: [String: [String: Any]] = [:], + settings: [[String: Any]] = [] + ) throws { try JSONSerialization.data( - withJSONObject: ["settings": settings, "functions": functions], options: .sortedKeys + withJSONObject: ["settings": settings, "entryPoints": entryPoints, "functions": functions], + options: .sortedKeys ) .write(to: config) } @@ -173,9 +177,10 @@ class CMTestCase { } func assertInvalidConfiguration( - _ functions: [String: [String: Any]], sourceLocation: SourceLocation = #_sourceLocation + _ entryPoints: [String: [String: Any]], functions: [String: [String: Any]] = [:], + sourceLocation: SourceLocation = #_sourceLocation ) throws { - try configure(functions) + try configure(entryPoints, functions: functions) assertFailure(try runCM(["main"]), sourceLocation: sourceLocation) #expect( !FileManager.default.fileExists(atPath: marker.path), diff --git a/Tests/CommandManagerTests/VersionTests.swift b/Tests/CommandManagerTests/VersionTests.swift index 7269277..d7e7443 100644 --- a/Tests/CommandManagerTests/VersionTests.swift +++ b/Tests/CommandManagerTests/VersionTests.swift @@ -59,7 +59,7 @@ final class VersionTests: CMTestCase { } private func writeConfiguration(minimum: Any, steps: [[String: Any]]) throws { - let document: [String: Any] = ["minimumVersion": minimum, "functions": ["main": function(steps)]] + let document: [String: Any] = ["minimumVersion": minimum, "entryPoints": ["main": function(steps)]] try JSONSerialization.data(withJSONObject: document).write(to: config) } } diff --git a/Tests/CommandManagerTests/WorkflowTests.swift b/Tests/CommandManagerTests/WorkflowTests.swift index 438a12f..0369445 100644 --- a/Tests/CommandManagerTests/WorkflowTests.swift +++ b/Tests/CommandManagerTests/WorkflowTests.swift @@ -24,12 +24,12 @@ final class WorkflowTests: CMTestCase { var helper = function( [ ["builtin": "log", "args": ["${name}"], "when": "verbose"] - ], parameters: ["name"], entry: false) + ], parameters: ["name"]) helper["options"] = ["verbose": "Print name"] - try configure([ - "main": function([["function": "helper", "args": ["--verbose", "--", "--literal"]]]), - "helper": helper, - ]) + try configure( + [ + "main": function([["function": "helper", "args": ["--verbose", "--", "--literal"]]]) + ], functions: ["helper": helper]) assertSuccess(try runCM(["main"]), output: "--literal\n") } @@ -137,18 +137,21 @@ final class WorkflowTests: CMTestCase { } @Test func testVariablesAreLocalAndExplicitlyPassed() throws { - try configure([ - "main": function([ - ["builtin": "set", "args": ["parent"], "saveAs": "value"], - ["function": "helper", "args": ["${value}"]], - ["builtin": "log", "args": ["${value}"]], - ]), - "helper": function( - [ - ["builtin": "set", "args": ["child-${input}"], "saveAs": "value"], + try configure( + [ + "main": function([ + ["builtin": "set", "args": ["parent"], "saveAs": "value"], + ["function": "helper", "args": ["${value}"]], ["builtin": "log", "args": ["${value}"]], - ], parameters: ["input"], entry: false), - ]) + ]) + ], + functions: [ + "helper": function( + [ + ["builtin": "set", "args": ["child-${input}"], "saveAs": "value"], + ["builtin": "log", "args": ["${value}"]], + ], parameters: ["input"]) + ]) assertSuccess(try runCM(["main"]), output: "child-parent\nparent\n") } @@ -174,21 +177,24 @@ final class WorkflowTests: CMTestCase { @Test func testPathsAndScopedDirectoryRestoration() throws { let child = directory.appendingPathComponent("child") try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) - try configure([ - "main": function([ - ["builtin": "pathJoin", "args": [directory.path, "child"], "saveAs": "child"], - ["builtin": "assertPath", "args": ["${child}", "directory"]], - ["builtin": "assertPath", "args": [config.path, "file"]], - ["builtin": "assertDirectChild", "args": ["${child}", directory.path]], - ["function": "helper", "args": ["${child}"]], - ["command": "/bin/pwd"], - ]), - "helper": function( - [ - ["builtin": "inDirectory", "args": ["${path}"]], + try configure( + [ + "main": function([ + ["builtin": "pathJoin", "args": [directory.path, "child"], "saveAs": "child"], + ["builtin": "assertPath", "args": ["${child}", "directory"]], + ["builtin": "assertPath", "args": [config.path, "file"]], + ["builtin": "assertDirectChild", "args": ["${child}", directory.path]], + ["function": "helper", "args": ["${child}"]], ["command": "/bin/pwd"], - ], parameters: ["path"], entry: false), - ]) + ]) + ], + functions: [ + "helper": function( + [ + ["builtin": "inDirectory", "args": ["${path}"]], + ["command": "/bin/pwd"], + ], parameters: ["path"]) + ]) assertSuccess(try runCM(["main"]), output: "\(child.path)\n\(directory.path)\n") try configure(["main": function([["builtin": "assertDirectChild", "args": [directory.path, directory.path]]])]) assertFailure(try runCM(["main"])) diff --git a/examples/cm.json b/examples/cm.json index ebff3c4..7a5489e 100644 --- a/examples/cm.json +++ b/examples/cm.json @@ -1,105 +1,132 @@ { - "minimumVersion": "0.3", + "minimumVersion": "0.4", "settings": [ { "name": "FIGMA_TOKEN", "value": "replace-with-your-token" } ], - "functions": { + "entryPoints": { + "CheckPackage": { + "description": "Build and test the named Swift package at its Git root.", + "parameters": [ + "folder" + ], + "steps": [ + { + "builtin": "inFolder", + "args": [ + "${folder}" + ] + }, + { + "builtin": "assertGitRoot" + }, + { + "function": "BuildAndTest" + } + ] + }, + "GitStatus": { + "description": "Show a short status from anywhere in a Git working tree.", + "steps": [ + { + "builtin": "assertGitRepository" + }, + { + "command": "git", + "args": [ + "status", + "--short" + ] + } + ] + }, "Hello": { "description": "Print a greeting.", - "entryPoint": true, - "parameters": ["name"], + "parameters": [ + "name" + ], "steps": [ { "command": "/usr/bin/printf", - "args": ["Hello, %s!\n", "${name}"] + "args": [ + "Hello, %s!\n", + "${name}" + ] } ] }, "icons-sync": { "description": "Sync Figma icons and generate Unify icons.", - "entryPoint": true, - "settings": ["FIGMA_TOKEN"], + "settings": [ + "FIGMA_TOKEN" + ], "steps": [ { "function": "invoke-in-frontend" }, { "builtin": "export", - "args": ["FIGMA_TOKEN", "${FIGMA_TOKEN}"] + "args": [ + "FIGMA_TOKEN", + "${FIGMA_TOKEN}" + ] }, { "command": "node", - "args": ["scripts/sync-figma-icons.mjs"] + "args": [ + "scripts/sync-figma-icons.mjs" + ] }, { "command": "npx", - "args": ["nx", "run", "unify:generate-unify-icons"] - } - ] - }, - "invoke-in-backend": { - "description": "Require the backend folder as the current working directory.", - "entryPoint": false, - "steps": [ - { - "builtin": "inFolder", - "args": ["backend"] - } - ] - }, - "invoke-in-frontend": { - "description": "Require the frontend folder as the current working directory.", - "entryPoint": false, - "steps": [ - { - "builtin": "inFolder", - "args": ["frontend"] + "args": [ + "nx", + "run", + "unify:generate-unify-icons" + ] } ] - }, - "GitStatus": { - "description": "Show a short status from anywhere in a Git working tree.", - "entryPoint": true, + } + }, + "functions": { + "BuildAndTest": { + "description": "Build and test the Swift package in the current directory.", "steps": [ { - "builtin": "assertGitRepository" + "command": "swift", + "args": [ + "build" + ] }, { - "command": "git", - "args": ["status", "--short"] + "command": "swift", + "args": [ + "test" + ] } ] }, - "CheckPackage": { - "description": "Build and test the named Swift package at its Git root.", - "entryPoint": true, - "parameters": ["folder"], + "invoke-in-backend": { + "description": "Require the backend folder as the current working directory.", "steps": [ { "builtin": "inFolder", - "args": ["${folder}"] - }, - { - "builtin": "assertGitRoot" - }, - { - "function": "BuildAndTest" + "args": [ + "backend" + ] } ] }, - "BuildAndTest": { - "description": "Build and test the Swift package in the current directory.", + "invoke-in-frontend": { + "description": "Require the frontend folder as the current working directory.", "steps": [ { - "command": "swift", - "args": ["build"] - }, - { - "command": "swift", - "args": ["test"] + "builtin": "inFolder", + "args": [ + "frontend" + ] } ] } From f2c27bf09dafda825da267a9be4f5e9c555744bb Mon Sep 17 00:00:00 2001 From: b4prog Date: Sat, 26 Sep 2026 06:48:58 +0200 Subject: [PATCH 08/10] [feat] add executable hashing and string comparison conditions Add executableHash with SHA-256 output and equals/notEquals conditions. Document the features and cover hashing, validation, and conditional execution. --- README.md | 32 ++++- Sources/cm/Execution/Builtins.swift | 8 +- Sources/cm/Execution/ExecutableHash.swift | 29 ++++ Sources/cm/Workflow/StringComparison.swift | 24 ++++ Sources/cm/Workflow/Workflow.swift | 13 +- .../CommandManagerTests/ComparisonTests.swift | 111 +++++++++++++++ .../ExecutableHashTests.swift | 131 ++++++++++++++++++ 7 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 Sources/cm/Execution/ExecutableHash.swift create mode 100644 Sources/cm/Workflow/StringComparison.swift create mode 100644 Tests/CommandManagerTests/ComparisonTests.swift create mode 100644 Tests/CommandManagerTests/ExecutableHashTests.swift diff --git a/README.md b/README.md index 35a2508..7f256b6 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,7 @@ Declare boolean options as a name-to-description object on a function: Invoke with `cm MyFunction --build --check`. Unselected options are false. Names are case sensitive, follow the function-name syntax, and cannot conflict with parameters or declared settings. Unknown `--options` fail. Use `--` to end option parsing when passing a positional value starting with `--`. Functions without declared options retain the previous literal-argument behavior. `--all` has no special built-in meaning: explicitly include it in the relevant conditions. Helper calls may pass declared options in their `args`; helpers do not inherit the caller's option values. -A step's optional `when` is a variable name or a condition object with exactly one of `any`, `all`, or `not`: +A step's optional `when` is a variable name or a condition object with exactly one of `any`, `all`, `not`, `equals`, or `notEquals`: ```json { "function": "Build", "when": { "any": ["build", "all"] } } @@ -307,6 +307,25 @@ A step's optional `when` is a variable name or a condition object with exactly o Conditions can nest. `any` and `all` require nonempty arrays and short-circuit in order. Values must be JSON booleans or the strings `true`/`false`. The condition is evaluated before arguments, so a skipped step does not attempt to resolve its arguments. `label` supplies a human-readable name included in failure diagnostics. +### Compare values + +`equals` and `notEquals` each take exactly two string templates. Comparisons are case sensitive and use the rendered text, including whitespace. Use `${name}` to reference a parameter, declared setting, option, or earlier saved value; other strings are literals. `$$` escapes a dollar sign as it does in command arguments. + +```json +{ + "command": "tool", + "args": ["refresh"], + "when": { + "all": [ + { "notEquals": ["${after}", ""] }, + { "notEquals": ["${before}", "${after}"] } + ] + } +} +``` + +Comparisons compose with `any`, `all`, and `not`, retaining short-circuit evaluation. Unknown references and malformed operands fail configuration validation. A skipped output referenced by an evaluated comparison fails at runtime. JSON booleans and numbers use the same scalar rendering as arguments; objects, arrays, and null cannot be interpolated. These are text comparisons, not numeric ordering or shell expressions. + ### Save and use values Value-producing builtins require `saveAs`; it defines a unique function-local identifier: @@ -406,6 +425,7 @@ Sets an environment variable for the remaining steps of the current entry point, | Builtin | Arguments | Result / behavior | | --- | --- | --- | | `set` | value | Save the substituted string using `saveAs`. | +| `executableHash` | executable name or path | Save the executable's SHA-256 digest, or an empty string if no matching executable exists. | | `inDirectory` | path | Enter an existing absolute or relative directory; restore the caller's directory on function return. | | `pathJoin` | base, component… | Save a joined path. Requires a nonempty base; later components must be nonempty relative paths. Does not check existence or expand `~`. | | `assertPath` | path, kind | Require a regular `file` or a `directory`. | @@ -416,6 +436,14 @@ Sets an environment variable for the remaining steps of the current entry point, | `jsonGet` | variable name, JSON pointer | Select and save a required JSON value; missing, null, and whitespace-only strings fail. | | `log` | message | Print a message with sensitive values redacted. | +`executableHash` uses the same executable resolution as command steps: the current execution environment's `PATH` (including previous `export` steps), or an explicit absolute/relative path. It follows symlinks, hashes file contents in chunks without launching the executable, and returns a lowercase hexadecimal SHA-256 digest. Non-executable files and directories do not match; empty executable files have the normal nonempty SHA-256 digest of empty content. A resolved executable that cannot be read causes a failure, rather than returning an empty hash. Special files are rejected. An empty executable name is invalid. + +```json +{ "builtin": "executableHash", "args": ["tool"], "saveAs": "before" } +``` + +Capture a second hash after an update and combine `notEquals` conditions to run a follow-up only when the executable exists and its contents changed. The lookup runs again each time, so changes to `PATH` or the executable selected by it are respected. + All value-producing builtins require `saveAs`; other builtins reject it. Paths resolve relative to the current function's directory. The new Git builtins, like existing Git assertions, ignore `GIT_*` environment overrides. `assertGitClean` checks the entire working tree even when called from a subdirectory. `jsonGet` uses a literal variable name as its first argument, not `${...}`. Its second argument uses JSON pointer syntax: `/items/0/id`, `/posthog-api-key`, or an empty string for the entire value. Escape a key's `/` as `~1` and `~` as `~0`. Array indices are zero-based. Objects and arrays can be selected for further extraction or expansion. @@ -459,9 +487,11 @@ The executable target lives in `Sources/cm/`. Each file owns a specific responsi | `Configuration/Configuration.swift` | Define entry points, functions, and settings; validate names and the combined call graph. | | `Configuration/ConfigurationIO.swift` | Locate and decode configuration files with strict JSON diagnostics. | | `Workflow/Workflow.swift` | Decode steps, conditions, and argument expansions. | +| `Workflow/StringComparison.swift` | Validate and evaluate templated string comparisons. | | `Workflow/RuntimeValue.swift` | Store structured results and render argument templates. | | `Execution/Runner.swift` | Execute function sequences with scoped variables, directories, and environment. | | `Execution/Builtins.swift` | Define and execute builtin operations, including filesystem and Git checks. | +| `Execution/ExecutableHash.swift` | Hash resolved executable contents without launching them. | | `Execution/CommandExecution.swift` | Execute configured commands, capture output, and check exit statuses. | | `Execution/ProcessExecution.swift` | Resolve executables and launch/wait for processes with terminal and signal handling. | | `CLI/Output.swift` | Format command echoes and redact sensitive values. | diff --git a/Sources/cm/Execution/Builtins.swift b/Sources/cm/Execution/Builtins.swift index 609ad4c..e77810c 100644 --- a/Sources/cm/Execution/Builtins.swift +++ b/Sources/cm/Execution/Builtins.swift @@ -3,11 +3,11 @@ import Foundation enum Builtin: String, CaseIterable { case inFolder, assertGitRoot, assertGitRepository, export case set, inDirectory, pathJoin, assertPath, gitRoot, assertDirectChild, assertGitClean - case readJson, jsonGet, log + case readJson, jsonGet, log, executableHash var argumentCount: Int? { switch self { - case .inFolder, .set, .inDirectory, .readJson, .log: return 1 + case .inFolder, .set, .inDirectory, .readJson, .log, .executableHash: return 1 case .export, .assertPath, .assertDirectChild, .jsonGet: return 2 case .assertGitRoot, .assertGitRepository, .gitRoot, .assertGitClean: return 0 case .pathJoin: return nil @@ -15,7 +15,7 @@ enum Builtin: String, CaseIterable { } var returnsValue: Bool { - [.set, .pathJoin, .gitRoot, .readJson, .jsonGet].contains(self) + [.set, .pathJoin, .gitRoot, .readJson, .jsonGet, .executableHash].contains(self) } func validateCount(_ args: [String]) throws { @@ -43,6 +43,8 @@ struct BuiltinExecutor { case .assertGitRepository: try assertGitRepository(directory, requireRoot: false) case .export: try export(name: arguments[0], value: arguments[1], into: &environment) case .set: return .string(arguments[0]) + case .executableHash: + return .string(try executableHash(arguments[0], directory: directory, environment: environment)) case .pathJoin: return .string(try joinedPath(arguments)) case .assertPath: try assertPath(resolvedPath(arguments[0], from: directory), kind: arguments[1]) case .gitRoot: diff --git a/Sources/cm/Execution/ExecutableHash.swift b/Sources/cm/Execution/ExecutableHash.swift new file mode 100644 index 0000000..5e2a0e6 --- /dev/null +++ b/Sources/cm/Execution/ExecutableHash.swift @@ -0,0 +1,29 @@ +import CryptoKit +import Foundation + +/// Hash the executable selected by command resolution without launching it. +func executableHash(_ executable: String, directory: URL, environment: [String: String]) throws -> String { + try validateProcessArguments([executable]) + guard !executable.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CommandError("executableHash requires a nonempty executable name or path.") + } + let path: URL + do { + path = try executableURL(executable, directory: directory, environment: environment) + } catch let error as CommandError where error.status == 127 { + return "" + } + guard isExecutableFile(path) else { return "" } + let resolved = path.resolvingSymlinksInPath() + let attributes = try FileManager.default.attributesOfItem(atPath: resolved.path) + guard attributes[.type] as? FileAttributeType == .typeRegular else { + throw CommandError("executableHash requires a regular file at '\(resolved.path)'.") + } + let handle = try FileHandle(forReadingFrom: resolved) + defer { try? handle.close() } + var hash = SHA256() + while let data = try handle.read(upToCount: 65_536), !data.isEmpty { + hash.update(data: data) + } + return hash.finalize().map { String(format: "%02x", $0) }.joined() +} diff --git a/Sources/cm/Workflow/StringComparison.swift b/Sources/cm/Workflow/StringComparison.swift new file mode 100644 index 0000000..b8f1834 --- /dev/null +++ b/Sources/cm/Workflow/StringComparison.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Comparison operands are string templates, with the same scalar rendering as arguments. +struct StringComparison: Decodable { + private let left: ArgumentTemplate + private let right: ArgumentTemplate + + init(from decoder: Decoder) throws { + let operands = try decoder.singleValueContainer().decode([String].self) + try requireArguments(operands, count: 2, target: "Comparison") + try validateProcessArguments(operands) + left = try ArgumentTemplate(operands[0]) + right = try ArgumentTemplate(operands[1]) + } + + func validate(_ names: Set) throws { + try left.validate(parameters: names) + try right.validate(parameters: names) + } + + func matches(_ values: [String: RuntimeValue]) throws -> Bool { + try left.renderRuntime(values: values) == right.renderRuntime(values: values) + } +} diff --git a/Sources/cm/Workflow/Workflow.swift b/Sources/cm/Workflow/Workflow.swift index 9b6c5ae..e9ebdda 100644 --- a/Sources/cm/Workflow/Workflow.swift +++ b/Sources/cm/Workflow/Workflow.swift @@ -95,21 +95,25 @@ indirect enum Condition: Decodable { case any([Condition]) case all([Condition]) case not(Condition) + case equals(StringComparison) + case notEquals(StringComparison) init(from decoder: Decoder) throws { if let name = try? decoder.singleValueContainer().decode(String.self) { self = .value(name) return } - try rejectUnknownKeys(decoder, allowed: ["any", "all", "not"]) + try rejectUnknownKeys(decoder, allowed: ["any", "all", "not", "equals", "notEquals"]) let container = try decoder.container(keyedBy: JSONKey.self) guard container.allKeys.count == 1, let key = container.allKeys.first else { - throw CommandError("A condition requires exactly one of any, all, or not.") + throw CommandError("A condition requires exactly one of any, all, not, equals, or notEquals.") } switch key.stringValue { case "any": self = .any(try container.decode([Condition].self, forKey: key)) case "all": self = .all(try container.decode([Condition].self, forKey: key)) - default: self = .not(try container.decode(Condition.self, forKey: key)) + case "not": self = .not(try container.decode(Condition.self, forKey: key)) + case "equals": self = .equals(try container.decode(StringComparison.self, forKey: key)) + default: self = .notEquals(try container.decode(StringComparison.self, forKey: key)) } } @@ -121,6 +125,7 @@ indirect enum Condition: Decodable { guard !conditions.isEmpty else { throw CommandError("Condition lists must not be empty.") } for condition in conditions { try condition.validate(names) } case .not(let condition): try condition.validate(names) + case .equals(let comparison), .notEquals(let comparison): try comparison.validate(names) } } @@ -137,6 +142,8 @@ indirect enum Condition: Decodable { case .any(let conditions): return try conditions.contains { try $0.evaluate(values) } case .all(let conditions): return try conditions.allSatisfy { try $0.evaluate(values) } case .not(let condition): return try !condition.evaluate(values) + case .equals(let comparison): return try comparison.matches(values) + case .notEquals(let comparison): return try !comparison.matches(values) } } } diff --git a/Tests/CommandManagerTests/ComparisonTests.swift b/Tests/CommandManagerTests/ComparisonTests.swift new file mode 100644 index 0000000..ca0d06a --- /dev/null +++ b/Tests/CommandManagerTests/ComparisonTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing + +final class ComparisonTests: CMTestCase { + @Test func testEqualityUsesLiteralAndTemplatedStrings() throws { + let cases = [("", "", true), ("one", "two", false), ("Case", "case", false), ("two words", "two words", true)] + for (left, right, equal) in cases { + try configure([ + "main": function( + [ + ["builtin": "log", "args": ["equal"], "when": ["equals": ["${left}", "${right}"]]], + ["builtin": "log", "args": ["different"], "when": ["notEquals": ["${left}", "${right}"]]], + ], parameters: ["left", "right"]) + ]) + assertSuccess(try runCM(["main", left, right]), output: equal ? "equal\n" : "different\n") + } + try configure([ + "main": function( + [ + ["builtin": "log", "args": ["matched"], "when": ["equals": ["prefix-${value}", "prefix-$$value"]]] + ], parameters: ["value"]) + ]) + assertSuccess(try runCM(["main", "$value"]), output: "matched\n") + } + + @Test func testComparisonsComposeAndShortCircuitMissingValues() throws { + var main = function([ + ["builtin": "set", "args": ["unused"], "saveAs": "missing", "when": "enabled"], + [ + "builtin": "log", "args": ["any"], + "when": [ + "any": [ + ["equals": ["", ""]], ["equals": ["${missing}", "value"]], + ] + ], + ], + [ + "builtin": "log", "args": ["unreachable"], + "when": [ + "all": [ + ["notEquals": ["", ""]], ["equals": ["${missing}", "value"]], + ] + ], + ], + ["builtin": "log", "args": ["not"], "when": ["not": ["equals": ["a", "b"]]]], + ]) + main["options"] = ["enabled": "Create optional output"] + try configure(["main": main]) + assertSuccess(try runCM(["main"]), output: "any\nnot\n") + } + + @Test func testComparisonSchemaAndUnknownReferencesFailBeforeCommands() throws { + let invalid: [Any] = [ + [], ["one"], ["one", "two", "three"], [1, 1], NSNull(), "value", ["left": "a", "right": "b"], + ["${unknown}", "value"], ["${unclosed", "value"], ["bad\0value", "value"], + ] + for operation in ["equals", "notEquals"] { + for operands in invalid { + try assertInvalidConfiguration([ + "main": function([ + markerStep(), ["builtin": "log", "args": ["unreachable"], "when": [operation: operands]], + ]) + ]) + } + } + try assertInvalidConfiguration([ + "main": function([ + markerStep(), + [ + "builtin": "log", "args": ["unreachable"], + "when": ["equals": ["a", "a"], "notEquals": ["a", "b"]], + ], + ]) + ]) + } + + @Test func testMissingOrStructuredComparisonValuesFailAtRuntime() throws { + for json in ["{}", "[]", "null"] { + try configure([ + "main": function([ + ["command": "/usr/bin/printf", "args": ["%s", json], "capture": "json", "saveAs": "value"], + ["builtin": "log", "args": ["unreachable"], "when": ["equals": ["${value}", ""]]], + markerStep(), + ]) + ]) + assertFailure(try runCM(["main"])) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + var main = function([ + ["builtin": "set", "args": ["unused"], "saveAs": "missing", "when": "enabled"], + ["builtin": "log", "args": ["unreachable"], "when": ["equals": ["${missing}", ""]]], + markerStep(), + ]) + main["options"] = ["enabled": "Create optional output"] + try configure(["main": main]) + assertFailure(try runCM(["main"])) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + + @Test func testComparisonsRenderJSONScalarsLikeArguments() throws { + for json in ["true", "false", "42"] { + try configure([ + "main": function([ + ["command": "/usr/bin/printf", "args": ["%s", json], "capture": "json", "saveAs": "value"], + ["builtin": "log", "args": ["matched"], "when": ["equals": ["${value}", json]]], + ]) + ]) + assertSuccess(try runCM(["main"]), output: "matched\n") + } + } +} diff --git a/Tests/CommandManagerTests/ExecutableHashTests.swift b/Tests/CommandManagerTests/ExecutableHashTests.swift new file mode 100644 index 0000000..9d69260 --- /dev/null +++ b/Tests/CommandManagerTests/ExecutableHashTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing + +final class ExecutableHashTests: CMTestCase { + private let abcHash = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + private let emptyHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + private func makeExecutable(_ name: String, contents: Data) throws -> URL { + let path = directory.appendingPathComponent(name) + try FileManager.default.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: path) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) + return path + } + + private func configureHash(_ executable: String) throws { + try configure([ + "main": function([ + ["builtin": "executableHash", "args": [executable], "saveAs": "hash"], + printStep("${hash}"), + ]) + ]) + } + + @Test func testKnownHashesAndSymlinksWithoutLaunchingExecutable() throws { + let target = try makeExecutable("target", contents: Data("abc".utf8)) + let link = directory.appendingPathComponent("link") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + for name in [target.path, "./target", "./link"] { + try configureHash(name) + assertSuccess(try runCM(["main"]), output: abcHash + "\n") + } + let empty = try makeExecutable("empty", contents: Data()) + try configureHash(empty.path) + assertSuccess(try runCM(["main"]), output: emptyHash + "\n") + } + + @Test func testHashUsesExportedPathOrderAndRelativeComponents() throws { + let first = try makeExecutable("first/tool", contents: Data("abc".utf8)) + let second = try makeExecutable("second/tool", contents: Data()) + try configure([ + "main": function([ + ["builtin": "export", "args": ["PATH", "first:second"]], + ["builtin": "executableHash", "args": ["tool"], "saveAs": "first"], + ["builtin": "export", "args": ["PATH", second.deletingLastPathComponent().path]], + ["builtin": "executableHash", "args": ["tool"], "saveAs": "second"], + printStep("${first}|${second}"), + ]) + ]) + assertSuccess(try runCM(["main"]), output: abcHash + "|" + emptyHash + "\n") + try configureHash("tool") + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = ":/nonexistent" + assertSuccess( + try runCM(["main"], cwd: first.deletingLastPathComponent(), environment: environment), + output: abcHash + "\n") + } + + @Test func testMissingAndNonExecutableFilesReturnEmptyHash() throws { + let file = try makeExecutable("not-executable", contents: Data("abc".utf8)) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: file.path) + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = directory.path + for name in ["missing-command", "./missing-file", "not-executable", file.path, directory.path] { + try configureHash(name) + assertSuccess(try runCM(["main"], environment: environment), output: "\n") + } + } + + @Test func testUnreadableExecutableFailsInsteadOfReturningEmpty() throws { + let file = try makeExecutable("unreadable", contents: Data("abc".utf8)) + try FileManager.default.setAttributes([.posixPermissions: 0o111], ofItemAtPath: file.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path) } + try configure([ + "main": function([ + ["builtin": "executableHash", "args": [file.path], "saveAs": "hash"], + markerStep(), + ]) + ]) + assertFailure(try runCM(["main"])) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + + @Test func testLargeExecutableIsHashedAcrossChunks() throws { + let file = try makeExecutable("large", contents: Data(repeating: 97, count: 1_000_000)) + try configureHash(file.path) + assertSuccess(try runCM(["main"]), output: "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0\n") + } + + @Test func testExecutableHashRequiresOneArgumentAndSavedResult() throws { + let invalid: [[String: Any]] = [ + ["builtin": "executableHash", "saveAs": "hash"], + ["builtin": "executableHash", "args": ["one", "two"], "saveAs": "hash"], + ["builtin": "executableHash", "args": ["tool"]], + ] + for step in invalid { + try assertInvalidConfiguration(["main": function([markerStep(), step])]) + } + try configureHash("") + assertFailure(try runCM(["main"])) + } + + @Test func testHashComparisonDetectsNewChangedAndUnchangedExecutables() throws { + let target = directory.appendingPathComponent("installed") + let replacement = try makeExecutable("replacement", contents: Data("abc".utf8)) + for initial in [nil, "abc", "old"] as [String?] { + if let initial { + _ = try makeExecutable("installed", contents: Data(initial.utf8)) + } + try configure([ + "main": function([ + ["builtin": "executableHash", "args": [target.path], "saveAs": "before"], + ["command": "/bin/cp", "args": [replacement.path, target.path]], + ["builtin": "executableHash", "args": [target.path], "saveAs": "after"], + [ + "command": "/usr/bin/touch", "args": [marker.path], + "when": [ + "all": [ + ["notEquals": ["${after}", ""]], ["notEquals": ["${before}", "${after}"]], + ] + ], + ], + ]) + ]) + assertSuccess(try runCM(["main"])) + #expect(FileManager.default.fileExists(atPath: marker.path) == (initial != "abc")) + try? FileManager.default.removeItem(at: marker) + try FileManager.default.removeItem(at: target) + } + } +} From 25c9d51001ca4c7d51a083a288678e76341f1312 Mon Sep 17 00:00:00 2001 From: b4prog Date: Sat, 26 Sep 2026 07:05:09 +0200 Subject: [PATCH 09/10] [fix] wait for actual child readiness in terminal interrupt tests Ignore READY in command echoes before sending Ctrl+C. Add delayed-start regression coverage and terminal-output diagnostics. --- .../InteractiveCommandTests.swift | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/Tests/CommandManagerTests/InteractiveCommandTests.swift b/Tests/CommandManagerTests/InteractiveCommandTests.swift index 7c1629b..3dbdd3b 100644 --- a/Tests/CommandManagerTests/InteractiveCommandTests.swift +++ b/Tests/CommandManagerTests/InteractiveCommandTests.swift @@ -43,14 +43,26 @@ final class InteractiveCommandTests: CMTestCase { } @Test func testTerminalInterruptStopsCommandAndPreservesStatus() throws { + let ready = directory.appendingPathComponent("ready") + // Expose the command-echo race and handle SIGINT consistently before exec replaces the shell. try configure([ "main": function([ - ["command": "/bin/sh", "args": ["-c", "echo READY; sleep 30"]], + [ + "command": "/bin/sh", + "args": [ + "-c", + "trap 'exit 130' INT; /bin/sleep 0.2; /usr/bin/touch \"$$1\"; echo READY; exec /bin/sleep 30", + "interrupt-test", ready.path, + ], + ], markerStep(), ]) ]) - let status = try interruptTerminalCommand() - #expect(status == 130) + let result = try interruptTerminalCommand() + #expect( + FileManager.default.fileExists(atPath: ready.path), + "Interrupt arrived before the child was ready: \(result.stdout)") + #expect(result.status == 130, "\(result.stdout)") #expect(!FileManager.default.fileExists(atPath: marker.path)) } @@ -84,7 +96,7 @@ final class InteractiveCommandTests: CMTestCase { return try String(contentsOf: outputURL, encoding: .utf8) } - private func interruptTerminalCommand() throws -> Int32 { + private func interruptTerminalCommand() throws -> CommandResult { let outputURL = directory.appendingPathComponent("terminal-output") try Data().write(to: outputURL) let output = try FileHandle(forWritingTo: outputURL) @@ -104,13 +116,15 @@ final class InteractiveCommandTests: CMTestCase { let deadline = Date().addingTimeInterval(5) while process.isRunning && Date() < deadline { let text = String(decoding: try Data(contentsOf: outputURL), as: UTF8.self) - if text.contains("READY") { break } + if text.contains("\r\nREADY\r\n") { break } Thread.sleep(forTimeInterval: 0.01) } let promptOutput = try String(contentsOf: outputURL, encoding: .utf8) - try #require(promptOutput.contains("READY"), "\(promptOutput)") + try #require(promptOutput.contains("\r\nREADY\r\n"), "\(promptOutput)") try input.fileHandleForWriting.write(contentsOf: Data([3])) try waitForProcess(process, timeout: 5) - return process.terminationStatus + return CommandResult( + status: process.terminationStatus, + stdout: try String(contentsOf: outputURL, encoding: .utf8), stderr: "") } } From 8da137a9302548d7691bf4e201b2b69c76e1bd6d Mon Sep 17 00:00:00 2001 From: b4prog Date: Sat, 26 Sep 2026 07:40:35 +0200 Subject: [PATCH 10/10] [fix] preserve dynamic helper arguments as positional values --- README.md | 2 +- Sources/cm/Execution/Runner.swift | 26 +++++---- Sources/cm/Workflow/RuntimeValue.swift | 7 +++ Sources/cm/Workflow/Workflow.swift | 17 ++++-- Tests/CommandManagerTests/WorkflowTests.swift | 53 +++++++++++++++++++ 5 files changed, 90 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7f256b6..e21644d 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ Declare boolean options as a name-to-description object on a function: "requireAnyOption": true ``` -Invoke with `cm MyFunction --build --check`. Unselected options are false. Names are case sensitive, follow the function-name syntax, and cannot conflict with parameters or declared settings. Unknown `--options` fail. Use `--` to end option parsing when passing a positional value starting with `--`. Functions without declared options retain the previous literal-argument behavior. `--all` has no special built-in meaning: explicitly include it in the relevant conditions. Helper calls may pass declared options in their `args`; helpers do not inherit the caller's option values. +Invoke with `cm MyFunction --build --check`. Unselected options are false. Names are case sensitive, follow the function-name syntax, and cannot conflict with parameters or declared settings. Unknown `--options` fail. Use `--` to end option parsing when passing a positional value starting with `--`. Functions without declared options retain the previous literal-argument behavior. `--all` has no special built-in meaning: explicitly include it in the relevant conditions. Helper calls may pass declared options in their `args`; helpers do not inherit the caller's option values. Only literal helper arguments without placeholders are parsed as options or the `--` terminator. Substituted values and spread elements remain positional, even when they start with `--`. A step's optional `when` is a variable name or a condition object with exactly one of `any`, `all`, `not`, `equals`, or `notEquals`: diff --git a/Sources/cm/Execution/Runner.swift b/Sources/cm/Execution/Runner.swift index d90a977..3c152b4 100644 --- a/Sources/cm/Execution/Runner.swift +++ b/Sources/cm/Execution/Runner.swift @@ -11,6 +11,7 @@ struct Runner { defer { environment = initialEnvironment } var secrets = Set(configuration.settings.map(\.value).filter { !$0.isEmpty }) guard let function = configuration.entryPoints[name] else { throw CommandError("Unknown function '\(name)'.") } + let arguments = arguments.map { RenderedArgument(value: $0, allowsOptionParsing: true) } let bindings = try bindArguments(arguments, function: function) if function.requireAnyOption && !function.options.keys.contains(where: { bindings[$0] == "true" }) { printFunctionHelp(name, function: function) @@ -23,21 +24,22 @@ struct Runner { } } - private func bindArguments(_ arguments: [String], function: FunctionDefinition) throws -> [String: String] { + private func bindArguments(_ arguments: [RenderedArgument], function: FunctionDefinition) throws -> [String: String] + { var bindings = Dictionary(uniqueKeysWithValues: function.options.keys.map { ($0, "false") }) var positional: [String] = [] var parseOptions = !function.options.isEmpty for argument in arguments { - if parseOptions && argument == "--" { + if parseOptions && argument.allowsOptionParsing && argument.value == "--" { parseOptions = false - } else if parseOptions && argument.hasPrefix("--") { - let name = String(argument.dropFirst(2)) + } else if parseOptions && argument.allowsOptionParsing && argument.value.hasPrefix("--") { + let name = String(argument.value.dropFirst(2)) guard function.options[name] != nil else { - throw CommandError("Unknown function option '\(argument)'.") + throw CommandError("Unknown function option '\(argument.value)'.") } bindings[name] = "true" } else { - positional.append(argument) + positional.append(argument.value) } } try requireArguments(positional, count: function.parameters.count, target: "Function") @@ -47,7 +49,7 @@ struct Runner { } private func run( - _ name: String, arguments: [String], directory: inout URL, environment: inout [String: String], + _ name: String, arguments: [RenderedArgument], directory: inout URL, environment: inout [String: String], secrets: inout Set ) throws { guard let function = configuration.definitions[name] else { throw CommandError("Unknown function '\(name)'.") } @@ -59,7 +61,7 @@ struct Runner { do { guard try step.when?.evaluate(values) ?? true else { continue } let args = try step.args.flatMap { try $0.render(values) } - try validateProcessArguments(args) + try validateProcessArguments(args.map(\.value)) let result = try execute( step, arguments: args, values: values, directory: &functionDirectory, environment: &environment, secrets: &secrets) @@ -81,13 +83,14 @@ struct Runner { } private func execute( - _ step: Step, arguments: [String], values: [String: RuntimeValue], directory: inout URL, + _ step: Step, arguments: [RenderedArgument], values: [String: RuntimeValue], directory: inout URL, environment: inout [String: String], secrets: inout Set ) throws -> RuntimeValue? { switch step.target { case .command(let executable): return try CommandExecutor().execute( - executable, arguments: arguments, capture: step.capture, directory: directory, environment: environment, + executable, arguments: arguments.map(\.value), capture: step.capture, directory: directory, + environment: environment, secrets: secrets) case .function(let name): try run(name, arguments: arguments, directory: &directory, environment: &environment, secrets: &secrets) @@ -95,7 +98,8 @@ struct Runner { case .builtin(let name): guard let builtin = Builtin(rawValue: name) else { throw CommandError("Unknown builtin '\(name)'.") } return try BuiltinExecutor().execute( - builtin, arguments: arguments, values: values, directory: &directory, environment: &environment, + builtin, arguments: arguments.map(\.value), values: values, directory: &directory, + environment: &environment, secrets: secrets) } } diff --git a/Sources/cm/Workflow/RuntimeValue.swift b/Sources/cm/Workflow/RuntimeValue.swift index 763ab6a..12c68a5 100644 --- a/Sources/cm/Workflow/RuntimeValue.swift +++ b/Sources/cm/Workflow/RuntimeValue.swift @@ -81,6 +81,13 @@ struct ArgumentTemplate { } } + var hasParameters: Bool { + parts.contains { part in + if case .parameter = part { return true } + return false + } + } + func validate(parameters: Set) throws { for case .parameter(let name) in parts { guard parameters.contains(name) else { diff --git a/Sources/cm/Workflow/Workflow.swift b/Sources/cm/Workflow/Workflow.swift index e9ebdda..a180a6a 100644 --- a/Sources/cm/Workflow/Workflow.swift +++ b/Sources/cm/Workflow/Workflow.swift @@ -46,6 +46,12 @@ enum CaptureMode: String, Decodable { case text, trimmed, json } +/// Keeps option syntax distinct from runtime data until function arguments are bound. +struct RenderedArgument { + let value: String + let allowsOptionParsing: Bool +} + enum StepArgument: Decodable { case template(String) case spread(String) @@ -70,16 +76,21 @@ enum StepArgument: Decodable { } } - func render(_ values: [String: RuntimeValue]) throws -> [String] { + func render(_ values: [String: RuntimeValue]) throws -> [RenderedArgument] { switch self { - case .template(let text): return [try ArgumentTemplate(text).renderRuntime(values: values)] + case .template(let text): + let template = try ArgumentTemplate(text) + return [ + RenderedArgument( + value: try template.renderRuntime(values: values), allowsOptionParsing: !template.hasParameters) + ] case .spread(let name): guard case .array(let array) = values[name] else { throw CommandError("Expected string array '\(name)'.") } return try array.map { value in guard case .string(let text) = value else { throw CommandError("Array '\(name)' must contain only strings.") } - return text + return RenderedArgument(value: text, allowsOptionParsing: false) } } } diff --git a/Tests/CommandManagerTests/WorkflowTests.swift b/Tests/CommandManagerTests/WorkflowTests.swift index 0369445..a65821f 100644 --- a/Tests/CommandManagerTests/WorkflowTests.swift +++ b/Tests/CommandManagerTests/WorkflowTests.swift @@ -33,6 +33,59 @@ final class WorkflowTests: CMTestCase { assertSuccess(try runCM(["main"]), output: "--literal\n") } + @Test(arguments: ["--verbose", "--other", "--"]) + func testDynamicHelperArgumentsRemainPositional(value: String) throws { + var helper = function( + [["builtin": "log", "args": ["${name}|${verbose}"]]], parameters: ["name"]) + helper["options"] = ["verbose": "Print name"] + try configure( + [ + "main": function( + [ + ["function": "helper", "args": ["${name}"]], + ["builtin": "set", "args": ["${name}"], "saveAs": "saved"], + ["function": "helper", "args": ["${saved}", "--verbose"]], + ["function": "helper", "args": ["--verbose", "${name}"]], + ], parameters: ["name"]) + ], functions: ["helper": helper]) + assertSuccess(try runCM(["main", value]), output: "\(value)|false\n\(value)|true\n\(value)|true\n") + } + + @Test func testSpreadHelperArgumentsRemainPositional() throws { + var helper = function( + [["builtin": "log", "args": ["${first}|${second}|${third}|${verbose}"]]], + parameters: ["first", "second", "third"]) + helper["options"] = ["verbose": "Print values"] + try configure( + [ + "main": function([ + [ + "command": "/usr/bin/printf", "args": ["%s", #"["--verbose","--other","--"]"#], + "capture": "json", "saveAs": "items", + ], + ["function": "helper", "args": [["spread": "items"]]], + ["function": "helper", "args": [["spread": "items"], "--verbose"]], + ]) + ], functions: ["helper": helper]) + assertSuccess(try runCM(["main"]), output: "--verbose|--other|--|false\n--verbose|--other|--|true\n") + } + + @Test func testInterpolatedHelperOptionIsPositionalAndLiteralUnknownOptionFails() throws { + var helper = function( + [["builtin": "log", "args": ["${name}|${verbose}"]]], parameters: ["name"]) + helper["options"] = ["verbose": "Print name"] + try configure( + ["main": function([["function": "helper", "args": ["--${name}"]]], parameters: ["name"])], + functions: ["helper": helper]) + assertSuccess(try runCM(["main", "verbose"]), output: "--verbose|false\n") + try configure( + ["main": function([["function": "helper", "args": ["--other", "value"]]])], + functions: ["helper": helper]) + let result = try runCM(["main"]) + assertFailure(result) + #expect(result.stderr.contains("Unknown function option '--other'.")) + } + @Test func testCaptureTextTrimmedJSONAndStderr() throws { try configure([ "main": function([