@runfile/cli 0.47.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +671 -699
- package/bin/darwin-arm64/run +0 -0
- package/bin/darwin-arm64/runfile-lsp +0 -0
- package/bin/darwin-x64/run +0 -0
- package/bin/darwin-x64/runfile-lsp +0 -0
- package/bin/linux-arm64/run +0 -0
- package/bin/linux-arm64/runfile-lsp +0 -0
- package/bin/linux-x64/run +0 -0
- package/bin/linux-x64/runfile-lsp +0 -0
- package/bin/run.js +10 -7
- package/bin/runfile-lsp.js +32 -0
- package/bin/win32-arm64/run.exe +0 -0
- package/bin/win32-arm64/runfile-lsp.exe +0 -0
- package/bin/win32-x64/run.exe +0 -0
- package/bin/win32-x64/runfile-lsp.exe +0 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,835 +1,807 @@
|
|
|
1
1
|
# Runfile
|
|
2
2
|
|
|
3
|
-
[Quick start](#quick-start) · [
|
|
3
|
+
[Quick start](#quick-start) · [Examples](#what-a-runfile-looks-like) · [Language](#the-language) · [Properties](#properties) · [Commands](#commands) · [Editors](#editor-support)
|
|
4
4
|
|
|
5
|
-
**One
|
|
5
|
+
**One directory. One binary. Every OS.**
|
|
6
6
|
|
|
7
|
-
A
|
|
7
|
+
A command runner that replaces Makefiles, shell scripts and `npm run` — without the platform headaches.
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
```jsonc
|
|
14
|
-
{
|
|
15
|
-
"$schema": "https://github.com/JoaaoVerona/runfile/releases/latest/download/v0.schema.json",
|
|
16
|
-
"targets": {
|
|
17
|
-
"dev": {
|
|
18
|
-
"description": "Starts the dev server",
|
|
19
|
-
"commands": "vite",
|
|
20
|
-
"env": { "PORT": "{{ ARG.port ? '3000' }}" }
|
|
21
|
-
},
|
|
22
|
-
"build": {
|
|
23
|
-
"description": "Type-checks and builds in parallel",
|
|
24
|
-
"commands": [
|
|
25
|
-
"@type-check",
|
|
26
|
-
"vite build",
|
|
27
|
-
{
|
|
28
|
-
"if": "{{ RUN.os == 'windows' && FLAG.wsl }}",
|
|
29
|
-
"then": "wsl --shell-type login -- vite build"
|
|
30
|
-
}
|
|
31
|
-
],
|
|
32
|
-
"envFiles": [".env", ".env.{{ ARG.env ? 'development' }}"],
|
|
33
|
-
"parallel": true
|
|
34
|
-
},
|
|
35
|
-
"type-check": {
|
|
36
|
-
"description": "Type-checks source code",
|
|
37
|
-
"commands": "tsc --noEmit"
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
10
|
+
run dev --port=4000
|
|
41
11
|
```
|
|
42
12
|
|
|
43
|
-
|
|
13
|
+
Your project's tasks live in a `runfiles/` directory, **one target per file**:
|
|
44
14
|
|
|
45
|
-
|
|
15
|
+
```
|
|
16
|
+
runfiles/
|
|
17
|
+
_shared.run settings every target here inherits
|
|
18
|
+
dev.run → run dev
|
|
19
|
+
build.run → run build
|
|
20
|
+
check.run → run check
|
|
21
|
+
api/deploy.run → run api:deploy
|
|
22
|
+
web/runfiles/build.run → run web:build
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A target is a file, its name is its path, and its first comment block is its description. There is no central
|
|
26
|
+
file to merge, so two people adding a task never conflict.
|
|
46
27
|
|
|
47
|
-
|
|
28
|
+
```sh
|
|
29
|
+
# runfiles/dev.run
|
|
30
|
+
# Start the dev server
|
|
48
31
|
|
|
49
|
-
|
|
32
|
+
.env-file = ".env.local"
|
|
33
|
+
.env.PORT = ARG.port ? "3000"
|
|
34
|
+
|
|
35
|
+
$ vite
|
|
36
|
+
```
|
|
50
37
|
|
|
51
38
|
```bash
|
|
52
|
-
|
|
39
|
+
$ run :list
|
|
40
|
+
build Type-check and build
|
|
41
|
+
check Non-mutating gate
|
|
42
|
+
dev Start the dev server
|
|
53
43
|
```
|
|
54
44
|
|
|
55
|
-
|
|
45
|
+
## Quick start
|
|
56
46
|
|
|
57
47
|
```bash
|
|
58
48
|
curl -fsSL https://github.com/JoaaoVerona/runfile/releases/latest/download/install.sh | sh
|
|
49
|
+
run :init # creates runfiles/ with an example
|
|
50
|
+
run :list # every target, with descriptions
|
|
51
|
+
run <target> --help # what one target does, and what it reads
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
run build --env=prod # an argument for the target
|
|
56
|
+
run --dry-run build # print what would run, without running it
|
|
57
|
+
run :completions install bash # tab-completion (or zsh, fish, powershell)
|
|
59
58
|
```
|
|
60
59
|
|
|
61
|
-
|
|
60
|
+
Runner flags go **before** the target name; everything after it belongs to the target. `run build --dry-run`
|
|
61
|
+
passes `--dry-run` to `build` as `FLAG.dry-run` and runs it for real — so the position is the whole meaning.
|
|
62
|
+
`run` warns when a target is handed a flag it never reads, which is what catches that.
|
|
63
|
+
|
|
64
|
+
In CI, the setup action installs `run` and puts it on PATH, so every step after it is a target name:
|
|
62
65
|
|
|
63
|
-
```
|
|
64
|
-
|
|
66
|
+
```yaml
|
|
67
|
+
- name: Setup Runfile
|
|
68
|
+
uses: JoaaoVerona/runfile/.github/actions/setup@v1
|
|
69
|
+
|
|
70
|
+
- run: run check
|
|
71
|
+
- run: run test
|
|
65
72
|
```
|
|
66
73
|
|
|
67
|
-
|
|
74
|
+
`@v1` is the major alias, moved to each release as it ships — so a fix arrives without editing every
|
|
75
|
+
workflow, and a new major never arrives unannounced. Pin `@v1.0.0` instead to hold one exact version.
|
|
68
76
|
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
## What a runfile looks like
|
|
78
|
+
|
|
79
|
+
These are complete files. Nothing is elided.
|
|
80
|
+
|
|
81
|
+
### One command that works on three operating systems
|
|
82
|
+
|
|
83
|
+
`match` on `RUN.os` instead of shipping three scripts and a wrapper.
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
# runfiles/setup/system.run
|
|
87
|
+
# Install the OS-level build prerequisites
|
|
88
|
+
|
|
89
|
+
match RUN.os
|
|
90
|
+
case "linux"
|
|
91
|
+
$ sudo apt-get update
|
|
92
|
+
$ sudo apt-get install -y build-essential libssl-dev pkg-config
|
|
93
|
+
case "mac"
|
|
94
|
+
$ xcode-select --install || true
|
|
95
|
+
$ brew install openssl pkg-config
|
|
96
|
+
default
|
|
97
|
+
print("No system setup for {{ RUN.os }} — see docs/prerequisites.md")
|
|
98
|
+
end
|
|
71
99
|
```
|
|
72
100
|
|
|
73
|
-
|
|
101
|
+
### A gate that names the file that failed
|
|
74
102
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
103
|
+
The loop is the language's, so a failure reports the one command that broke rather than the whole script.
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
# runfiles/check.run
|
|
107
|
+
# Non-mutating gate: every compose file is valid
|
|
108
|
+
|
|
109
|
+
for compose in glob("**/docker-compose.yml")
|
|
110
|
+
$ docker compose -f {{ compose }} config -q
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
print("check: every docker-compose.yml is valid")
|
|
80
114
|
```
|
|
81
115
|
|
|
82
|
-
|
|
116
|
+
```
|
|
117
|
+
[runfile] error: `docker compose -f observability/loki/docker-compose.yml config -q` exited with status 1
|
|
118
|
+
```
|
|
83
119
|
|
|
84
|
-
|
|
85
|
-
|
|
120
|
+
### Waiting for something to come up
|
|
121
|
+
|
|
122
|
+
`retry` replaces the `until … do sleep … done` loop, and counts the attempts for you.
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
# runfiles/db/wait.run
|
|
126
|
+
# Block until Postgres answers, then load the seed data
|
|
127
|
+
|
|
128
|
+
retry 120 every 1
|
|
129
|
+
$ docker exec db pg_isready -U app -h 127.0.0.1
|
|
130
|
+
else
|
|
131
|
+
error("the database never became ready")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
$ docker exec -i db psql -U app < seed.sql
|
|
86
135
|
```
|
|
87
136
|
|
|
88
|
-
|
|
137
|
+
### Doing several things at once
|
|
89
138
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
139
|
+
`.parallel` on a `for` fans out its iterations, and labels every line so you can tell them apart.
|
|
140
|
+
|
|
141
|
+
It is block-scoped, so **where it sits is what it covers**: inside the loop it parallelises the iterations and
|
|
142
|
+
nothing else, and at the top of the file it would cover every command in the target.
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
# runfiles/pull.run
|
|
146
|
+
# Pull every service image
|
|
147
|
+
|
|
148
|
+
for compose in glob("**/docker-compose.yml")
|
|
149
|
+
.parallel
|
|
150
|
+
.ignore-errors
|
|
151
|
+
|
|
152
|
+
$ docker compose -f {{ compose }} pull
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
[docker] api Pulling
|
|
158
|
+
[docker] web Pulling
|
|
159
|
+
[docker] postgres Pull complete
|
|
93
160
|
```
|
|
94
161
|
|
|
95
|
-
|
|
96
|
-
installs are handled too: on Linux/macOS `:update` runs `npm install -g @runfile/cli@latest` for you; on Windows it
|
|
97
|
-
prints that command to run from a fresh shell (npm can't overwrite the running `run.exe` there).
|
|
162
|
+
### JSON without the backslashes
|
|
98
163
|
|
|
99
|
-
|
|
164
|
+
`json … end` is a block of JSON as a value. An interpolation inside becomes **one JSON value** — quoted and
|
|
165
|
+
escaped if it is a string, an array if it is a list — so nothing is escaped by hand.
|
|
100
166
|
|
|
101
|
-
|
|
167
|
+
```sh
|
|
168
|
+
# runfiles/bucket/policy.run
|
|
169
|
+
# Apply the storage policy to a bucket
|
|
102
170
|
|
|
103
|
-
|
|
104
|
-
in sync. It runs `run :generate task-descriptors` and turns the result into real VS Code tasks plus a
|
|
105
|
-
**Runfile → Targets** sidebar, complete with namespace folders and a **Globals** bucket for machine-wide targets.
|
|
171
|
+
let bucket = one_of(ARG.bucket, "app-uploads", "app-backups")
|
|
106
172
|
|
|
107
|
-
|
|
108
|
-
|
|
173
|
+
let policy = json
|
|
174
|
+
{
|
|
175
|
+
"Version": "2012-10-17",
|
|
176
|
+
"Statement": [
|
|
177
|
+
{
|
|
178
|
+
"Effect": "Allow",
|
|
179
|
+
"Action": [
|
|
180
|
+
"s3:PutObject",
|
|
181
|
+
"s3:GetObject"
|
|
182
|
+
],
|
|
183
|
+
"Resource": {{ concat("arn:aws:s3:::", bucket, "/*") }},
|
|
184
|
+
"MaxKeys": {{ number(ARG.limit ? "1000") }}
|
|
185
|
+
}
|
|
186
|
+
]
|
|
187
|
+
}
|
|
188
|
+
end
|
|
109
189
|
|
|
110
|
-
|
|
111
|
-
code --install-extension runfile-vscode-<version>.vsix
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
(or, in VS Code: **Extensions → … → Install from VSIX…**)
|
|
115
|
-
|
|
116
|
-
Targets then show up in **Terminal → Run Task…** and in the Runfile sidebar. Every task is invoked with
|
|
117
|
-
`--stdin-args`, so targets that need input prompt for it right in the task terminal. Nothing is written to your
|
|
118
|
-
repository.
|
|
119
|
-
|
|
120
|
-
The source lives in [`editors/vscode`](editors/vscode) — see its [README](editors/vscode/README.md) for settings,
|
|
121
|
-
commands, and how the sidebar groups targets.
|
|
122
|
-
|
|
123
|
-
---
|
|
124
|
-
|
|
125
|
-
## Why Runfile?
|
|
126
|
-
|
|
127
|
-
Other runners assume one platform and one shell. Runfile assumes **none**.
|
|
128
|
-
|
|
129
|
-
Sorted from where Runfile is most differentiated to where it lags behind.
|
|
130
|
-
|
|
131
|
-
| Feature | Runfile | Make | Just | Taskfile |
|
|
132
|
-
|---------------------------------------------------------------------|:-------:|:----:|:----:|:--------:|
|
|
133
|
-
| Encrypted env vars, built-in (AES-256-GCM) | ✅ | ❌ | ❌ | ❌ |
|
|
134
|
-
| MCP server (AI agent integration) | ✅ | ❌ | ❌ | ❌ |
|
|
135
|
-
| `when:` blocks for success/failure/always cleanup | ✅ | ❌ | ❌ | ❌ |
|
|
136
|
-
| Inline OS / shell / cwd / paths / debug branching via `{{ RUN.* }}` | ✅ | ❌ | ❌ | ❌ |
|
|
137
|
-
| Detached background execution | ✅ | ❌ | ❌ | ❌ |
|
|
138
|
-
| IDE task generation (VS Code / Zed / JetBrains) | ✅ | ❌ | ❌ | ❌ |
|
|
139
|
-
| Migrate from `package.json` / `Makefile` | ✅ | ❌ | ❌ | ❌ |
|
|
140
|
-
| Force-kill process tree on SIGINT (GUI apps) | ✅ | ❌ | ❌ | ❌ |
|
|
141
|
-
| Stdio log tailers (`extendStdio`) | ✅ | ❌ | ❌ | ❌ |
|
|
142
|
-
| First-class PowerShell / cmd.exe | ✅ | ❌ | ✅ | ❌ |
|
|
143
|
-
| Fish shell support | ✅ | ❌ | ✅ | ❌ |
|
|
144
|
-
| Per-target shell override | ✅ | ❌ | ✅ | ❌ |
|
|
145
|
-
| Strict parsing (typos are errors) | ✅ | ❌ | ✅ | ❌ |
|
|
146
|
-
| Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
|
|
147
|
-
| JSON Schema autocomplete in editors | ✅ | ❌ | ❌ | ✅ |
|
|
148
|
-
| Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
|
|
149
|
-
| Output grouping / prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
|
|
150
|
-
| Native Windows (no WSL / msys2 needed) | ✅ | ❌ | ✅ | ✅ |
|
|
151
|
-
| Shell completions | ✅ | ❌ | ✅ | ✅ |
|
|
152
|
-
| Private / internal targets (`_name`) | ✅ | ❌ | ✅ | ✅ |
|
|
153
|
-
| Built-in string functions (upper, replace, trim, regex, base64, …) | ✅ | ❌ | ✅ | ✅ |
|
|
154
|
-
| Parallel execution | ✅ | ✅ | ❌ | ✅ |
|
|
155
|
-
| Single static binary | ✅ | ✅ | ✅ | ✅ |
|
|
156
|
-
| Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
|
|
157
|
-
| Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
|
|
158
|
-
| Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
|
|
159
|
-
|
|
160
|
-
---
|
|
161
|
-
|
|
162
|
-
## What makes it different
|
|
163
|
-
|
|
164
|
-
#### Cross-shell, not just cross-platform
|
|
165
|
-
|
|
166
|
-
Auto-detects the shell, or pin one per target. The same Runfile works for a teammate using `bash` on Linux and another
|
|
167
|
-
using PowerShell on Windows.
|
|
168
|
-
|
|
169
|
-
```jsonc
|
|
170
|
-
{
|
|
171
|
-
"targets": {
|
|
172
|
-
"ps": { "commands": ["Write-Host 'hi'"], "forceShell": "powershell" },
|
|
173
|
-
"bash": { "commands": ["echo $BASH_VERSION"], "forceShell": "bash" }
|
|
174
|
-
}
|
|
175
|
-
}
|
|
190
|
+
run _aws -- s3api put-bucket-policy --bucket {{ bucket }} --policy {{ policy }}
|
|
176
191
|
```
|
|
177
192
|
|
|
178
|
-
|
|
193
|
+
`run :format` lays the block out, and a missing brace is underlined in your editor as you type — not reported
|
|
194
|
+
by the far end an hour later.
|
|
179
195
|
|
|
180
|
-
|
|
181
|
-
tool, no wrapper.
|
|
196
|
+
### Any language you have installed
|
|
182
197
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
198
|
+
`exec <command>` runs that command with the block as its **stdin**, so a target can be a Python script, a Node
|
|
199
|
+
script, a SQL file — whatever is already on the machine. The body is that command's language, not this one.
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
# runfiles/i18n/check.run
|
|
203
|
+
# Check that every translation catalogue has the same keys as the reference
|
|
204
|
+
|
|
205
|
+
.env.LOCALES = "src/locales"
|
|
206
|
+
|
|
207
|
+
exec python3
|
|
208
|
+
import json, os, pathlib, sys
|
|
209
|
+
|
|
210
|
+
files = sorted(pathlib.Path(os.environ["LOCALES"]).glob("*.json"))
|
|
211
|
+
catalogues = {f.stem: set(json.loads(f.read_text())) for f in files}
|
|
212
|
+
reference = catalogues.pop("en")
|
|
213
|
+
|
|
214
|
+
for name, keys in catalogues.items():
|
|
215
|
+
missing = reference - keys
|
|
216
|
+
if missing:
|
|
217
|
+
sys.exit(f"{name}: missing {', '.join(sorted(missing))}")
|
|
218
|
+
|
|
219
|
+
print(f"i18n: {len(files)} catalogues agree on {len(reference)} keys")
|
|
220
|
+
end
|
|
187
221
|
```
|
|
188
222
|
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
|
|
223
|
+
```sh
|
|
224
|
+
# runfiles/deps/list.run
|
|
225
|
+
# Print the first few runtime dependencies
|
|
226
|
+
|
|
227
|
+
.env.LIMIT = ARG.limit ? "10"
|
|
228
|
+
|
|
229
|
+
exec node
|
|
230
|
+
const { readFileSync } = require("node:fs")
|
|
231
|
+
|
|
232
|
+
const pkg = JSON.parse(readFileSync("package.json", "utf8"))
|
|
233
|
+
const deps = Object.keys(pkg.dependencies ?? {})
|
|
234
|
+
|
|
235
|
+
for (const name of deps.slice(0, Number(process.env.LIMIT))) {
|
|
236
|
+
console.log(` ${name}`)
|
|
237
|
+
}
|
|
238
|
+
end
|
|
192
239
|
```
|
|
193
240
|
|
|
194
|
-
|
|
195
|
-
|
|
241
|
+
```sh
|
|
242
|
+
# runfiles/db/report.run
|
|
243
|
+
# Row counts for the tables that matter
|
|
196
244
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
245
|
+
exec psql --quiet {{ ENV.DATABASE_URL }}
|
|
246
|
+
select 'accounts' as table, count(*) from accounts
|
|
247
|
+
union all
|
|
248
|
+
select 'orders', count(*) from orders
|
|
249
|
+
order by 1;
|
|
250
|
+
end
|
|
201
251
|
```
|
|
202
252
|
|
|
203
|
-
In
|
|
204
|
-
|
|
205
|
-
repos that keep their encrypted `.env` in a GitHub secret):
|
|
253
|
+
In value position it captures what the command printed, so another language can answer a question this one
|
|
254
|
+
then acts on:
|
|
206
255
|
|
|
207
|
-
```
|
|
208
|
-
|
|
256
|
+
```sh
|
|
257
|
+
# runfiles/coverage/gate.run
|
|
258
|
+
# Fail if line coverage dropped below the floor
|
|
259
|
+
|
|
260
|
+
let percent = exec python3
|
|
261
|
+
import pathlib, re
|
|
262
|
+
|
|
263
|
+
text = pathlib.Path("lcov.info").read_text()
|
|
264
|
+
hit = len(re.findall(r"^DA:\d+,[1-9]", text, re.M))
|
|
265
|
+
total = len(re.findall(r"^DA:", text, re.M))
|
|
266
|
+
|
|
267
|
+
print(round(100 * hit / total))
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
if number(percent) < 85
|
|
271
|
+
error("coverage is {{ percent }}%, below the 85% floor")
|
|
272
|
+
end
|
|
209
273
|
```
|
|
210
274
|
|
|
211
|
-
|
|
275
|
+
One thing to know: the body **is** the command's stdin, so a script cannot also read data from stdin — give it
|
|
276
|
+
what it needs through `.env`, a file, or the command's own arguments.
|
|
277
|
+
|
|
278
|
+
An interpolation inside the body arrives **as itself**, unquoted, when the command is not a shell — shell
|
|
279
|
+
quoting is the wrong quoting for anybody else's language, so quote it the way that language wants. A shell
|
|
280
|
+
body still self-quotes, and `$ cp {{ src }} {{ dst }}` is as safe as it ever was.
|
|
212
281
|
|
|
213
|
-
|
|
214
|
-
`{{ RUN.cwd }}` / `{{ RUN.file }}` / `{{ RUN.parent }}`) — with chained fallbacks and required values. The
|
|
215
|
-
`{{ ... }}` syntax avoids collisions with shell `$(...)` command substitution.
|
|
282
|
+
### Secrets that never touch the disk
|
|
216
283
|
|
|
217
|
-
|
|
218
|
-
|
|
284
|
+
```sh
|
|
285
|
+
# runfiles/deploy.run
|
|
286
|
+
# Deploy to production
|
|
219
287
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
"
|
|
224
|
-
|
|
225
|
-
{ "if": "{{ RUN.os == 'windows' }}", "then": ["del /S /Q build"], "else": ["rm -rf build"] },
|
|
226
|
-
// User-supplied flags branch through `{{ FLAG.x }}` (resolves to "true" / "false"):
|
|
227
|
-
{ "if": "{{ FLAG.debug }}", "then": ["./tool --verbose"], "else": ["./tool"] },
|
|
228
|
-
// workingDirectory is a free-form path (defaults to {{ RUN.parent }}):
|
|
229
|
-
"workingDirectory": "{{ ARG.workdir ? RUN.cwd }}"
|
|
288
|
+
.env-file = ".env.production"
|
|
289
|
+
.env.GOOGLE_APPLICATION_CREDENTIALS = temp_file(base64_decode(ENV.SERVICE_ACCOUNT_B64), "json")
|
|
290
|
+
|
|
291
|
+
confirm("Deploy to production?")
|
|
292
|
+
$ terraform apply -auto-approve
|
|
230
293
|
```
|
|
231
294
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
295
|
+
The temp file is deleted when the run ends, however it ends — including a failure half-way, which is exactly
|
|
296
|
+
when a decoded credential must not be left behind.
|
|
297
|
+
|
|
298
|
+
### A pre-commit hook
|
|
236
299
|
|
|
237
|
-
|
|
300
|
+
```sh
|
|
301
|
+
# runfiles/precommit.run
|
|
302
|
+
# Format staged files and re-stage them
|
|
238
303
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
but you can use it anywhere:
|
|
304
|
+
let staged = $ git diff --cached --name-only --diff-filter=ACMR -- "*.rs"
|
|
305
|
+
let files = lines(staged)
|
|
242
306
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
"else": ["./deploy-staging.sh"] },
|
|
307
|
+
if length(files) > 0
|
|
308
|
+
$ rustfmt {{ files }}
|
|
309
|
+
$ git add {{ files }}
|
|
310
|
+
end
|
|
311
|
+
```
|
|
249
312
|
|
|
250
|
-
|
|
251
|
-
{ "if": "{{ ARG.env != 'development' && ARG.env != 'production' }}",
|
|
252
|
-
"then": ["./deploy-staging.sh"] },
|
|
313
|
+
`files` is a list, so `{{ files }}` becomes one argument per file — spaces in names and all.
|
|
253
314
|
|
|
254
|
-
|
|
255
|
-
// comparison helpers (`less_than`, `is_number`, …) and `contains` etc.:
|
|
256
|
-
{ "if": "{{ !is_number(ARG.port) }}",
|
|
257
|
-
"then": ["echo 'port must be a number' && exit 1"] },
|
|
315
|
+
### Re-running on change
|
|
258
316
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
317
|
+
```sh
|
|
318
|
+
# runfiles/watch.run
|
|
319
|
+
# Recompile whenever a source file changes
|
|
262
320
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
321
|
+
.watch = "src/**/*.rs"
|
|
322
|
+
.watch = "!src/generated/**"
|
|
323
|
+
|
|
324
|
+
run build
|
|
266
325
|
```
|
|
267
326
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
`
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
// encoding : base64_encode, base64_decode, url_encode, url_decode
|
|
302
|
-
// hashing : sha256, md5
|
|
303
|
-
// ids/time : uuid, now
|
|
304
|
-
// files : read_file, write_file, file_exists, temp_file, temp_dir
|
|
305
|
-
// json : json_get, json_set
|
|
306
|
-
// control : try, error
|
|
307
|
-
// shell : shell_quote, capture
|
|
308
|
-
// variables : define
|
|
309
|
-
// cwd : set_cwd
|
|
310
|
-
"echo deploying-{{ to_upper(ARG.env) }}",
|
|
311
|
-
"curl -H \"X-Auth: {{ base64_encode(ENV.TOKEN) }}\" ...",
|
|
312
|
-
"echo {{ concat('hello-', ARG.name, '-2026') }}",
|
|
313
|
-
"echo {{ join(' AND ', 'flag-1', 'flag-2', ARG.extra) }}",
|
|
314
|
-
// Tokenise-and-rejoin-style transforms via replace_all:
|
|
315
|
-
"go test {{ replace_all(ARG.flags, ' ', ' -tag=') }}",
|
|
316
|
-
// Strip every match of a regex (here: collapse whitespace runs to a single space):
|
|
317
|
-
"echo {{ regex_replace(ARG.text, '\\s+', ' ') }}",
|
|
318
|
-
// Pull a capture group out of the first regex match — no `(?s)^.*X(...)X.*$`
|
|
319
|
-
// greedy-replace trick needed. Group 0 is the whole match; group N is the
|
|
320
|
-
// N-th `(...)`. Out-of-bounds returns "" (same convention as `nth`).
|
|
321
|
-
"echo version={{ regex_capture(read_file('app/build.gradle.kts'), 'versionName = \"([^\"]+)\"', '1') }}",
|
|
322
|
-
// Pull group N out of EVERY match and join with a separator (the "all" variant
|
|
323
|
-
// of regex_capture). Here: every quoted dependency name, comma-separated.
|
|
324
|
-
"echo deps={{ regex_capture_all(read_file('deps.txt'), 'name = \"([^\"]+)\"', '1', ',') }}",
|
|
325
|
-
// Split-by-separator scalar accessors — string in, string out, no list type:
|
|
326
|
-
// basename idiom (last segment after `/`); empty string when input ends in `/`.
|
|
327
|
-
"echo basename={{ last(ARG.path, '/') }}",
|
|
328
|
-
// Pull out the N-th comma-separated field; out-of-bounds returns "".
|
|
329
|
-
"echo target={{ nth(ARG.csv, ',', '1') }}",
|
|
330
|
-
// Pair `count_parts` with `nth` to bound-check before indexing:
|
|
331
|
-
// "if": "{{ count_parts(ARG.csv, ',') == '3' }}"
|
|
332
|
-
// Boolean-returning helpers are valid DSL `Truthy` values — use them in `if`:
|
|
333
|
-
// "if": "{{ starts_with(ARG.path, '/usr') }}"
|
|
334
|
-
// "if": "{{ regex_matches(ARG.tag, '^v[0-9]+\\.[0-9]+$') }}"
|
|
335
|
-
|
|
336
|
-
// Safely inline arbitrary content (newlines, quotes, JSON) as a CLI arg —
|
|
337
|
-
// `shell_quote` picks the right quoting for the active shell:
|
|
338
|
-
"some-tool --json {{ shell_quote(base64_decode(ENV.SECRET_BASE64)) }}",
|
|
339
|
-
|
|
340
|
-
// Slurp shell-command stdout straight into a substitution. `capture` runs
|
|
341
|
-
// the command through the platform's default shell (sh / cmd) and trims
|
|
342
|
-
// the trailing newline. Results are memoized per-target so the same
|
|
343
|
-
// capture in multiple commands runs once. `--dry-run` substitutes a
|
|
344
|
-
// readable placeholder instead of spawning the shell.
|
|
345
|
-
"echo built-at={{ capture('date -u +%Y-%m-%dT%H:%M:%SZ') }}",
|
|
346
|
-
"{{ define(sha, capture('git rev-parse HEAD')) }}",
|
|
347
|
-
|
|
348
|
-
// Arithmetic — variadic (2+ args), coerces strings to numbers, errors on
|
|
349
|
-
// non-numeric input. The result is formatted as an integer when whole and
|
|
350
|
-
// as a decimal otherwise (so `add('5', '3')` → "8", `add('5', '1.1')` →
|
|
351
|
-
// "6.1"). Divide-by-zero errors out.
|
|
352
|
-
"echo next-build={{ add(VAR.versionCode, '1') }}",
|
|
353
|
-
"echo half={{ divide(VAR.total, '2') }}",
|
|
354
|
-
// …plus modulo, power, min, max, abs, round, floor, ceil:
|
|
355
|
-
"echo shard={{ modulo(ARG.id, '8') }} clamped={{ max('0', min('100', ARG.pct)) }}",
|
|
356
|
-
|
|
357
|
-
// Path helpers (std::path semantics). basename/dirname/extname/stem/join_path:
|
|
358
|
-
"echo name={{ basename(ARG.file) }} ext={{ extname(ARG.file) }}",
|
|
359
|
-
"cp {{ ARG.file }} {{ join_path(ARG.outdir, basename(ARG.file)) }}",
|
|
360
|
-
|
|
361
|
-
// Substring (char-indexed; 3rd arg = length, optional → to end). Short SHA:
|
|
362
|
-
"echo short={{ substring(capture('git rev-parse HEAD'), '0', '7') }}",
|
|
363
|
-
|
|
364
|
-
// Current UTC time. Formats: unix-timestamp, unix-millis, iso, iso-date,
|
|
365
|
-
// iso-time, rfc3339, year/month/day/hour/minute/second.
|
|
366
|
-
"echo built-at={{ now('iso') }} tag=build-{{ now('unix-timestamp') }}",
|
|
367
|
-
|
|
368
|
-
// UUID (v4-shaped) for unique temp / cache names. `--dry-run` → `<uuid>`.
|
|
369
|
-
"echo tmp=/tmp/job-{{ uuid() }}",
|
|
370
|
-
|
|
371
|
-
// URL percent-encode / decode (space → %20; symmetric round-trip):
|
|
372
|
-
"curl 'https://api/search?q={{ url_encode(ARG.query) }}'",
|
|
373
|
-
|
|
374
|
-
// Numeric comparisons — coerce both args to numbers (same rules as the
|
|
375
|
-
// arithmetic family, so non-numeric input errors) and return "true"/"false",
|
|
376
|
-
// so they work as DSL `Truthy` values in `if` conditions:
|
|
377
|
-
// "if": "{{ greater_than(VAR.count, '50') }}"
|
|
378
|
-
// "if": "{{ less_than_or_equal(ARG.retries, '3') }}"
|
|
379
|
-
// `is_number` tests whether a value parses as a (finite) number — it returns
|
|
380
|
-
// "false" instead of erroring on non-numeric input:
|
|
381
|
-
// "if": "{{ is_number(ARG.port) }}"
|
|
382
|
-
|
|
383
|
-
// Validate a value against a fixed allow-list. Returns the value on
|
|
384
|
-
// match; lists every valid option in the error on mismatch. Collapses
|
|
385
|
-
// the four-case `match { major: define(part, 'major'), ... }` boilerplate.
|
|
386
|
-
"{{ define(part, one_of(ARGS, 'major', 'minor', 'patch', 'build')) }}",
|
|
387
|
-
|
|
388
|
-
// Cache keys / content fingerprints. `sha256` for security-sensitive use,
|
|
389
|
-
// `md5` for cheap fingerprinting (NOT cryptographically secure).
|
|
390
|
-
"echo cache-key={{ sha256(read_file('package-lock.json')) }}",
|
|
391
|
-
|
|
392
|
-
// Branch on file presence without forking a shell. `file_exists` returns
|
|
393
|
-
// the literal "true" / "false", so it doubles as a DSL truthy value.
|
|
394
|
-
// "if": "{{ file_exists('.env.local') }}"
|
|
395
|
-
|
|
396
|
-
// Read a file inline — relative paths anchor to the Runfile directory.
|
|
397
|
-
// Use `try(...)` to recover from missing files.
|
|
398
|
-
"echo version={{ try(read_file('VERSION')) ? '0.0.0-dev' }}",
|
|
399
|
-
|
|
400
|
-
// Write a file straight from a substitution — pairs with `read_file` for
|
|
401
|
-
// read-modify-write pipelines (version bumps, config edits, …). Returns
|
|
402
|
-
// an empty string, so a line containing only this call is dropped silently
|
|
403
|
-
// (same convention as `define`). Goes through Rust's `std::fs::write`, so
|
|
404
|
-
// it sidesteps the shell entirely — safe for large content, content with
|
|
405
|
-
// quotes, embedded newlines, anything that would break a
|
|
406
|
-
// `printf > file`-style redirect on Windows. `--dry-run` skips the write.
|
|
407
|
-
"{{ write_file('build.gradle.kts', regex_replace(read_file('build.gradle.kts'), 'versionCode = [0-9]+', 'versionCode = 43')) }}",
|
|
408
|
-
|
|
409
|
-
// Create a temp file / dir in the OS temp directory, auto-deleted when the
|
|
410
|
-
// CLI exits. `temp_file([content], [extension])` writes `content` (if given)
|
|
411
|
-
// and appends `.<extension>` (leading dot optional); `temp_dir()` makes an
|
|
412
|
-
// empty directory (removed recursively). `--dry-run` → `<temp_file>` /
|
|
413
|
-
// `<temp_dir>` placeholders, nothing created. Capture the path once with
|
|
414
|
-
// `define` and reuse it:
|
|
415
|
-
"{{ define(cfg, temp_file('{\"env\":\"prod\"}', 'json')) }}",
|
|
416
|
-
"tool --config {{ VAR.cfg }}",
|
|
417
|
-
"{{ define(work, temp_dir()) }}",
|
|
418
|
-
"git clone --depth 1 $REPO {{ VAR.work }} && build {{ VAR.work }}",
|
|
419
|
-
|
|
420
|
-
// Pull values out of arbitrary JSON without `jq` (works on every shell):
|
|
421
|
-
"echo db_host={{ json_get(read_file('config.json'), 'database.host') }}",
|
|
422
|
-
|
|
423
|
-
// Modify a JSON document in place (returns the new compact JSON):
|
|
424
|
-
"echo {{ json_set('{\"port\":3000}', 'env', 'production') }}",
|
|
425
|
-
|
|
426
|
-
// `try(expr)` swallows inner errors. Standalone returns "" on failure;
|
|
427
|
-
// chained, the next segment runs as a fallback.
|
|
428
|
-
"echo {{ try(base64_decode(ARG.maybe_b64)) ? ARG.maybe_b64 }}",
|
|
429
|
-
|
|
430
|
-
// `error('message')` fails the current command on purpose: prints the
|
|
431
|
-
// message to stderr and marks the step failed — but the failure flows
|
|
432
|
-
// through the normal walker, so `when: failure` / `when: always` steps
|
|
433
|
-
// still run and `ignoreErrors` still suppresses it. Great for guard clauses:
|
|
434
|
-
// { "if": "{{ ARG.env == 'prod' }}", "then": ["{{ error('refusing to deploy to prod from a laptop') }}"] }
|
|
435
|
-
|
|
436
|
-
// Nested:
|
|
437
|
-
"echo {{ to_upper(to_lower(ARG.x)) }}",
|
|
438
|
-
|
|
439
|
-
// As a chain fallback:
|
|
440
|
-
"echo host={{ ARG.host ? to_lower(ENV.HOST) }}",
|
|
441
|
-
|
|
442
|
-
// Capture a value with `define(...)` and read it later via `VAR.<name>`:
|
|
443
|
-
"{{ define(sdk, ENV.SDK ? '/opt/sdk') }}",
|
|
444
|
-
"{{ VAR.sdk }}/bin/build",
|
|
445
|
-
"echo using sdk at {{ VAR.sdk }}",
|
|
446
|
-
|
|
447
|
-
// `set_cwd(path)` switches the cwd subsequent commands spawn in — like
|
|
448
|
-
// shell `cd`, but works on every shell / OS without forking. Relative
|
|
449
|
-
// paths chain (matching `cd a; cd b` → `a/b`); absolute paths replace.
|
|
450
|
-
"{{ set_cwd(ARG.subproject ? 'packages/api') }}",
|
|
451
|
-
"npm install",
|
|
452
|
-
"npm run build",
|
|
453
|
-
|
|
454
|
-
// Single-quoted strings interpolate nested {{ }} substitutions:
|
|
455
|
-
"{{ define(cmd, 'docker compose -f {{ VAR.compose }} pull') }}",
|
|
456
|
-
"{{ VAR.cmd }}"
|
|
327
|
+
There is no `--watch` flag: the file already said what it wants, so `run watch` watches.
|
|
328
|
+
|
|
329
|
+
## The language
|
|
330
|
+
|
|
331
|
+
Line-oriented, with one rule: **the language is the default, the shell is marked.**
|
|
332
|
+
|
|
333
|
+
| Line | Meaning |
|
|
334
|
+
| --- | --- |
|
|
335
|
+
| `# text` | A comment. The leading block is the target's description. |
|
|
336
|
+
| `.name = value` | A property. |
|
|
337
|
+
| `$ echo hi` | Hand this line to a shell. |
|
|
338
|
+
| `exec python3` … `end` | Run a command with the block as its stdin. |
|
|
339
|
+
| `json` … `end` | A block of JSON, as one value. |
|
|
340
|
+
| `let x = 1` | Bind a value. `x = 2` rebinds. |
|
|
341
|
+
| `if` / `else` / `end` | Branch. |
|
|
342
|
+
| `for x in list` / `end` | Loop. |
|
|
343
|
+
| `match` / `case` / `default` / `end` | Dispatch on a value. A label is a quoted string: `case "linux"`. |
|
|
344
|
+
| `retry n [every s]` … `end` | Run the block again while it fails, up to `n` times. |
|
|
345
|
+
| `do` … `end` | A block with no condition, so a property can cover a few commands. |
|
|
346
|
+
| `run other-target` | Run another target, in this process. |
|
|
347
|
+
| `print(…)` | Anything else is an expression, evaluated for its effect. |
|
|
348
|
+
|
|
349
|
+
### Values
|
|
350
|
+
|
|
351
|
+
Strings, numbers, booleans and lists — **strict, with no coercion.** `"a" + 1` is an error telling you to use
|
|
352
|
+
`concat`. `"1" == 1` is false. `number(ARG.count)` is required before arithmetic. `1 / 2` is `0.5`.
|
|
353
|
+
|
|
354
|
+
Lists nest as deep as you like, which beats packing several fields into one string and splitting it back out:
|
|
355
|
+
|
|
356
|
+
```sh
|
|
357
|
+
for volume in [
|
|
358
|
+
["prometheus-data", "65534:65534"],
|
|
359
|
+
["kvrocks-data", "999:999"],
|
|
457
360
|
]
|
|
361
|
+
$ docker run --rm -v {{ volume[0] }}:/v alpine chown -R {{ volume[1] }} /v
|
|
362
|
+
end
|
|
458
363
|
```
|
|
459
364
|
|
|
460
|
-
`
|
|
461
|
-
|
|
462
|
-
being dispatched to the shell. `define`s in a parent target are visible to `@target` children. The `name` MUST
|
|
463
|
-
be a bareword identifier matching `[A-Za-z_][A-Za-z0-9_-]*` — quotes are NOT allowed on the name.
|
|
365
|
+
`print` writes a line to stdout; `printf` writes exactly what you give it, with `%s`, `%d`, `%f`, `%.Nf` and
|
|
366
|
+
`%%`, and no newline of its own.
|
|
464
367
|
|
|
465
|
-
|
|
368
|
+
Lists are values, and every list function answers with a **new** list, so nothing changes behind another name:
|
|
466
369
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
`{{ FLAG.* }}`, `{{ RUN.* }}`, and earlier vars. If a reference has no default and isn't supplied, it errors —
|
|
471
|
-
exactly like everywhere else.
|
|
370
|
+
```sh
|
|
371
|
+
let recent = slice(reverse(sort(tags)), 0, 5)
|
|
372
|
+
let targets = without(RUN.namespaces, "docs")
|
|
472
373
|
|
|
473
|
-
|
|
474
|
-
{
|
|
475
|
-
|
|
476
|
-
"vars": { "appName": "myapp" } // available to every target
|
|
477
|
-
},
|
|
478
|
-
"targets": {
|
|
479
|
-
"deploy": {
|
|
480
|
-
"env": { "REGION": "us-east-1" },
|
|
481
|
-
"vars": {
|
|
482
|
-
"region": "{{ ENV.REGION }}", // vars resolve after env
|
|
483
|
-
"retries": "3", // numbers/bools are stringified
|
|
484
|
-
"tag": "{{ ARG.tag ? 'latest' }}" // chain fallbacks work
|
|
485
|
-
},
|
|
486
|
-
"commands": ["echo deploying {{ VAR.appName }} to {{ VAR.region }} as {{ VAR.tag }}"]
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
}
|
|
374
|
+
for pair in zip(names, owners)
|
|
375
|
+
$ chown {{ pair[1] }} /srv/{{ pair[0] }}
|
|
376
|
+
end
|
|
490
377
|
```
|
|
491
378
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
`
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
`
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
Function args are separated by `, ` (comma + exactly one space).
|
|
514
|
-
|
|
515
|
-
**Quote semantics inside `{{ ... }}`:**
|
|
516
|
-
|
|
517
|
-
- **Single quotes (`'...'`) — interpolated string**: surrounding quotes stripped, with any nested `{{ ... }}`
|
|
518
|
-
resolved through the regular substitution machinery. Use these for almost every literal — they handle the
|
|
519
|
-
full range of values including embedded substitutions: `concat('a, b', ARG.x)`,
|
|
520
|
-
`'/var/log/{{ RUN.os }}.log'`, `define(cmd, 'docker -f {{ VAR.compose }} pull')`.
|
|
521
|
-
- **Double quotes (`"..."`) — fully literal value**: the quote characters are part of the value (so `"test"` is
|
|
522
|
-
the 6-character string `"test"`). No interpolation. Useful when the literal you want to emit really should
|
|
523
|
-
contain `"` chars.
|
|
524
|
-
- **Plain barewords are rejected** — `{{ ARG.env ? development }}` is a parse-time error. Wrap in quotes.
|
|
525
|
-
|
|
526
|
-
#### Conditionals and loops, no shell required
|
|
527
|
-
|
|
528
|
-
Drop `if` / `for` / `match` blocks straight into your `commands` array. Conditions, loops, and value dispatch are
|
|
529
|
-
evaluated by Runfile itself, so the logic works the same on every shell and platform.
|
|
530
|
-
|
|
531
|
-
```jsonc
|
|
532
|
-
"commands": [
|
|
533
|
-
{ "if": "{{ ARG.env == 'production' }}",
|
|
534
|
-
"then": ["./deploy-prod.sh"],
|
|
535
|
-
"else": ["./deploy-staging.sh"] },
|
|
536
|
-
|
|
537
|
-
{ "match": "{{ ARG.tier ? '1' }}", // chain default; case "1" runs when --tier missing
|
|
538
|
-
"cases": {
|
|
539
|
-
"1": "flutter emulators --launch Tier_1_Android_9",
|
|
540
|
-
"2": "flutter emulators --launch Tier_2_Android_11",
|
|
541
|
-
"3": "flutter emulators --launch Tier_3_Android_14",
|
|
542
|
-
// Case keys wrapped in `/.../` are treated as regex patterns.
|
|
543
|
-
// Literal cases always win over a regex that would also match.
|
|
544
|
-
"/^v\\d+$/": "echo version-tag",
|
|
545
|
-
"/^pr-\\d+$/": "echo preview-build"
|
|
546
|
-
} }, // unknown values error out, listing the valid cases
|
|
547
|
-
|
|
548
|
-
{ "for": "service",
|
|
549
|
-
"in": ["api", "web", "worker"],
|
|
550
|
-
"parallel": true,
|
|
551
|
-
// VAR.<name>_index exposes the 0-based iteration counter.
|
|
552
|
-
"do": ["echo [{{ VAR.service_index }}] docker build -t {{ VAR.service }} services/{{ VAR.service }}"] },
|
|
553
|
-
|
|
554
|
-
{ "for": "file",
|
|
555
|
-
"shell": "git diff --name-only HEAD~1",
|
|
556
|
-
"do": ["clang-format -i {{ VAR.file }}"] }
|
|
557
|
-
]
|
|
379
|
+
`append`, `prepend`, `concat_lists`, `sort`, `reverse`, `unique`, `slice`, `flatten`, `zip`, `index_of` and
|
|
380
|
+
`without`, beside `first`, `last`, `length` and `join`.
|
|
381
|
+
|
|
382
|
+
There are 78 built-in functions — strings, lists, regex, paths, JSON, hashes, time, files. Your editor lists
|
|
383
|
+
them all with a description and an example; `run :list` is for targets, and the language server is for the
|
|
384
|
+
language.
|
|
385
|
+
|
|
386
|
+
### Where values come from
|
|
387
|
+
|
|
388
|
+
| Source | From |
|
|
389
|
+
| --- | --- |
|
|
390
|
+
| `ARG.name` | `--name=value` |
|
|
391
|
+
| `FLAG.name` | `--name` (a boolean) |
|
|
392
|
+
| `ARGS` | positional arguments, as a list |
|
|
393
|
+
| `ENV.NAME` | the environment |
|
|
394
|
+
| `RUN.os` `RUN.arch` `RUN.cwd` `RUN.file` `RUN.parent` `RUN.namespaces` `RUN.user` | the run itself |
|
|
395
|
+
|
|
396
|
+
`a ? b` means "`a`, or `b` if `a` is not there", and chains:
|
|
397
|
+
|
|
398
|
+
```sh
|
|
399
|
+
let port = ARG.port ? ENV.PORT ? "3000"
|
|
558
400
|
```
|
|
559
401
|
|
|
560
|
-
|
|
402
|
+
Arguments are `--key=value`. Writing `--key value` gives you a flag and a positional, because nothing declares
|
|
403
|
+
which names take values — and if you meant an argument, the error says so.
|
|
404
|
+
|
|
405
|
+
**A flag a target cannot read is an error**, not a typo you find out about later. `run` knows exactly what a
|
|
406
|
+
target reads by walking its parsed tree, so `--forse` stops the run and points at `--help`:
|
|
561
407
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
408
|
+
```bash
|
|
409
|
+
$ run deploy --help
|
|
410
|
+
run deploy [--env=<value>] --token=<value> [--force]
|
|
565
411
|
|
|
566
|
-
|
|
567
|
-
skipped instead of erroring. Useful with `for in: "namespaces"` when the dispatched target isn't defined in every
|
|
568
|
-
namespace.
|
|
412
|
+
Deploy to an environment.
|
|
569
413
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
]
|
|
414
|
+
Arguments
|
|
415
|
+
--env=<value> defaults to staging
|
|
416
|
+
--token=<value> required
|
|
417
|
+
--force off unless passed
|
|
418
|
+
|
|
419
|
+
Environment
|
|
420
|
+
DEPLOY_KEY required
|
|
578
421
|
```
|
|
579
422
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
ordinal suffix (`[npm run dev #1]`). They're padded to a common width so the brackets line up, and each gets one
|
|
592
|
-
of six cycling colors so adjacent branches stay distinct.
|
|
593
|
-
SGR colors flow through unchanged. The prefix propagates through `@target` invocations too,
|
|
594
|
-
so monorepo fan-outs like `{ "for": "ns", "in": "namespaces", "do": "@{{ VAR.ns }}:dev" }` tag every nested shell
|
|
595
|
-
with its branch identity. Set `RUNFILE_NO_LINE_PREFIX=1` to opt out and inherit raw stdio.
|
|
596
|
-
|
|
597
|
-
```text
|
|
598
|
-
[runfile] (1/3) [parallel] docker compose pull api
|
|
599
|
-
[runfile] (2/3) [parallel] docker compose pull web
|
|
600
|
-
[runfile] (3/3) [parallel] docker compose pull worker
|
|
601
|
-
[1] Pulling api ... 50%
|
|
602
|
-
[2] Pulling web ... 30%
|
|
603
|
-
[3] Pulling worker ... 100%
|
|
604
|
-
[1] Pulling api ... 100%
|
|
605
|
-
[2] Pulling web ... 100%
|
|
606
|
-
```
|
|
607
|
-
|
|
608
|
-
#### Cleanup on failure / always with `when:` blocks
|
|
609
|
-
|
|
610
|
-
Wrap any commands in a `when:` block to run them only after a failure, or always — interleaved with the rest of
|
|
611
|
-
`commands`, no separate `before`/`after` arrays.
|
|
612
|
-
|
|
613
|
-
```jsonc
|
|
614
|
-
"commands": [
|
|
615
|
-
"./run-tests.sh", // failure flips state
|
|
616
|
-
"./report.sh", // skipped on failure (default when:success)
|
|
617
|
-
{ "when": "failure", "commands": ["./post-failure.sh"] }, // runs only after a failure
|
|
618
|
-
{ "when": "always", "commands": ["@cleanup"] } // runs every time
|
|
619
|
-
]
|
|
423
|
+
Nothing is declared for that — a `?` chain is what makes a value optional, and the literal it ends in is the
|
|
424
|
+
default. `run --stdin-args deploy` asks for the same list, in the same order, **before anything runs**.
|
|
425
|
+
|
|
426
|
+
Everything after a bare `--` is passed through untouched, flags included. That is how a wrapper forwards a
|
|
427
|
+
command line it does not understand:
|
|
428
|
+
|
|
429
|
+
```sh
|
|
430
|
+
# runfiles/aws.run
|
|
431
|
+
# The AWS CLI, in a container
|
|
432
|
+
|
|
433
|
+
$ docker run --rm amazon/aws-cli {{ ARGS }}
|
|
620
434
|
```
|
|
621
435
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
suppressible by `ignoreErrors` (that forgives a failed command, not a user asking to stop), it stops `for` loops
|
|
626
|
-
from starting further iterations, and it reaches every target on the stack: an interrupted `@target` dependency
|
|
627
|
-
runs its own cleanup, then its caller runs the caller's. Cleanup blocks are exempt from the abort, so their own
|
|
628
|
-
steps — including a `@teardown` they delegate to — run in full. Press Ctrl+C **again** to quit immediately if a
|
|
629
|
-
cleanup block is itself slow or stuck.
|
|
436
|
+
```bash
|
|
437
|
+
run aws -- s3api list-buckets --output json
|
|
438
|
+
```
|
|
630
439
|
|
|
631
|
-
|
|
440
|
+
### Interpolation quotes itself
|
|
632
441
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
persist for free.
|
|
442
|
+
`{{ … }}` in a shell line becomes **exactly one argument** — or, for a list, one argument per item. So this is
|
|
443
|
+
safe with any file name, spaces and quotes included:
|
|
636
444
|
|
|
637
|
-
```
|
|
638
|
-
{
|
|
639
|
-
"deploy": {
|
|
640
|
-
"sameShell": true,
|
|
641
|
-
"commands": ["cd ci-scripts/", "./ci-deploy.sh"]
|
|
642
|
-
}
|
|
643
|
-
}
|
|
445
|
+
```sh
|
|
446
|
+
$ cp {{ ARG.src }} {{ ARG.dest }}
|
|
644
447
|
```
|
|
645
448
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
parent. `sameShell` also composes with `detach: true` (joined command spawns as one detached process) and is
|
|
650
|
-
available on `globals` for project-wide defaults.
|
|
449
|
+
**Never wrap an interpolation in shell quotes.** There is no `shell_quote` function because there is nothing
|
|
450
|
+
left to quote: the substitution already did it. The same rule holds one layer up inside a `json` block, where
|
|
451
|
+
an interpolation becomes one JSON value.
|
|
651
452
|
|
|
652
|
-
|
|
453
|
+
### Asking whether a command worked
|
|
653
454
|
|
|
654
|
-
|
|
455
|
+
A `$` run may stand as a condition or as a `match` subject. The condition is true when the command exits 0;
|
|
456
|
+
the cases are exit codes. Neither stops the target — a non-zero exit is the answer, not a failure.
|
|
655
457
|
|
|
656
|
-
```
|
|
657
|
-
|
|
458
|
+
```sh
|
|
459
|
+
if $ command -v docker
|
|
460
|
+
$ docker info
|
|
461
|
+
else
|
|
462
|
+
error("docker is not on PATH")
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
match $ curl -fsS https://example.com
|
|
466
|
+
case "0"
|
|
467
|
+
print("up")
|
|
468
|
+
case "22"
|
|
469
|
+
error("the endpoint answered 4xx or 5xx")
|
|
470
|
+
default
|
|
471
|
+
error("could not reach it")
|
|
472
|
+
end
|
|
658
473
|
```
|
|
659
474
|
|
|
660
|
-
|
|
475
|
+
`code_of($ cmd)` is the same status as a number, for when you want to keep it — and `code_of(run other)`
|
|
476
|
+
scores another **target** the same way. That is how one target runs both halves of a check and gives a single
|
|
477
|
+
verdict at the end, instead of stopping at the first thing that failed:
|
|
661
478
|
|
|
662
|
-
|
|
479
|
+
```sh
|
|
480
|
+
# runfiles/coverage.run
|
|
481
|
+
# Coverage for both halves, with one verdict
|
|
663
482
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
483
|
+
let rust = code_of(run coverage:rust)
|
|
484
|
+
let web = code_of(run web:coverage)
|
|
485
|
+
|
|
486
|
+
run _verdict --name=Rust --code={{ rust }}
|
|
487
|
+
run _verdict --name=Web --code={{ web }}
|
|
488
|
+
|
|
489
|
+
if rust != 0 || web != 0
|
|
490
|
+
exit(1)
|
|
491
|
+
end
|
|
668
492
|
```
|
|
669
493
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
494
|
+
A dispatched target is scored the way `run` itself would exit: `exit(3)` is 3, any other failure is 1, and the
|
|
495
|
+
error is printed where it happened. An interrupt, or answering no to a `confirm()`, is not a status — that
|
|
496
|
+
stops the caller too.
|
|
673
497
|
|
|
674
|
-
|
|
675
|
-
targets carry their `namespace:` prefixes, exactly as `run :list` shows them (e.g. `run api:build`).
|
|
676
|
-
- `--include-globals` also generates entries for the global user-level Runfiles registered with
|
|
677
|
-
`run :config global-files` — the same ones `run :list` folds in. Handy when an editor integration
|
|
678
|
-
should offer your machine-wide targets (e.g. `run c`, `run backup-git`) alongside a project's own.
|
|
498
|
+
### Capturing output
|
|
679
499
|
|
|
680
|
-
|
|
500
|
+
```sh
|
|
501
|
+
let branch = $ git rev-parse --abbrev-ref HEAD
|
|
681
502
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
503
|
+
if branch != "main"
|
|
504
|
+
error("release from main, not {{ branch }}")
|
|
505
|
+
end
|
|
506
|
+
```
|
|
685
507
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
{
|
|
696
|
-
"formatVersion": 1,
|
|
697
|
-
"sources": [
|
|
698
|
-
{
|
|
699
|
-
"filePath": "/abs/path/Runfile.json",
|
|
700
|
-
"kind": "local", // "local" | "included" | "global"
|
|
701
|
-
"onlyInDirectories": ["~/w"], // only on globals restricted to certain dirs
|
|
702
|
-
"targets": [
|
|
703
|
-
{
|
|
704
|
-
"name": "api:build", // full canonical invocation name
|
|
705
|
-
"namespace": "api", // omitted for un-namespaced targets
|
|
706
|
-
"description": "Build the API"
|
|
707
|
-
}
|
|
708
|
-
]
|
|
709
|
-
}
|
|
710
|
-
]
|
|
711
|
-
}
|
|
508
|
+
A `$` capture runs to the end of its line, so it can only be a call's **last** argument, and the `)` has to be
|
|
509
|
+
the last character of the line:
|
|
510
|
+
|
|
511
|
+
```sh
|
|
512
|
+
let files = lines($ git diff --cached --name-only)
|
|
513
|
+
|
|
514
|
+
for f in lines($ git ls-files '*.sh')
|
|
515
|
+
$ shellcheck {{ f }}
|
|
516
|
+
end
|
|
712
517
|
```
|
|
713
518
|
|
|
714
|
-
`
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
(
|
|
727
|
-
|
|
728
|
-
sets the newline sequence, `insert_final_newline` and `trim_trailing_whitespace` are applied, and
|
|
729
|
-
`charset = utf-8-bom` prepends a BOM. When no `.editorconfig` applies, the previous defaults are kept.
|
|
730
|
-
|
|
731
|
-
#### Preparation targets (like `npm prepare`, but enforced)
|
|
732
|
-
|
|
733
|
-
Most projects have a one-time setup step — install git hooks, pull dependencies, generate config.
|
|
734
|
-
It's easy to forget, and easy to fall behind when it changes. Point `globals.prepare` at a target and
|
|
735
|
-
Runfile makes it a precondition for **every** target in the file:
|
|
736
|
-
|
|
737
|
-
```jsonc
|
|
738
|
-
{
|
|
739
|
-
"globals": { "prepare": "@setup" },
|
|
740
|
-
"targets": {
|
|
741
|
-
"setup": { "commands": ["git config core.hooksPath .githooks", "npm ci"] },
|
|
742
|
-
"build": { "commands": ["npm run build"] },
|
|
743
|
-
"test": { "commands": ["npm test"], "prepare": "@setup-tests" },
|
|
744
|
-
"setup-tests": { "commands": ["./scripts/seed-test-db.sh"] }
|
|
745
|
-
}
|
|
746
|
-
}
|
|
519
|
+
`code_of($ cmd)` and `code_of(run other)` are the same shape: run it and take the status, ignoring how it went
|
|
520
|
+
— which is what `|| true` was doing.
|
|
521
|
+
|
|
522
|
+
### Blocks that are not shell
|
|
523
|
+
|
|
524
|
+
`$ line` is shorthand for a one-line `exec` with the default shell. Name any other command and the block
|
|
525
|
+
becomes its stdin — see [Any language you have installed](#any-language-you-have-installed).
|
|
526
|
+
|
|
527
|
+
```sh
|
|
528
|
+
exec python3
|
|
529
|
+
import json, sys
|
|
530
|
+
|
|
531
|
+
print(json.dumps({"ok": True}))
|
|
532
|
+
end
|
|
747
533
|
```
|
|
748
534
|
|
|
749
|
-
|
|
535
|
+
### Stopping early
|
|
750
536
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
537
|
+
`exit()` ends the run with a status; `error("…")` fails with a message; `confirm("…")` asks, and stops if the
|
|
538
|
+
answer is no. Nothing catches any of them: not a `?` fallback, not `.ignore-errors`. A target may forgive a
|
|
539
|
+
command that failed, but being told to stop is not that.
|
|
540
|
+
|
|
541
|
+
`confirm` is a function rather than a property, so the question can depend on what is about to happen — and is
|
|
542
|
+
skipped by `-y`, in CI, and under `--dry-run`, where there is nothing to approve.
|
|
543
|
+
|
|
544
|
+
A line that is only a value — `exit`, `abc`, `35` — is a parse error, since it computes something and throws
|
|
545
|
+
it away. Most often it is a call with the parentheses left off, and the message says so.
|
|
546
|
+
|
|
547
|
+
## Properties
|
|
548
|
+
|
|
549
|
+
Set at the top of the file, or inside a block where marked.
|
|
550
|
+
|
|
551
|
+
| Property | Effect | In a block? |
|
|
552
|
+
| --- | --- | :-: |
|
|
553
|
+
| `.shell` | Which shell `$` lines use. | ✅ |
|
|
554
|
+
| `.env.NAME` | Set an environment variable. | ✅ |
|
|
555
|
+
| `.workdir` | Where commands run. | ✅ |
|
|
556
|
+
| `.parallel` | Run this block's commands at once. On a `for`, its iterations. | ✅ |
|
|
557
|
+
| `.ignore-errors` | Keep going when a command fails. | ✅ |
|
|
558
|
+
| `.logging` | Announce each command on stderr before it runs. Off unless set. | ✅ |
|
|
559
|
+
| `.env-file` | Load a `.env` file (encrypted values are decrypted in memory). Appends. | ✅ |
|
|
560
|
+
| `.add-path` | Prepend a directory to `PATH`. Appends. | ✅ |
|
|
561
|
+
| `.watch` | Re-run when matching files change. A `!` prefix excludes. | |
|
|
562
|
+
| `.alias` | Another name for this target. | |
|
|
563
|
+
| `.detach` | Start the commands and do not wait. | |
|
|
564
|
+
| `.only-in-directories` | For the machine-wide directory: only offer these targets inside these directories. | |
|
|
565
|
+
|
|
566
|
+
`.env-file` and `.add-path` **append**, so a block adds to what it inherited rather than replacing it, and a
|
|
567
|
+
block's file can be named by something the body worked out — which a header property cannot do, because it
|
|
568
|
+
resolves before any statement runs:
|
|
569
|
+
|
|
570
|
+
```sh
|
|
571
|
+
# runfiles/deploy.run
|
|
572
|
+
# Deploy to an environment
|
|
573
|
+
|
|
574
|
+
let env = one_of(first(ARGS), "staging", "production")
|
|
575
|
+
|
|
576
|
+
if env == "production"
|
|
577
|
+
.env-file = ".env.production"
|
|
578
|
+
.add-path = "vendor/prod-tools"
|
|
579
|
+
|
|
580
|
+
confirm("Deploy to production?")
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
$ terraform apply -auto-approve
|
|
755
584
|
```
|
|
756
585
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
on top of the global one, so `run test` above requires both `setup` and `setup-tests`.
|
|
586
|
+
**A target whose file name starts with `_` is hidden** from `run :list`, from completion and from generated
|
|
587
|
+
editor tasks — and still runs when something calls it. That is the whole of it; there is no property to
|
|
588
|
+
disagree with the name.
|
|
761
589
|
|
|
762
|
-
|
|
763
|
-
|
|
590
|
+
```
|
|
591
|
+
runfiles/_aws.run → run _aws (a helper other targets call)
|
|
592
|
+
runfiles/deploy.run → run deploy
|
|
593
|
+
```
|
|
764
594
|
|
|
765
|
-
|
|
595
|
+
`do … end` is a block with no condition — somewhere for a property to go when it should cover a few commands
|
|
596
|
+
and not the whole target:
|
|
766
597
|
|
|
767
|
-
```
|
|
768
|
-
|
|
769
|
-
|
|
598
|
+
```sh
|
|
599
|
+
# runfiles/setup.run
|
|
600
|
+
# One-time per clone: hooks, then the web dependencies
|
|
601
|
+
|
|
602
|
+
$ git config core.hooksPath .githooks
|
|
603
|
+
|
|
604
|
+
do
|
|
605
|
+
.workdir = "web"
|
|
606
|
+
|
|
607
|
+
$ pnpm install
|
|
608
|
+
$ pnpm exec playwright install chromium
|
|
609
|
+
end
|
|
770
610
|
```
|
|
771
611
|
|
|
772
|
-
|
|
612
|
+
That is what replaced writing `cd web &&` on every line, which the shell would have needed once per command.
|
|
773
613
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
confirmation prompts · `--dry-run` · `--stdin-args` (interactive prompting for missing inputs) · `--timings` ·
|
|
777
|
-
force-kill on SIGINT (works for GUI apps like Unity) · stdio tailers · shell completions · MCP server.
|
|
614
|
+
`_shared.run` holds properties — and `let` bindings — that every target in its directory inherits. A nested
|
|
615
|
+
one layers over the directory above it, so `runfiles/api/_shared.run` adds to `runfiles/_shared.run`.
|
|
778
616
|
|
|
779
|
-
|
|
617
|
+
```sh
|
|
618
|
+
# runfiles/_shared.run
|
|
619
|
+
# Settings every target here inherits
|
|
780
620
|
|
|
781
|
-
|
|
621
|
+
.add-path = "node_modules/.bin"
|
|
622
|
+
.env.CARGO_TERM_COLOR = "always"
|
|
782
623
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
| Linux (x86-64) | Supported |
|
|
786
|
-
| Linux (ARM64) | Supported |
|
|
787
|
-
| macOS (Apple Silicon) | Supported |
|
|
788
|
-
| macOS (Intel) | Supported |
|
|
789
|
-
| Windows 10 | Supported |
|
|
790
|
-
| Windows 11 | Supported |
|
|
791
|
-
| Windows Server | Supported |
|
|
624
|
+
let image = "ghcr.io/example/builder:v2"
|
|
625
|
+
```
|
|
792
626
|
|
|
793
|
-
|
|
627
|
+
## How targets are found
|
|
794
628
|
|
|
795
|
-
|
|
629
|
+
`run` walks **up** from the working directory for the nearest `runfiles/`, then **down** one level for
|
|
630
|
+
subprojects. A nested directory becomes a namespace:
|
|
796
631
|
|
|
797
|
-
|
|
632
|
+
```
|
|
633
|
+
runfiles/build.run → run build
|
|
634
|
+
web/runfiles/build.run → run web:build
|
|
635
|
+
runfiles/api/deploy.run → run api:deploy
|
|
636
|
+
```
|
|
798
637
|
|
|
799
|
-
|
|
638
|
+
Inside a subproject, `run build` means *that* subproject's `build` — so a file reads the same wherever you
|
|
639
|
+
invoke `run` from.
|
|
800
640
|
|
|
801
|
-
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
- [Encrypted environment variables](.github/DOCS.md#encrypted-environment-variables)
|
|
805
|
-
- [Internal targets](.github/DOCS.md#internal-targets)
|
|
806
|
-
- [`when:` blocks](.github/DOCS.md#when-guarded-blocks-when)
|
|
807
|
-
- [Watch mode](.github/DOCS.md#watch-mode)
|
|
808
|
-
- [Editor integration](.github/DOCS.md#editor-integration)
|
|
641
|
+
`$HOME/.runfiles/` holds machine-wide targets, available in every project. If you would rather see the
|
|
642
|
+
directory than hide it, `$HOME/runfiles/` and `$HOME/Runfiles/` are read too — but only one of the three may
|
|
643
|
+
hold anything.
|
|
809
644
|
|
|
810
|
-
|
|
645
|
+
Everything relative — `.env-file`, `.add-path`, `glob`, `read_file`, `{{ RUN.parent }}`, the working directory
|
|
646
|
+
— resolves against **the parent of `runfiles/`**. One anchor, one rule.
|
|
811
647
|
|
|
812
|
-
##
|
|
648
|
+
## Setup targets
|
|
649
|
+
|
|
650
|
+
Name a target `setup` and every other target in its directory refuses to run until it has:
|
|
813
651
|
|
|
814
|
-
```bash
|
|
815
|
-
run setup # one-time per clone: activates the committed git hooks (.githooks/)
|
|
816
|
-
run build # debug build
|
|
817
|
-
run check # non-mutating gate: fmt --check + clippy (deny warnings) + cargo check
|
|
818
|
-
run lint # auto-format + clippy
|
|
819
|
-
run test # full workspace test suite
|
|
820
652
|
```
|
|
653
|
+
$ run build
|
|
654
|
+
error: `setup` has never been run
|
|
655
|
+
|
|
656
|
+
run setup
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
It re-triggers when `setup.run` itself changes, so a new dependency is not silently missed. `--dry-run` is
|
|
660
|
+
exempt, CI is exempt, and `RUNFILE_SKIP_PREPARE=1` bypasses it.
|
|
661
|
+
|
|
662
|
+
## Commands
|
|
663
|
+
|
|
664
|
+
| Command | |
|
|
665
|
+
| --- | --- |
|
|
666
|
+
| `run <target> [args…]` | Run a target |
|
|
667
|
+
| `run :list` | List every target (`--names`, `--json` for tooling) |
|
|
668
|
+
| `run :init` | Create `runfiles/` with an example |
|
|
669
|
+
| `run :format [path…]` | Format runfiles in place (`--check`, `--stdout`) |
|
|
670
|
+
| `run :env <sub>` | Manage `.env` files: `init`, `get`, `set`, `encrypt`, `decrypt`, `rotate`, `inject`, `secret-keys` |
|
|
671
|
+
| `run :completions <command> <shell>` | `install`, `uninstall` or `output` a completion script for `bash`, `zsh`, `fish` or `powershell` |
|
|
672
|
+
| `run :generate <editor>` | Task files for `zed`, `jetbrains` or `vscode`, merged into what is there |
|
|
673
|
+
| `run :update` | Update the binary |
|
|
821
674
|
|
|
822
|
-
|
|
675
|
+
| Flag | |
|
|
676
|
+
| --- | --- |
|
|
677
|
+
| `-y`, `--yes` | Skip confirmation prompts |
|
|
678
|
+
| `--stdin-args` | Prompt for every input the target reads, before it runs |
|
|
679
|
+
| `--dry-run` | Print what would run, without running it |
|
|
680
|
+
| `--dir <path>` | Start discovery somewhere else |
|
|
681
|
+
| `-h`, `--help` | Show the help for `run` or any of its commands |
|
|
682
|
+
| `-v`, `--version` | Print the version |
|
|
683
|
+
|
|
684
|
+
Flags belong **before** the target name; everything after it is passed to the target.
|
|
685
|
+
|
|
686
|
+
`run :format` has no settings: one shape, everywhere. It reindents with tabs, spaces expressions, places blank
|
|
687
|
+
lines, lays out `json` blocks, and leaves the three things that are not the language's to touch — strings, the
|
|
688
|
+
text after `$ `, and `exec` bodies — exactly as written. It refuses a file that does not parse, and checks that
|
|
689
|
+
its own output still means the same thing before writing it. `--check` reports what would change and exits 1,
|
|
690
|
+
which is what a CI step wants.
|
|
691
|
+
|
|
692
|
+
## Encrypted environment variables
|
|
693
|
+
|
|
694
|
+
Values in a `.env` file can be encrypted individually, so the file stays readable and diffable:
|
|
695
|
+
|
|
696
|
+
```
|
|
697
|
+
DATABASE_URL=encrypted:AAAA...
|
|
698
|
+
PUBLIC_HOST=example.com
|
|
699
|
+
```
|
|
823
700
|
|
|
824
701
|
```bash
|
|
825
|
-
run
|
|
826
|
-
run
|
|
827
|
-
run
|
|
828
|
-
run
|
|
829
|
-
run vscode:install # package + install into VS Code
|
|
702
|
+
run :env init .env.production # create, with a fresh key
|
|
703
|
+
run :env set .env.production KEY value # encrypts on write
|
|
704
|
+
run :env get .env.production KEY # decrypts on read
|
|
705
|
+
run :env rotate .env.production # re-encrypt under a new key
|
|
830
706
|
```
|
|
831
707
|
|
|
832
|
-
|
|
708
|
+
Keys live in the OS credential store — Keychain, Credential Manager, or Secret Service with a keyutils
|
|
709
|
+
fallback. In CI, pass them as `RUNFILE_PRIVATE_KEYS` (newline-separated) and no credential store is involved.
|
|
710
|
+
|
|
711
|
+
Decryption happens in memory; secrets never reach disk. The credential store is only touched when something
|
|
712
|
+
actually decrypts, so a locked keyring never gets in the way of an unrelated target.
|
|
713
|
+
|
|
714
|
+
## Editor support
|
|
715
|
+
|
|
716
|
+
`runfile-lsp` ships beside `run` and gives you diagnostics, completion, formatting, go-to-definition and
|
|
717
|
+
documentation on hover — from the same parser `run` itself uses, so your editor never disagrees with what will
|
|
718
|
+
actually happen.
|
|
719
|
+
|
|
720
|
+
Hover anything: a function shows its signature, what it does and a worked example; a property adds whether it
|
|
721
|
+
may sit inside a block; `$`, `exec`, `run`, `retry`, `match` and the rest explain the line form itself.
|
|
722
|
+
|
|
723
|
+
Ctrl+click a `run <target>` to open that target's file, or a variable to jump to where it was bound — including
|
|
724
|
+
into the `_shared.run` above it, which is the one definition you cannot find by reading the file in front of
|
|
725
|
+
you.
|
|
726
|
+
|
|
727
|
+
It also hands `$` lines and shell `exec` bodies to [shellcheck](https://www.shellcheck.net) when it is
|
|
728
|
+
installed, mapping findings back to the lines you wrote.
|
|
729
|
+
|
|
730
|
+
**VS Code** — install the `.vsix` from the [latest release](https://github.com/JoaaoVerona/runfile/releases).
|
|
731
|
+
You get a Run button on every target, a task provider, a sidebar tree, and shell lines coloured exactly as the
|
|
732
|
+
same command in a `.sh` file. Set `"editor.formatOnSave": true` and saving formats.
|
|
733
|
+
|
|
734
|
+
**JetBrains IDEs** read the same grammar: *Settings → Editor → TextMate Bundles*, add the `editors/vscode`
|
|
735
|
+
directory from a checkout.
|
|
736
|
+
|
|
737
|
+
**Zed, Neovim and Helix** use the tree-sitter grammar in `editors/tree-sitter`, with highlight queries
|
|
738
|
+
included. For Neovim with nvim-treesitter:
|
|
739
|
+
|
|
740
|
+
```lua
|
|
741
|
+
require("nvim-treesitter.parsers").get_parser_configs().runfile = {
|
|
742
|
+
install_info = {
|
|
743
|
+
url = "https://github.com/JoaaoVerona/runfile",
|
|
744
|
+
location = "editors/tree-sitter",
|
|
745
|
+
files = { "src/parser.c", "src/scanner.c" },
|
|
746
|
+
},
|
|
747
|
+
filetype = "runfile",
|
|
748
|
+
}
|
|
749
|
+
vim.filetype.add({ extension = { run = "runfile" } })
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
`run :generate zed` writes every target into `.zed/tasks.json`, and `run :generate jetbrains` into
|
|
753
|
+
`.idea/runConfigurations/`; both leave entries you wrote yourself alone and replace only their own.
|
|
754
|
+
|
|
755
|
+
## Why a language
|
|
756
|
+
|
|
757
|
+
The alternative is a config format plus an escape hatch into shell, and every version of that ends up encoding
|
|
758
|
+
control flow in JSON or YAML — or giving up and shipping shell scripts that only run on one platform.
|
|
759
|
+
|
|
760
|
+
A small language means `if`, `for` and `match` read as themselves; values have types, so `"a" + 1` is a mistake
|
|
761
|
+
rather than `"a1"`; and interpolation can be safe by construction, because the language knows a string is one
|
|
762
|
+
argument.
|
|
763
|
+
|
|
764
|
+
Shell is still there. It is just marked.
|
|
765
|
+
|
|
766
|
+
## How it compares
|
|
767
|
+
|
|
768
|
+
| | Runfile | Make | Just | Taskfile |
|
|
769
|
+
| --- | :-: | :-: | :-: | :-: |
|
|
770
|
+
| One target per file, so a shared task file never conflicts | ✅ | ❌ | ❌ | ❌ |
|
|
771
|
+
| Encrypted env vars, built-in (AES-256-GCM) | ✅ | ❌ | ❌ | ❌ |
|
|
772
|
+
| Inline OS / shell / cwd branching via `RUN.*` | ✅ | ❌ | ❌ | ❌ |
|
|
773
|
+
| Editor diagnostics from the runner's own parser | ✅ | ❌ | ❌ | ❌ |
|
|
774
|
+
| Hover documentation for every function, property and line form | ✅ | ❌ | ❌ | ❌ |
|
|
775
|
+
| One canonical format, in the CLI and on save | ✅ | ❌ | ❌ | ❌ |
|
|
776
|
+
| IDE task generation (VS Code / Zed / JetBrains) | ✅ | ❌ | ❌ | ❌ |
|
|
777
|
+
| Per-target shell override | ✅ | ❌ | ✅ | ❌ |
|
|
778
|
+
| Any interpreter for a block (`exec python3`) | ✅ | ❌ | ✅ | ❌ |
|
|
779
|
+
| Strict parsing (typos are errors) | ✅ | ❌ | ✅ | ❌ |
|
|
780
|
+
| `--help` for one target, built from its own comments | ✅ | ❌ | ✅ | ❌ |
|
|
781
|
+
| Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
|
|
782
|
+
| Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
|
|
783
|
+
| Shell completions | ✅ | ❌ | ✅ | ✅ |
|
|
784
|
+
| Hidden targets | ✅ | ❌ | ✅ | ✅ |
|
|
785
|
+
| Built-in functions (strings, regex, paths, JSON, hashes, time) | ✅ | ❌ | ✅ | ✅ |
|
|
786
|
+
| Parallel execution | ✅ | ✅ | ❌ | ✅ |
|
|
787
|
+
| Single static binary | ✅ | ✅ | ✅ | ✅ |
|
|
788
|
+
| Native Windows binary (`$` lines use Git Bash) | ✅ | ❌ | ✅ | ✅ |
|
|
789
|
+
| Output prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
|
|
790
|
+
| First-class PowerShell / cmd.exe | ❌ | ❌ | ✅ | ❌ |
|
|
791
|
+
| Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
|
|
792
|
+
| Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
|
|
793
|
+
| Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
|
|
794
|
+
|
|
795
|
+
## Platform support
|
|
796
|
+
|
|
797
|
+
| | |
|
|
798
|
+
| --- | --- |
|
|
799
|
+
| Linux | x86-64, arm64 |
|
|
800
|
+
| macOS | Intel, Apple Silicon |
|
|
801
|
+
| Windows | x86-64, arm64 |
|
|
802
|
+
|
|
803
|
+
`$` lines use bash where it exists, Git Bash on Windows, and `sh` otherwise — so one file works everywhere. Set
|
|
804
|
+
`.shell` to pin something else.
|
|
833
805
|
|
|
834
806
|
## License
|
|
835
807
|
|