From 5b22a36c2aafc75da3c028ae14c40454c6726921 Mon Sep 17 00:00:00 2001 From: b4prog Date: Thu, 24 Sep 2026 06:51:16 +0200 Subject: [PATCH 1/4] [feat] add configuration settings for functions --- README.md | 35 +++++++---- .../CommandExecutionTests.swift | 20 +++++- .../ConfigurationTests.swift | 28 +++++++++ Tests/CommandManagerTests/Support.swift | 15 +++-- Tests/CommandManagerTests/VersionTests.swift | 8 +-- cm.swift | 62 +++++++++++++++++-- examples/cm.json | 22 ++++++- 7 files changed, 160 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 654e398..2d38185 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ The optional root-level `minimumVersion` field specifies the oldest compatible C ```json { - "minimumVersion": "0.2", + "minimumVersion": "0.3", "functions": {} } ``` @@ -86,12 +86,14 @@ cm cm Hello Bruno cm GitStatus cm CheckPackage CommandManager +cm icons-sync ``` -- `cm` shows the current version (`0.2`) and lists the available entry points and their descriptions. +- `cm` shows the current version (`0.3`) 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. +- `icons-sync` exports the configured Figma token, syncs Figma icons, and generates Unify icons. This repository includes `Package.swift`, so `cm CheckPackage CommandManager` builds and tests CommandManager. Replace `CommandManager` with another Swift package's folder name to check that package instead. @@ -117,21 +119,27 @@ Names are case sensitive. Functions accept exactly the number of arguments decla cm Hello "Bruno Smith" ``` -## Define functions +## Define functions and settings -The root object contains a `functions` object. Its keys are the function names: +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: ```json { + "settings": [ + { "name": "FIGMA_TOKEN", "value": "replace-with-your-token" } + ], "functions": { - "Hello": { - "description": "Print a greeting.", + "icons-sync": { + "description": "Sync Figma icons and generate Unify icons.", "entryPoint": true, - "parameters": ["name"], + "settings": ["FIGMA_TOKEN"], "steps": [ { - "command": "/usr/bin/printf", - "args": ["Hello, %s!\n", "${name}"] + "command": "/bin/zsh", + "args": [ + "-c", + "export FIGMA_TOKEN=\"${FIGMA_TOKEN}\" &&\nnode scripts/sync-figma-icons.mjs &&\nnpx nx run unify:generate-unify-icons" + ] } ] } @@ -144,9 +152,12 @@ The root object contains a `functions` object. Its keys are the function names: | `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. | -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 names use `[A-Za-z_][A-Za-z0-9_]*` and must be unique within a function; hyphens are allowed only in function names. +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. + +The `icons-sync` example exports `FIGMA_TOKEN` and then runs the icon synchronization and generation commands in the same shell process, so the token is available to both commands. Its `&&` chain stops at the first failure. 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. @@ -230,7 +241,7 @@ Built-ins implement operations that need access to CommandManager's execution st ## Arguments and substitution -Use `${parameter}` inside any step argument to insert the corresponding function argument. A placeholder can be the whole string or part of it: +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: ```json { @@ -297,7 +308,7 @@ Both assertions ignore `GIT_*` environment overrides for their internal checks, ## 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, unknown function or built-in references, incorrect argument counts, unknown parameter references, and recursive call cycles. Executable names and argument strings must not contain NUL characters. Direct recursion and cycles involving several functions are not supported. +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. 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. diff --git a/Tests/CommandManagerTests/CommandExecutionTests.swift b/Tests/CommandManagerTests/CommandExecutionTests.swift index 74dfecf..4037e16 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.2 —")) + #expect(result.stdout.hasPrefix("CommandManager 0.3 —")) 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.2 —")) + #expect(result.stdout.hasPrefix("CommandManager 0.3 —")) #expect(output.contains("cm.json")) #expect(output.contains("Application Support")) #expect(!(output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) @@ -96,6 +96,22 @@ final class CommandExecutionTests: CMTestCase { assertSuccess(try runCM(["main", "hello"]), output: "prefix-hello-suffix $ ${literal}\n") } + @Test func testDeclaredSettingsAreSubstitutedInFunctionArguments() throws { + try configure( + ["main": function([printStep("${FIGMA_TOKEN}")], settings: ["FIGMA_TOKEN"])], + settings: [["name": "FIGMA_TOKEN", "value": "secret-token"]]) + assertSuccess(try runCM(["main"]), output: "secret-token\n") + } + + @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"]]) + assertSuccess(try runCM(["main"]), output: "secret-token\n") + } + @Test func testCommandsAreEchoedInGreenWithAGreyChevronBeforeTheirOutput() throws { try configure(["main": function([printStep("${value}")], parameters: ["value"])]) for value in ["plain", "", "two words", "it's quoted", "a\"b", "first\nsecond"] { diff --git a/Tests/CommandManagerTests/ConfigurationTests.swift b/Tests/CommandManagerTests/ConfigurationTests.swift index 41910f8..d66cc02 100644 --- a/Tests/CommandManagerTests/ConfigurationTests.swift +++ b/Tests/CommandManagerTests/ConfigurationTests.swift @@ -68,6 +68,34 @@ final class ConfigurationTests: CMTestCase { } } + @Test func testInvalidSettingsAreRejectedBeforeExecution() throws { + let functions = ["main": function([markerStep()], settings: ["known"])] + let invalidSettings: [[[String: Any]]] = [ + [["name": "known", "value": "one"], ["name": "known", "value": "two"]], + [["name": "invalid name", "value": "one"]], + [["name": "known", "value": NSNull()]], + ] + for settings in invalidSettings { + try configure(functions, settings: settings) + assertFailure(try runCM(["main"])) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + try assertInvalidConfiguration(["main": function([markerStep()], settings: ["missing"])]) + } + + @Test func testSettingsMustBeDeclaredAndCannotConflictWithParameters() throws { + try configure( + ["main": function([markerStep(), printStep("${token}")])], + settings: [["name": "token", "value": "secret"]]) + assertFailure(try runCM(["main"])) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + try configure( + ["main": function([markerStep()], parameters: ["token"], settings: ["token"])], + settings: [["name": "token", "value": "secret"]]) + assertFailure(try runCM(["main", "value"])) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + } + @Test func testInvalidFunctionNamesAndEmptyDescriptionsAreRejected() throws { for name in ["", "two words", "--option", "1number", "name\n"] { try assertInvalidConfiguration([ diff --git a/Tests/CommandManagerTests/Support.swift b/Tests/CommandManagerTests/Support.swift index aa1a39d..e1432c0 100644 --- a/Tests/CommandManagerTests/Support.swift +++ b/Tests/CommandManagerTests/Support.swift @@ -104,15 +104,20 @@ class CMTestCase { } func function( - _ steps: [[String: Any]], parameters: [String] = [], entry: Bool = true, + _ steps: [[String: Any]], parameters: [String] = [], settings: [String] = [], entry: Bool = true, description: String = "Example function" ) -> [String: Any] { - ["description": description, "entryPoint": entry, "parameters": parameters, "steps": steps] + [ + "description": description, "entryPoint": entry, "parameters": parameters, "settings": settings, + "steps": steps, + ] } - func configure(_ functions: [String: [String: Any]]) throws { - try JSONSerialization.data(withJSONObject: ["functions": functions], options: .sortedKeys) - .write(to: config) + func configure(_ functions: [String: [String: Any]], settings: [[String: Any]] = []) throws { + try JSONSerialization.data( + withJSONObject: ["settings": settings, "functions": functions], options: .sortedKeys + ) + .write(to: config) } func executableURL() throws -> URL { diff --git a/Tests/CommandManagerTests/VersionTests.swift b/Tests/CommandManagerTests/VersionTests.swift index 3eff9de..59177ee 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"] { + for minimum in ["0.0", "0.1", "0.1.999", "0.2", "0.2.0", "0.02", "0.3"] { try writeConfiguration(minimum: minimum, steps: [printStep("compatible")]) assertSuccess(try runCM(["main"]), output: "compatible\n") } } @Test func testNewerMinimumVersionsFailBeforeExecution() throws { - for minimum in ["0.2.1", "0.3", "0.10", "1.0", "10.0"] { + for minimum in ["0.3.1", "0.4", "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.2")) + #expect(result.stderr.contains("installed version is 0.3")) #expect(result.stdout.isEmpty) #expect(!FileManager.default.fileExists(atPath: marker.path)) } } @Test func testNewerMinimumVersionAlsoFailsForHelp() throws { - try writeConfiguration(minimum: "0.3", steps: []) + try writeConfiguration(minimum: "0.4", steps: []) assertFailure(try runCM()) assertFailure(try runCM(["--help"])) } diff --git a/cm.swift b/cm.swift index 6139573..86f43f5 100755 --- a/cm.swift +++ b/cm.swift @@ -2,7 +2,7 @@ import Darwin import Foundation -let commandManagerVersion = "0.2" +let commandManagerVersion = "0.3" struct CommandError: Error, CustomStringConvertible { let description: String @@ -86,18 +86,20 @@ 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, steps + case description, entryPoint, parameters, settings, steps } init(from decoder: Decoder) throws { - try rejectUnknownKeys(decoder, allowed: ["description", "entryPoint", "parameters", "steps"]) + 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) } @@ -106,6 +108,22 @@ struct FunctionDefinition: Decodable { } } +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) @@ -206,20 +224,23 @@ struct ArgumentTemplate { } struct Configuration: Decodable { + let settings: [SettingDefinition] let functions: [String: FunctionDefinition] enum CodingKeys: String, CodingKey { - case minimumVersion, functions + 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", "functions"]) + 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) @@ -231,6 +252,16 @@ struct Configuration: Decodable { } } + 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( @@ -247,6 +278,19 @@ struct Configuration: Decodable { "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 { @@ -254,7 +298,7 @@ struct Configuration: Decodable { do { try validateTarget(step) for argument in step.args { - try ArgumentTemplate(argument).validate(parameters: Set(function.parameters)) + try ArgumentTemplate(argument).validate(parameters: Set(function.parameters + function.settings)) } } catch { throw CommandError("Function '\(name)', step \(index + 1): \(error)") @@ -315,6 +359,7 @@ struct Runner { } try requireArguments(arguments, count: function.parameters.count, target: "Function '\(name)'") 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) } @@ -325,6 +370,11 @@ struct Runner { } } + 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) throws { switch target { case .command(let executable): diff --git a/examples/cm.json b/examples/cm.json index 0fc61ff..589f5a9 100644 --- a/examples/cm.json +++ b/examples/cm.json @@ -1,5 +1,11 @@ { - "minimumVersion": "0.2", + "minimumVersion": "0.3", + "settings": [ + { + "name": "FIGMA_TOKEN", + "value": "replace-with-your-token" + } + ], "functions": { "Hello": { "description": "Print a greeting.", @@ -12,6 +18,20 @@ } ] }, + "icons-sync": { + "description": "Sync Figma icons and generate Unify icons.", + "entryPoint": true, + "settings": ["FIGMA_TOKEN"], + "steps": [ + { + "command": "/bin/zsh", + "args": [ + "-c", + "export FIGMA_TOKEN=\"${FIGMA_TOKEN}\" &&\nnode scripts/sync-figma-icons.mjs &&\nnpx nx run unify:generate-unify-icons" + ] + } + ] + }, "GitStatus": { "description": "Show a short status from anywhere in a Git working tree.", "entryPoint": true, From 67163f82371ee1e06767056030d2d3190f7e6280 Mon Sep 17 00:00:00 2001 From: b4prog Date: Thu, 24 Sep 2026 07:13:27 +0200 Subject: [PATCH 2/4] [feat] add environment export steps and redact settings --- README.md | 27 +++++-- .../CommandExecutionTests.swift | 25 +++++++ .../ConfigurationTests.swift | 2 + cm.swift | 73 ++++++++++++++----- examples/cm.json | 38 ++++++++-- 5 files changed, 136 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 2d38185..7a83b6a 100644 --- a/README.md +++ b/README.md @@ -135,11 +135,16 @@ The root object contains a `functions` object and can contain a `settings` array "settings": ["FIGMA_TOKEN"], "steps": [ { - "command": "/bin/zsh", - "args": [ - "-c", - "export FIGMA_TOKEN=\"${FIGMA_TOKEN}\" &&\nnode scripts/sync-figma-icons.mjs &&\nnpx nx run unify:generate-unify-icons" - ] + "builtin": "export", + "args": ["FIGMA_TOKEN", "${FIGMA_TOKEN}"] + }, + { + "command": "node", + "args": ["scripts/sync-figma-icons.mjs"] + }, + { + "command": "npx", + "args": ["nx", "run", "unify:generate-unify-icons"] } ] } @@ -157,7 +162,7 @@ The root object contains a `functions` object and can contain a `settings` array 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. -The `icons-sync` example exports `FIGMA_TOKEN` and then runs the icon synchronization and generation commands in the same shell process, so the token is available to both commands. Its `&&` chain stops at the first failure. 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. +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. @@ -180,7 +185,7 @@ Executables are resolved using `PATH`, or you can specify an executable path. Co 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'`. 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 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. @@ -306,6 +311,14 @@ Succeeds at a Git working tree's root or in one of its subdirectories. Both Git Both assertions ignore `GIT_*` environment overrides for their internal checks, so they inspect the actual current directory even when invoked from a Git hook or alias. Configured command steps still inherit the full environment. +### `export` — environment-variable name and value + +```json +{ "builtin": "export", "args": ["FIGMA_TOKEN", "${FIGMA_TOKEN}"] } +``` + +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. 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`. + ## 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. diff --git a/Tests/CommandManagerTests/CommandExecutionTests.swift b/Tests/CommandManagerTests/CommandExecutionTests.swift index 4037e16..719bb5c 100644 --- a/Tests/CommandManagerTests/CommandExecutionTests.swift +++ b/Tests/CommandManagerTests/CommandExecutionTests.swift @@ -103,6 +103,19 @@ final class CommandExecutionTests: CMTestCase { assertSuccess(try runCM(["main"]), output: "secret-token\n") } + @Test func testSettingsAreRedactedInPrintedCommands() throws { + try configure( + [ + "main": function( + [["command": "/usr/bin/true", "args": ["prefix-${FIGMA_TOKEN}-suffix"]]], + settings: ["FIGMA_TOKEN"]) + ], settings: [["name": "FIGMA_TOKEN", "value": "secret-token"]]) + let result = try runCM(["main"]) + assertSuccess(result) + #expect(result.stdout.contains("prefix-*****-suffix")) + #expect(!result.stdout.contains("secret-token")) + } + @Test func testCalledFunctionsUseTheirOwnDeclaredSettings() throws { try configure( [ @@ -197,6 +210,18 @@ final class CommandExecutionTests: CMTestCase { ) } + @Test func testExportBuiltinSetsEnvironmentForLaterCommands() throws { + try configure( + [ + "main": function( + [ + ["builtin": "export", "args": ["FIGMA_TOKEN", "${FIGMA_TOKEN}"]], + ["command": "/usr/bin/printenv", "args": ["FIGMA_TOKEN"]], + ], settings: ["FIGMA_TOKEN"]) + ], settings: [["name": "FIGMA_TOKEN", "value": "secret-token"]]) + assertSuccess(try runCM(["main"]), output: "secret-token\n") + } + @Test func testCommandOutputChannelsArePreserved() throws { try configure([ "main": function([ diff --git a/Tests/CommandManagerTests/ConfigurationTests.swift b/Tests/CommandManagerTests/ConfigurationTests.swift index d66cc02..5af8c2a 100644 --- a/Tests/CommandManagerTests/ConfigurationTests.swift +++ b/Tests/CommandManagerTests/ConfigurationTests.swift @@ -46,6 +46,8 @@ final class ConfigurationTests: CMTestCase { ["builtin": "inFolder", "args": ["one", "two"]], ["builtin": "assertGitRoot", "args": ["one"]], ["builtin": "assertGitRepository", "args": ["one"]], + ["builtin": "export"], + ["builtin": "export", "args": ["VARIABLE"]], ] for step in invalidSteps { try assertInvalidConfiguration([ diff --git a/cm.swift b/cm.swift index 86f43f5..7b2559f 100755 --- a/cm.swift +++ b/cm.swift @@ -158,9 +158,17 @@ enum Builtin: String, CaseIterable { case inFolder case assertGitRoot case assertGitRepository + case export var argumentCount: Int { - self == .inFolder ? 1 : 0 + switch self { + case .inFolder: + return 1 + case .export: + return 2 + case .assertGitRoot, .assertGitRepository: + return 0 + } } } @@ -353,7 +361,7 @@ func requireArguments(_ arguments: [String], count: Int, target: String) throws struct Runner { let configuration: Configuration - func run(_ name: String, arguments: [String], directory: inout URL) throws { + 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)'.") } @@ -363,7 +371,7 @@ struct Runner { 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: &directory) + try execute(step.target, arguments: args, directory: &directory, environment: &environment) } catch let error as CommandError { throw CommandError("\(name), step \(index + 1): \(error)", status: error.status) } @@ -375,21 +383,25 @@ struct Runner { return Dictionary(uniqueKeysWithValues: function.settings.map { ($0, values[$0]!) }) } - private func execute(_ target: Step.Target, arguments: [String], directory: inout URL) throws { + 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) + try executeCommand(executable, arguments: arguments, directory: directory, environment: environment) case .function(let name): - try run(name, arguments: arguments, directory: &directory) + 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) + try executeBuiltin(builtin, arguments: arguments, directory: &directory, environment: &environment) } } - private func executeBuiltin(_ builtin: Builtin, arguments: [String], directory: inout URL) throws { + 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) @@ -397,6 +409,8 @@ struct Runner { try assertGitRepository(directory, requireRoot: true) case .assertGitRepository: try assertGitRepository(directory, requireRoot: false) + case .export: + try export(name: arguments[0], value: arguments[1], into: &environment) } } @@ -415,14 +429,35 @@ struct Runner { return child } - private func executeCommand(_ executable: String, arguments: [String], directory: URL) throws { - printCommand(executable, arguments: arguments) - let status = try runInheritedCommand(executable, arguments: arguments, directory: directory) + 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 { @@ -485,9 +520,11 @@ func quoteControlCharacter(_ character: Unicode.Scalar) -> String { } /// Inherit the caller's process group so terminal reads and terminal signals work normally. -func runInheritedCommand(_ executable: String, arguments: [String], directory: URL) throws -> Int32 { +func runInheritedCommand( + _ executable: String, arguments: [String], directory: URL, environment: [String: String] +) throws -> Int32 { try validateProcessArguments([executable] + arguments) - let path = try executableURL(executable, directory: directory).path + 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) } @@ -510,7 +547,7 @@ func runInheritedCommand(_ executable: String, arguments: [String], directory: U signal(SIGQUIT, previousQuit) } try withCStringArray([executable] + arguments) { argv in - try withCStringArray(ProcessInfo.processInfo.environment.map { "\($0.key)=\($0.value)" }) { environment 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) } @@ -575,11 +612,11 @@ func validateProcessArguments(_ arguments: [String]) throws { } } -func executableURL(_ executable: String, directory: URL) throws -> URL { +func executableURL(_ executable: String, directory: URL, environment: [String: String]? = nil) throws -> URL { if executable.contains("/") { return URL(fileURLWithPath: executable, relativeTo: directory).absoluteURL } - let searchPath = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" + 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) @@ -720,7 +757,9 @@ func main(_ arguments: [String]) throws { return } var directory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) - try Runner(configuration: configuration).run(name, arguments: options.arguments, directory: &directory) + var environment = ProcessInfo.processInfo.environment + try Runner(configuration: configuration).run( + name, arguments: options.arguments, directory: &directory, environment: &environment) } func handleMissingConfiguration(_ options: Options, path: URL) throws { diff --git a/examples/cm.json b/examples/cm.json index 589f5a9..ebff3c4 100644 --- a/examples/cm.json +++ b/examples/cm.json @@ -24,11 +24,39 @@ "settings": ["FIGMA_TOKEN"], "steps": [ { - "command": "/bin/zsh", - "args": [ - "-c", - "export FIGMA_TOKEN=\"${FIGMA_TOKEN}\" &&\nnode scripts/sync-figma-icons.mjs &&\nnpx nx run unify:generate-unify-icons" - ] + "function": "invoke-in-frontend" + }, + { + "builtin": "export", + "args": ["FIGMA_TOKEN", "${FIGMA_TOKEN}"] + }, + { + "command": "node", + "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"] } ] }, From 5106249d9bbca8931a9acca77e933bcfeee67cc8 Mon Sep 17 00:00:00 2001 From: b4prog Date: Thu, 24 Sep 2026 07:18:58 +0200 Subject: [PATCH 3/4] [fix] restore exported environment after entry points --- README.md | 2 +- cm.swift | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7a83b6a..ce4f3ed 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,7 @@ Both assertions ignore `GIT_*` environment overrides for their internal checks, { "builtin": "export", "args": ["FIGMA_TOKEN", "${FIGMA_TOKEN}"] } ``` -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. 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`. +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`. ## Validation and failures diff --git a/cm.swift b/cm.swift index 7b2559f..283a75f 100755 --- a/cm.swift +++ b/cm.swift @@ -361,6 +361,14 @@ func requireArguments(_ arguments: [String], count: Int, target: String) throws 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)'.") @@ -758,7 +766,7 @@ func main(_ arguments: [String]) throws { } var directory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) var environment = ProcessInfo.processInfo.environment - try Runner(configuration: configuration).run( + try Runner(configuration: configuration).runEntryPoint( name, arguments: options.arguments, directory: &directory, environment: &environment) } From 5f4694703137ab13a59d3ab892ee00b5c0891593 Mon Sep 17 00:00:00 2001 From: b4prog Date: Thu, 24 Sep 2026 07:28:07 +0200 Subject: [PATCH 4/4] [fix] scope directory changes to function calls --- README.md | 24 ++++++++++--------- .../DirectoryAndGitTests.swift | 14 ++++++----- cm.swift | 5 ++-- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ce4f3ed..87a7294 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ Substitution applies only to `args`, not to executable names, function names, bu { "builtin": "inFolder", "args": ["MyPackage"] } ``` -`inFolder` changes the working directory for the remaining execution of the entry point. The change applies to the current function, its callers when they resume, and later function calls. Each time this step is reached: +`inFolder` changes the working directory for the remaining steps of the current function and any functions it calls. When the current function returns, its caller's directory is restored. Each time this step is reached: 1. If the current directory's name is already `MyPackage`, it does nothing. 2. Otherwise, it enters a direct child directory named `MyPackage`. @@ -275,23 +275,25 @@ Substitution applies only to `args`, not to executable names, function names, bu The argument must be a single folder name. Empty names, `.`, `..`, absolute paths, and names containing `/` are rejected. It does not search ancestors or arbitrary descendants. -An entry point and all functions it calls share one working directory. A directory change made by a helper persists after that helper returns: later steps in its caller and later sibling functions continue from that directory. Calling `inFolder` several times can descend one folder at a time, whether the calls are in the same function or different functions: +Each function starts in its caller's directory. A directory change made by a helper is available to nested calls, but it does not leak back to the caller or sibling functions: ```text Entry point starts in /work - inFolder("App") → /work/App Call Prepare - inFolder("Packages") → /work/App/Packages - inFolder("Core") → /work/App/Packages/Core - Prepare returns → /work/App/Packages/Core - Entry point's next step → /work/App/Packages/Core + inFolder("App") → /work/App + Prepare's command → /work/App + Call Build + inFolder("Core") → /work/App/Core + Build's command → /work/App/Core + Build returns → /work/App + Prepare's next command → /work/App + Prepare returns → /work Call Check - inFolder("Core") → /work/App/Packages/Core (already there) - Check's next command → /work/App/Packages/Core + Check's command → /work Entry point finishes; the launching terminal is still in /work ``` -A command that runs `cd` inside a shell changes only that shell's directory. Use `inFolder` to affect subsequent CommandManager steps. The shared directory context lasts until the entry point finishes, whether successfully or with an error. CommandManager does not change the directory of the terminal that launched it. +A command that runs `cd` inside a shell changes only that shell's directory. Use `inFolder` to affect subsequent steps in the same function and its nested calls. CommandManager does not change the directory of the terminal that launched it. ### `assertGitRoot` — no arguments @@ -325,7 +327,7 @@ CommandManager validates the whole configuration before running any step, includ 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. -Directory changes persist throughout the entry point's call hierarchy and end when that entry point finishes. Other effects are not rolled back: files written by an earlier command remain if a later command fails. There are no automatic retries, parallel steps, or continue-on-error options. +Directory changes are scoped to the function that makes them and its nested calls; a caller's directory is restored when a helper returns. Other effects are not rolled back: files written by an earlier command remain if a later command fails. There are no automatic retries, parallel steps, or continue-on-error options. ## Add a built-in in Swift diff --git a/Tests/CommandManagerTests/DirectoryAndGitTests.swift b/Tests/CommandManagerTests/DirectoryAndGitTests.swift index 5caec4a..508f576 100644 --- a/Tests/CommandManagerTests/DirectoryAndGitTests.swift +++ b/Tests/CommandManagerTests/DirectoryAndGitTests.swift @@ -26,24 +26,26 @@ final class DirectoryAndGitTests: CMTestCase { try runCM(["main"]), output: "\(directory.path)\n\(child.path)\n\(grandchild.path)\n") } - @Test func testDirectoryChangesContinueThroughTheEntryPoint() throws { + @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"], - ["function": "sibling"], ["command": "/bin/pwd"], ]), "helper": function( - [["builtin": "inFolder", "args": ["child"]], ["command": "/bin/pwd"]], + [ + ["builtin": "inFolder", "args": ["child"]], ["command": "/bin/pwd"], + ["function": "nested"], ["command": "/bin/pwd"], + ], entry: false), - "sibling": function( + "nested": function( [ - ["command": "/bin/pwd"], ["builtin": "inFolder", "args": ["grandchild"]], + ["builtin": "inFolder", "args": ["grandchild"]], ["command": "/bin/pwd"], ], entry: false), ]) - let expected = [directory.path, child.path, child.path, child.path, grandchild.path, grandchild.path] + 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/cm.swift b/cm.swift index 283a75f..6d358ec 100755 --- a/cm.swift +++ b/cm.swift @@ -357,7 +357,7 @@ func requireArguments(_ arguments: [String], count: Int, target: String) throws } } -/// All calls share the entry point's directory; the host process never changes cwd. +/// Each function inherits its caller's directory and restores it when the function returns. struct Runner { let configuration: Configuration @@ -374,12 +374,13 @@ struct Runner { 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: &directory, environment: &environment) + 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) }