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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 50 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ The optional root-level `minimumVersion` field specifies the oldest compatible C

```json
{
"minimumVersion": "0.2",
"minimumVersion": "0.3",
"functions": {}
}
```
Expand All @@ -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.

Expand All @@ -117,21 +119,32 @@ 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}"]
"builtin": "export",
"args": ["FIGMA_TOKEN", "${FIGMA_TOKEN}"]
},
{
"command": "node",
"args": ["scripts/sync-figma-icons.mjs"]
},
{
"command": "npx",
"args": ["nx", "run", "unify:generate-unify-icons"]
}
]
}
Expand All @@ -144,9 +157,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 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.

Expand All @@ -169,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.

Expand Down Expand Up @@ -230,7 +246,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
{
Expand All @@ -251,31 +267,33 @@ 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`.
3. If that child directory does not exist, the function fails.

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

Expand All @@ -295,13 +313,21 @@ 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. 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

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.

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

Expand Down
45 changes: 43 additions & 2 deletions Tests/CommandManagerTests/CommandExecutionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -96,6 +96,35 @@ 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 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(
[
"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"] {
Expand Down Expand Up @@ -181,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([
Expand Down
30 changes: 30 additions & 0 deletions Tests/CommandManagerTests/ConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -68,6 +70,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([
Expand Down
14 changes: 8 additions & 6 deletions Tests/CommandManagerTests/DirectoryAndGitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
Loading
Loading