@runfile/cli 0.38.0 → 0.40.0

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.
@@ -1,708 +0,0 @@
1
- # Runfile
2
-
3
- [Quick start](#quick-start) · [Why Runfile?](#why-runfile) · [Features](#what-makes-it-different) · [Platforms](#platform-support) · [Docs](.github/DOCS.md)
4
-
5
- **One JSON file. One binary. Every OS. Every shell.**
6
-
7
- A modern command runner that replaces Makefiles, shell scripts, and `npm run` — without the platform headaches.
8
-
9
- ```bash
10
- $ run dev --port=4000
11
- ```
12
-
13
- ```jsonc
14
- {
15
- "$schema": "https://github.com/Skiley/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
- }
41
- ```
42
-
43
- ---
44
-
45
- ## Quick start
46
-
47
- **1. Install:**
48
-
49
- npm:
50
-
51
- ```bash
52
- npm install -g @runfile/cli
53
- ```
54
-
55
- Linux / macOS:
56
-
57
- ```bash
58
- curl -fsSL https://github.com/Skiley/runfile/releases/latest/download/install.sh | sh
59
- ```
60
-
61
- Windows (via PowerShell):
62
-
63
- ```powershell
64
- iwr https://github.com/Skiley/runfile/releases/latest/download/install.ps1 | iex
65
- ```
66
-
67
- **2. Generate a starter `Runfile.json`** in your project root:
68
-
69
- ```bash
70
- $ run :init
71
- ```
72
-
73
- **3. List and run targets:**
74
-
75
- ```bash
76
- $ run :list
77
- $ run build
78
- $ run test --release
79
- $ run dev --port=4000
80
- ```
81
-
82
- **4. Add tab completion** (optional):
83
-
84
- ```bash
85
- $ run :completions install bash # or zsh, fish, powershell
86
- ```
87
-
88
- **5. Keep it up to date:**
89
-
90
- ```bash
91
- $ run :update # update to the latest release in place
92
- $ run :update --version v0.19.0 # or pin a specific release tag
93
- ```
94
-
95
- `:update` re-runs the install script for your platform, replacing the binary where it already lives. npm-managed
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).
98
-
99
- ---
100
-
101
- ## Why Runfile?
102
-
103
- Other runners assume one platform and one shell. Runfile assumes **none**.
104
-
105
- Sorted from where Runfile is most differentiated to where it lags behind.
106
-
107
- | Feature | Runfile | Make | Just | Taskfile |
108
- |---------------------------------------------------------------------|:-------:|:----:|:----:|:--------:|
109
- | Encrypted env vars, built-in (AES-256-GCM) | ✅ | ❌ | ❌ | ❌ |
110
- | MCP server (AI agent integration) | ✅ | ❌ | ❌ | ❌ |
111
- | `when:` blocks for success/failure/always cleanup | ✅ | ❌ | ❌ | ❌ |
112
- | Inline OS / shell / cwd / paths / debug branching via `{{ RUN.* }}` | ✅ | ❌ | ❌ | ❌ |
113
- | Detached background execution | ✅ | ❌ | ❌ | ❌ |
114
- | IDE task generation (VS Code / Zed / JetBrains) | ✅ | ❌ | ❌ | ❌ |
115
- | Migrate from `package.json` / `Makefile` | ✅ | ❌ | ❌ | ❌ |
116
- | Force-kill process tree on SIGINT (GUI apps) | ✅ | ❌ | ❌ | ❌ |
117
- | Stdio log tailers (`extendStdio`) | ✅ | ❌ | ❌ | ❌ |
118
- | First-class PowerShell / cmd.exe | ✅ | ❌ | ✅ | ❌ |
119
- | Fish shell support | ✅ | ❌ | ✅ | ❌ |
120
- | Per-target shell override | ✅ | ❌ | ✅ | ❌ |
121
- | Strict parsing (typos are errors) | ✅ | ❌ | ✅ | ❌ |
122
- | Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
123
- | JSON Schema autocomplete in editors | ✅ | ❌ | ❌ | ✅ |
124
- | Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
125
- | Output grouping / prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
126
- | Native Windows (no WSL / msys2 needed) | ✅ | ❌ | ✅ | ✅ |
127
- | Shell completions | ✅ | ❌ | ✅ | ✅ |
128
- | Private / internal targets (`_name`) | ✅ | ❌ | ✅ | ✅ |
129
- | Built-in string functions (upper, replace, trim, regex, base64, …) | ✅ | ❌ | ✅ | ✅ |
130
- | Parallel execution | ✅ | ✅ | ❌ | ✅ |
131
- | Single static binary | ✅ | ✅ | ✅ | ✅ |
132
- | Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
133
- | Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
134
- | Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
135
-
136
- ---
137
-
138
- ## What makes it different
139
-
140
- #### Cross-shell, not just cross-platform
141
-
142
- Auto-detects the shell, or pin one per target. The same Runfile works for a teammate using `bash` on Linux and another
143
- using PowerShell on Windows.
144
-
145
- ```jsonc
146
- {
147
- "targets": {
148
- "ps": { "commands": ["Write-Host 'hi'"], "forceShell": "powershell" },
149
- "bash": { "commands": ["echo $BASH_VERSION"], "forceShell": "bash" }
150
- }
151
- }
152
- ```
153
-
154
- #### Encrypted env vars, baked in
155
-
156
- AES-256-GCM secrets you can commit to git. Auto-decrypted at runtime, never written to disk in plaintext. No external
157
- tool, no wrapper.
158
-
159
- ```bash
160
- $ run :env init .env.production
161
- $ run :env set .env.production DB_PASS "s3cr3t" # auto-encrypted
162
- $ run :env set .env.production DB_PASS # omit value → read from stdin (no shell history, no escaping)
163
- ```
164
-
165
- ```bash
166
- RUNFILE_ENCRYPTION_PUBLIC_KEY=9f86d081...
167
- DB_PASS=encrypted:BASE64_CIPHERTEXT...
168
- ```
169
-
170
- You can also use Runfile as a drop-in `dotenvx`/`dotenv` replacement to inject env vars (encrypted or plaintext) into
171
- any command — no Runfile needed:
172
-
173
- ```bash
174
- $ run :env inject .env -- node app.js # one file
175
- $ run :env inject .env .env.local -- pnpm dev # multiple files, last wins; parent env always wins
176
- $ run :env inject .env.production -- ./deploy.sh # encrypted values auto-decrypted
177
- ```
178
-
179
- In CI, the file path can be supplied via `RUNFILE_ENV_FILE_TARGET` (set automatically by the
180
- [setup action's `env-file-source` input](.github/actions/setup/action.yml) for open-source
181
- repos that keep their encrypted `.env` in a GitHub secret):
182
-
183
- ```bash
184
- $ run :env inject -- node app.js # uses $RUNFILE_ENV_FILE_TARGET
185
- ```
186
-
187
- #### Powerful argument substitution
188
-
189
- Positional, named, flags, env vars, and runtime context (`{{ RUN.os }}` / `{{ RUN.arch }}` / `{{ RUN.shell }}` /
190
- `{{ RUN.cwd }}` / `{{ RUN.file }}` / `{{ RUN.parent }}`) — with chained fallbacks and required values. The
191
- `{{ ... }}` syntax avoids collisions with shell `$(...)` command substitution.
192
-
193
- The source prefixes are `{{ ARG.<name> }}` (named arg), `{{ ENV.<name> }}`, `{{ FLAG.<name> }}`,
194
- `{{ VAR.<name> }}`, and `{{ RUN.<key> }}`. Bare `{{ ARGS }}` expands to **all** positional arguments.
195
-
196
- ```jsonc
197
- "PORT": "{{ ARG.port ? ENV.PORT ? '3000' }}",
198
- "OPTS": "{{ FLAG.release ? '--release' : }} {{ FLAG.verbose ? '-v' : }}",
199
- "CARGO_TARGET_DIR": "target-{{ RUN.os }}",
200
- // Inline OS/shell branches — the boolean DSL goes inside a single `{{ ... }}` block:
201
- { "if": "{{ RUN.os == 'windows' }}", "then": ["del /S /Q build"], "else": ["rm -rf build"] },
202
- // User-supplied flags branch through `{{ FLAG.x }}` (resolves to "true" / "false"):
203
- { "if": "{{ FLAG.debug }}", "then": ["./tool --verbose"], "else": ["./tool"] },
204
- // workingDirectory is a free-form path (defaults to {{ RUN.parent }}):
205
- "workingDirectory": "{{ ARG.workdir ? RUN.cwd }}"
206
- ```
207
-
208
- Strict format: exactly one space after `{{` and before `}}`, exactly one space around `?` and `:` operators.
209
- **String literals must be wrapped in single quotes** — `'production'`, not `production`. Source references
210
- (`ARG.x`, `VAR.x`, etc.) and function calls remain bare. Use `\{{` / `\}}` to emit a literal `{{` / `}}` in
211
- your output.
212
-
213
- #### Boolean conditions inside substitutions
214
-
215
- A substitution body containing `==`, `!=`, `&&`, `||`, or unary `!` at top level is evaluated as a boolean
216
- expression — the result is the string `"true"` or `"false"`. This is what powers the new `if`-block syntax,
217
- but you can use it anywhere:
218
-
219
- ```jsonc
220
- "commands": [
221
- // Branch on a CLI flag — `if` checks if the substitution resolves to the literal "true":
222
- { "if": "{{ ARG.env == 'production' }}",
223
- "then": ["./deploy-prod.sh"],
224
- "else": ["./deploy-staging.sh"] },
225
-
226
- // Compose AND/OR/NOT freely:
227
- { "if": "{{ ARG.env != 'development' && ARG.env != 'production' }}",
228
- "then": ["./deploy-staging.sh"] },
229
-
230
- // Invert any boolean-returning value with unary `!` — including the
231
- // comparison helpers (`less_than`, `is_number`, …) and `contains` etc.:
232
- { "if": "{{ !is_number(ARG.port) }}",
233
- "then": ["echo 'port must be a number' && exit 1"] },
234
-
235
- // FLAG.x works as a bare boolean inside DSL — no `== 'true'` needed:
236
- { "if": "{{ RUN.os == 'windows' && FLAG.wsl }}",
237
- "then": "wsl --shell-type login -- vite build" },
238
-
239
- // Inline in a command — useful for passing booleans to other tools:
240
- "my-command --resolve {{ ARG.env == 'production' }}"
241
- ]
242
- ```
243
-
244
- **Strict boolean rule.** Any value used as a bare boolean — both inside the DSL Truthy check (e.g. `&& FLAG.x`,
245
- `!ARG.y`) and as the entire `if` condition — must resolve to exactly one of:
246
-
247
- - `"true"` → truthy (the `then` branch runs / left arm of `&&` continues)
248
- - `"false"` → falsy
249
- - `""` (empty) → falsy
250
-
251
- Anything else (`"True"`, `"1"`, `"yes"`, `"hello"`, etc.) errors out with a clear message pointing you toward
252
- the explicit comparison form. So `if: "{{ ARG.x }}"` and `{{ ARG.x && ... }}` only work when `ARG.x` is
253
- exactly `"true"` / `"false"` / empty — for any other check, use a comparison: `{{ ARG.x == 'yes' }}`.
254
-
255
- Comparisons (`==` / `!=`) operate on raw strings — *those* don't have the boolean restriction, so
256
- `{{ ARG.env == 'staging' }}` works for any value of `ARG.env`.
257
-
258
- #### Functions: transform values inline
259
-
260
- Wrap any source or chain expression in a function call. Functions resolve at substitution time, can be nested,
261
- and work as full substitution bodies *or* as chain segments:
262
-
263
- ```jsonc
264
- "commands": [
265
- // Built-ins:
266
- // case : to_upper, to_lower, capitalize
267
- // trim : trim, trim_start, trim_end
268
- // inspect : length, starts_with, ends_with, contains, substring
269
- // transform : escape, repeat, replace_all, remove_all
270
- // regex : regex_replace, regex_remove, regex_matches, regex_capture, regex_capture_all
271
- // build : concat, join
272
- // split : nth, first, last, count_parts
273
- // path : basename, dirname, extname, stem, join_path
274
- // math : add, subtract, multiply, divide, modulo, power, min, max, abs, round, floor, ceil
275
- // compare : less_than, less_than_or_equal, greater_than, greater_than_or_equal, is_number
276
- // validate : one_of
277
- // encoding : base64_encode, base64_decode, url_encode, url_decode
278
- // hashing : sha256, md5
279
- // ids/time : uuid, now
280
- // files : read_file, write_file, file_exists, temp_file, temp_dir
281
- // json : json_get, json_set
282
- // control : try, error
283
- // shell : shell_quote, capture
284
- // variables : define
285
- // cwd : set_cwd
286
- "echo deploying-{{ to_upper(ARG.env) }}",
287
- "curl -H \"X-Auth: {{ base64_encode(ENV.TOKEN) }}\" ...",
288
- "echo {{ concat('hello-', ARG.name, '-2026') }}",
289
- "echo {{ join(' AND ', 'flag-1', 'flag-2', ARG.extra) }}",
290
- // Tokenise-and-rejoin-style transforms via replace_all:
291
- "go test {{ replace_all(ARG.flags, ' ', ' -tag=') }}",
292
- // Strip every match of a regex (here: collapse whitespace runs to a single space):
293
- "echo {{ regex_replace(ARG.text, '\\s+', ' ') }}",
294
- // Pull a capture group out of the first regex match — no `(?s)^.*X(...)X.*$`
295
- // greedy-replace trick needed. Group 0 is the whole match; group N is the
296
- // N-th `(...)`. Out-of-bounds returns "" (same convention as `nth`).
297
- "echo version={{ regex_capture(read_file('app/build.gradle.kts'), 'versionName = \"([^\"]+)\"', '1') }}",
298
- // Pull group N out of EVERY match and join with a separator (the "all" variant
299
- // of regex_capture). Here: every quoted dependency name, comma-separated.
300
- "echo deps={{ regex_capture_all(read_file('deps.txt'), 'name = \"([^\"]+)\"', '1', ',') }}",
301
- // Split-by-separator scalar accessors — string in, string out, no list type:
302
- // basename idiom (last segment after `/`); empty string when input ends in `/`.
303
- "echo basename={{ last(ARG.path, '/') }}",
304
- // Pull out the N-th comma-separated field; out-of-bounds returns "".
305
- "echo target={{ nth(ARG.csv, ',', '1') }}",
306
- // Pair `count_parts` with `nth` to bound-check before indexing:
307
- // "if": "{{ count_parts(ARG.csv, ',') == '3' }}"
308
- // Boolean-returning helpers are valid DSL `Truthy` values — use them in `if`:
309
- // "if": "{{ starts_with(ARG.path, '/usr') }}"
310
- // "if": "{{ regex_matches(ARG.tag, '^v[0-9]+\\.[0-9]+$') }}"
311
-
312
- // Safely inline arbitrary content (newlines, quotes, JSON) as a CLI arg —
313
- // `shell_quote` picks the right quoting for the active shell:
314
- "some-tool --json {{ shell_quote(base64_decode(ENV.SECRET_BASE64)) }}",
315
-
316
- // Slurp shell-command stdout straight into a substitution. `capture` runs
317
- // the command through the platform's default shell (sh / cmd) and trims
318
- // the trailing newline. Results are memoized per-target so the same
319
- // capture in multiple commands runs once. `--dry-run` substitutes a
320
- // readable placeholder instead of spawning the shell.
321
- "echo built-at={{ capture('date -u +%Y-%m-%dT%H:%M:%SZ') }}",
322
- "{{ define(sha, capture('git rev-parse HEAD')) }}",
323
-
324
- // Arithmetic — variadic (2+ args), coerces strings to numbers, errors on
325
- // non-numeric input. The result is formatted as an integer when whole and
326
- // as a decimal otherwise (so `add('5', '3')` → "8", `add('5', '1.1')` →
327
- // "6.1"). Divide-by-zero errors out.
328
- "echo next-build={{ add(VAR.versionCode, '1') }}",
329
- "echo half={{ divide(VAR.total, '2') }}",
330
- // …plus modulo, power, min, max, abs, round, floor, ceil:
331
- "echo shard={{ modulo(ARG.id, '8') }} clamped={{ max('0', min('100', ARG.pct)) }}",
332
-
333
- // Path helpers (std::path semantics). basename/dirname/extname/stem/join_path:
334
- "echo name={{ basename(ARG.file) }} ext={{ extname(ARG.file) }}",
335
- "cp {{ ARG.file }} {{ join_path(ARG.outdir, basename(ARG.file)) }}",
336
-
337
- // Substring (char-indexed; 3rd arg = length, optional → to end). Short SHA:
338
- "echo short={{ substring(capture('git rev-parse HEAD'), '0', '7') }}",
339
-
340
- // Current UTC time. Formats: unix-timestamp, unix-millis, iso, iso-date,
341
- // iso-time, rfc3339, year/month/day/hour/minute/second.
342
- "echo built-at={{ now('iso') }} tag=build-{{ now('unix-timestamp') }}",
343
-
344
- // UUID (v4-shaped) for unique temp / cache names. `--dry-run` → `<uuid>`.
345
- "echo tmp=/tmp/job-{{ uuid() }}",
346
-
347
- // URL percent-encode / decode (space → %20; symmetric round-trip):
348
- "curl 'https://api/search?q={{ url_encode(ARG.query) }}'",
349
-
350
- // Numeric comparisons — coerce both args to numbers (same rules as the
351
- // arithmetic family, so non-numeric input errors) and return "true"/"false",
352
- // so they work as DSL `Truthy` values in `if` conditions:
353
- // "if": "{{ greater_than(VAR.count, '50') }}"
354
- // "if": "{{ less_than_or_equal(ARG.retries, '3') }}"
355
- // `is_number` tests whether a value parses as a (finite) number — it returns
356
- // "false" instead of erroring on non-numeric input:
357
- // "if": "{{ is_number(ARG.port) }}"
358
-
359
- // Validate a value against a fixed allow-list. Returns the value on
360
- // match; lists every valid option in the error on mismatch. Collapses
361
- // the four-case `match { major: define(part, 'major'), ... }` boilerplate.
362
- "{{ define(part, one_of(ARGS, 'major', 'minor', 'patch', 'build')) }}",
363
-
364
- // Cache keys / content fingerprints. `sha256` for security-sensitive use,
365
- // `md5` for cheap fingerprinting (NOT cryptographically secure).
366
- "echo cache-key={{ sha256(read_file('package-lock.json')) }}",
367
-
368
- // Branch on file presence without forking a shell. `file_exists` returns
369
- // the literal "true" / "false", so it doubles as a DSL truthy value.
370
- // "if": "{{ file_exists('.env.local') }}"
371
-
372
- // Read a file inline — relative paths anchor to the Runfile directory.
373
- // Use `try(...)` to recover from missing files.
374
- "echo version={{ try(read_file('VERSION')) ? '0.0.0-dev' }}",
375
-
376
- // Write a file straight from a substitution — pairs with `read_file` for
377
- // read-modify-write pipelines (version bumps, config edits, …). Returns
378
- // an empty string, so a line containing only this call is dropped silently
379
- // (same convention as `define`). Goes through Rust's `std::fs::write`, so
380
- // it sidesteps the shell entirely — safe for large content, content with
381
- // quotes, embedded newlines, anything that would break a
382
- // `printf > file`-style redirect on Windows. `--dry-run` skips the write.
383
- "{{ write_file('build.gradle.kts', regex_replace(read_file('build.gradle.kts'), 'versionCode = [0-9]+', 'versionCode = 43')) }}",
384
-
385
- // Create a temp file / dir in the OS temp directory, auto-deleted when the
386
- // CLI exits. `temp_file([content], [extension])` writes `content` (if given)
387
- // and appends `.<extension>` (leading dot optional); `temp_dir()` makes an
388
- // empty directory (removed recursively). `--dry-run` → `<temp_file>` /
389
- // `<temp_dir>` placeholders, nothing created. Capture the path once with
390
- // `define` and reuse it:
391
- "{{ define(cfg, temp_file('{\"env\":\"prod\"}', 'json')) }}",
392
- "tool --config {{ VAR.cfg }}",
393
- "{{ define(work, temp_dir()) }}",
394
- "git clone --depth 1 $REPO {{ VAR.work }} && build {{ VAR.work }}",
395
-
396
- // Pull values out of arbitrary JSON without `jq` (works on every shell):
397
- "echo db_host={{ json_get(read_file('config.json'), 'database.host') }}",
398
-
399
- // Modify a JSON document in place (returns the new compact JSON):
400
- "echo {{ json_set('{\"port\":3000}', 'env', 'production') }}",
401
-
402
- // `try(expr)` swallows inner errors. Standalone returns "" on failure;
403
- // chained, the next segment runs as a fallback.
404
- "echo {{ try(base64_decode(ARG.maybe_b64)) ? ARG.maybe_b64 }}",
405
-
406
- // `error('message')` fails the current command on purpose: prints the
407
- // message to stderr and marks the step failed — but the failure flows
408
- // through the normal walker, so `when: failure` / `when: always` steps
409
- // still run and `ignoreErrors` still suppresses it. Great for guard clauses:
410
- // { "if": "{{ ARG.env == 'prod' }}", "then": ["{{ error('refusing to deploy to prod from a laptop') }}"] }
411
-
412
- // Nested:
413
- "echo {{ to_upper(to_lower(ARG.x)) }}",
414
-
415
- // As a chain fallback:
416
- "echo host={{ ARG.host ? to_lower(ENV.HOST) }}",
417
-
418
- // Capture a value with `define(...)` and read it later via `VAR.<name>`:
419
- "{{ define(sdk, ENV.SDK ? '/opt/sdk') }}",
420
- "{{ VAR.sdk }}/bin/build",
421
- "echo using sdk at {{ VAR.sdk }}",
422
-
423
- // `set_cwd(path)` switches the cwd subsequent commands spawn in — like
424
- // shell `cd`, but works on every shell / OS without forking. Relative
425
- // paths chain (matching `cd a; cd b` → `a/b`); absolute paths replace.
426
- "{{ set_cwd(ARG.subproject ? 'packages/api') }}",
427
- "npm install",
428
- "npm run build",
429
-
430
- // Single-quoted strings interpolate nested {{ }} substitutions:
431
- "{{ define(cmd, 'docker compose -f {{ VAR.compose }} pull') }}",
432
- "{{ VAR.cmd }}"
433
- ]
434
- ```
435
-
436
- `define(name, value)` returns an empty string and stores `value` in a run-wide map; a command line that resolves
437
- to only whitespace (i.e. one consisting solely of a `{{ define(...) }}` call) is silently skipped instead of
438
- being dispatched to the shell. `define`s in a parent target are visible to `@target` children. The `name` MUST
439
- be a bareword identifier matching `[A-Za-z_][A-Za-z0-9_-]*` — quotes are NOT allowed on the name.
440
-
441
- #### Declaring variables up front (`vars`)
442
-
443
- Instead of (or alongside) `define(...)`, you can declare variables directly on a target — or on `globals` to
444
- apply them to every target — and read them as `{{ VAR.<key> }}`. This mirrors `env`: each value is a `{{ ... }}`
445
- template resolved **after** the env is built (so it can reference `{{ ENV.* }}`), plus `{{ ARG.* }}`,
446
- `{{ FLAG.* }}`, `{{ RUN.* }}`, and earlier vars. If a reference has no default and isn't supplied, it errors —
447
- exactly like everywhere else.
448
-
449
- ```jsonc
450
- {
451
- "globals": {
452
- "vars": { "appName": "skiley" } // available to every target
453
- },
454
- "targets": {
455
- "deploy": {
456
- "env": { "REGION": "us-east-1" },
457
- "vars": {
458
- "region": "{{ ENV.REGION }}", // vars resolve after env
459
- "retries": "3", // numbers/bools are stringified
460
- "tag": "{{ ARG.tag ? 'latest' }}" // chain fallbacks work
461
- },
462
- "commands": ["echo deploying {{ VAR.appName }} to {{ VAR.region }} as {{ VAR.tag }}"]
463
- }
464
- }
465
- }
466
- ```
467
-
468
- Global `vars` are merged into each target's `vars` at parse time (target keys win on conflict). Declared vars are
469
- **scoped per-target like `env`**: a parent's vars are visible inside an `@target` dependency, but a dependency's
470
- own declared vars don't leak back to the parent. A runtime `define(...)` of the same name overrides the declared
471
- value for the rest of the target. Var keys must match `[A-Za-z_][A-Za-z0-9_-]*`.
472
-
473
- `set_cwd(path)` is the cwd analog of `define`: returns an empty string and changes the cwd that subsequent shell
474
- commands in the *current target* spawn in. Behaves like shell `cd`, but works uniformly across every shell / OS
475
- (no forked process, no `cd` binary needed). Resolution rules:
476
-
477
- - **Absolute path** → fully replaces the current override (matches `cd /abs`).
478
- - **Relative path** → joins onto the existing override, or onto the target's `workingDirectory` if no override
479
- has been set yet. So `set_cwd('a'); set_cwd('b')` lands subsequent commands in `<workingDirectory>/a/b`,
480
- matching how `cd a; cd b` chains in a shell.
481
-
482
- `set_cwd` is **per-target**: each `@target` invocation starts with a clean override (the dispatched target sees
483
- its own `workingDirectory`, not the caller's `set_cwd` state). Inside `parallel: true` targets each leaf
484
- captures the override at the moment of its substitution, so siblings don't race on the spawn cwd. Like
485
- `define`, the side effect is skipped on the redacted-logging pass so log lines don't double-apply it. With
486
- `sameShell: true`, only the *final* `set_cwd` value applies (everything joins into one shell invocation) — use
487
- shell `cd` directly between leaves there if you need intermediate cwd changes.
488
-
489
- Function args are separated by `, ` (comma + exactly one space).
490
-
491
- **Quote semantics inside `{{ ... }}`:**
492
-
493
- - **Single quotes (`'...'`) — interpolated string**: surrounding quotes stripped, with any nested `{{ ... }}`
494
- resolved through the regular substitution machinery. Use these for almost every literal — they handle the
495
- full range of values including embedded substitutions: `concat('a, b', ARG.x)`,
496
- `'/var/log/{{ RUN.os }}.log'`, `define(cmd, 'docker -f {{ VAR.compose }} pull')`.
497
- - **Double quotes (`"..."`) — fully literal value**: the quote characters are part of the value (so `"test"` is
498
- the 6-character string `"test"`). No interpolation. Useful when the literal you want to emit really should
499
- contain `"` chars.
500
- - **Plain barewords are rejected** — `{{ ARG.env ? development }}` is a parse-time error. Wrap in quotes.
501
-
502
- #### Conditionals and loops, no shell required
503
-
504
- Drop `if` / `for` / `match` blocks straight into your `commands` array. Conditions, loops, and value dispatch are
505
- evaluated by Runfile itself, so the logic works the same on every shell and platform.
506
-
507
- ```jsonc
508
- "commands": [
509
- { "if": "{{ ARG.env == 'production' }}",
510
- "then": ["./deploy-prod.sh"],
511
- "else": ["./deploy-staging.sh"] },
512
-
513
- { "match": "{{ ARG.tier ? '1' }}", // chain default; case "1" runs when --tier missing
514
- "cases": {
515
- "1": "flutter emulators --launch Tier_1_Android_9",
516
- "2": "flutter emulators --launch Tier_2_Android_11",
517
- "3": "flutter emulators --launch Tier_3_Android_14",
518
- // Case keys wrapped in `/.../` are treated as regex patterns.
519
- // Literal cases always win over a regex that would also match.
520
- "/^v\\d+$/": "echo version-tag",
521
- "/^pr-\\d+$/": "echo preview-build"
522
- } }, // unknown values error out, listing the valid cases
523
-
524
- { "for": "service",
525
- "in": ["api", "web", "worker"],
526
- "parallel": true,
527
- // VAR.<name>_index exposes the 0-based iteration counter.
528
- "do": ["echo [{{ VAR.service_index }}] docker build -t {{ VAR.service }} services/{{ VAR.service }}"] },
529
-
530
- { "for": "file",
531
- "shell": "git diff --name-only HEAD~1",
532
- "do": ["clang-format -i {{ VAR.file }}"] }
533
- ]
534
- ```
535
-
536
- #### Call other targets inline with `@target`
537
-
538
- Any command string starting with `@` invokes another target. Forward args with `{{ ARGS }}` or pass anything explicit.
539
- Each invocation runs (no dedup), inherits the parent's env, and works inside `if` / `for` blocks. Parallel parents
540
- fan out target calls onto worker threads.
541
-
542
- Prefix with `@?target` to mark the call **optional**: if the (substituted) target doesn't exist, it's silently
543
- skipped instead of erroring. Useful with `for in: "namespaces"` when the dispatched target isn't defined in every
544
- namespace.
545
-
546
- ```jsonc
547
- "commands": [
548
- "@lint",
549
- "@test --coverage",
550
- "@build {{ ARGS }}",
551
- { "if": "{{ RUN.os == 'windows' }}", "then": "@deploy-win", "else": "@deploy-unix" },
552
- { "for": "ns", "in": "namespaces", "do": "@?{{ VAR.ns }}:adb-forward" } // skip namespaces without the target
553
- ]
554
- ```
555
-
556
- #### Readable parallel output
557
-
558
- When commands run in parallel (`"parallel": true` on a target or `for` block), each branch's stdout/stderr is
559
- line-buffered, prefixed with a colored bracketed label identifying the branch, and stripped of cursor-control
560
- escapes — so progress-bar redraws (`docker compose pull`, `cargo build`, etc.) become chronological append-only
561
- lines instead of corrupting each other's output. The label shows the full resolved `@target` invocation
562
- (`[@dev --port 5000]`) for target-call branches, or the raw command truncated to 12 characters (`[docker compo]`)
563
- for shell branches; each label gets one of six cycling colors so adjacent branches stay distinct.
564
- SGR colors flow through unchanged. The prefix propagates through `@target` invocations too,
565
- so monorepo fan-outs like `{ "for": "ns", "in": "namespaces", "do": "@{{ VAR.ns }}:dev" }` tag every nested shell
566
- with its branch identity. Set `RUNFILE_NO_LINE_PREFIX=1` to opt out and inherit raw stdio.
567
-
568
- ```text
569
- [runfile] (1/3) [parallel] docker compose pull api
570
- [runfile] (2/3) [parallel] docker compose pull web
571
- [runfile] (3/3) [parallel] docker compose pull worker
572
- [1] Pulling api ... 50%
573
- [2] Pulling web ... 30%
574
- [3] Pulling worker ... 100%
575
- [1] Pulling api ... 100%
576
- [2] Pulling web ... 100%
577
- ```
578
-
579
- #### Cleanup on failure / always with `when:` blocks
580
-
581
- Wrap any commands in a `when:` block to run them only after a failure, or always — interleaved with the rest of
582
- `commands`, no separate `before`/`after` arrays.
583
-
584
- ```jsonc
585
- "commands": [
586
- "./run-tests.sh", // failure flips state
587
- "./report.sh", // skipped on failure (default when:success)
588
- { "when": "failure", "commands": ["./post-failure.sh"] }, // runs only after a failure
589
- { "when": "always", "commands": ["@cleanup"] } // runs every time
590
- ]
591
- ```
592
-
593
- #### Single-shell mode with `sameShell`
594
-
595
- By default each step runs in its own shell process, so `cd`, exported variables, and other shell state don't carry
596
- over between steps. Set `"sameShell": true` to join every step into a single shell invocation — state changes
597
- persist for free.
598
-
599
- ```jsonc
600
- {
601
- "deploy": {
602
- "sameShell": true,
603
- "commands": ["cd ci-scripts/", "./ci-deploy.sh"]
604
- }
605
- }
606
- ```
607
-
608
- Steps are joined with `&&` so the run stops at the first failure (or `;` / `&` when `ignoreErrors: true`). `if`,
609
- `for`, and `match` blocks are evaluated by Runfile and their chosen branches flow into the same joined invocation.
610
- `@target` calls inside the body are rejected — they have their own shell context and can't share state with the
611
- parent. `sameShell` also composes with `detach: true` (joined command spawns as one detached process) and is
612
- available on `globals` for project-wide defaults.
613
-
614
- #### Watch mode, built in
615
-
616
- Add a `watch` array to any target. No flags, no extra tooling.
617
-
618
- ```jsonc
619
- "watch": ["src/**/*.rs", "!target/**"]
620
- ```
621
-
622
- #### Editor integrations
623
-
624
- Generate native tasks for **VS Code**, **Zed**, and **JetBrains** IDEs from your Runfile:
625
-
626
- ```bash
627
- $ run :generate vscode-tasks
628
- $ run :generate zed-tasks
629
- $ run :generate jetbrains-run-configurations
630
- ```
631
-
632
- Pass `--stdout` to any of these to print the generated configuration to standard output instead of
633
- writing it to disk — handy for previewing, piping, or diffing in CI. The output is the freshly
634
- generated config (not merged with any existing file on disk) and nothing is created or modified.
635
- For `jetbrains-run-configurations`, which produces one file per target, each block is delimited by a
636
- `<!-- .run/<file> -->` comment when more than one is emitted.
637
-
638
- By default only the local Runfile's own targets are generated. Two independent flags widen the set,
639
- and both compose with `--stdout` and the on-disk writers:
640
-
641
- - `--include-namespaces` also generates entries for targets pulled in via `includes` — namespaced
642
- targets carry their `namespace:` prefixes, exactly as `run :list` shows them (e.g. `run api:build`).
643
- - `--include-globals` also generates entries for the global user-level Runfiles registered with
644
- `run :config global-files` — the same ones `run :list` folds in. Handy when an editor integration
645
- should offer your machine-wide targets (e.g. `run c`, `run backup-git`) alongside a project's own.
646
-
647
- Skip a target from the generated configs by setting `metadata.excludeFromGenerateCommand: true`. The
648
- `metadata` block on `globals` and on each target is **fully open** — any property of any JSON type
649
- (strings, numbers, booleans, arrays, deeply-nested objects) is accepted and round-trips untouched, so
650
- editor extensions and CI scripts can stash arbitrary tooling-specific fields here.
651
-
652
- Every file Runfile writes honors your project's [**`.editorconfig`**](https://editorconfig.org): the
653
- output is formatted to match the settings that apply to each written path — the editor files above
654
- (`.vscode/tasks.json`, `.zed/tasks.json`, `.run/*.run.xml`) as well as the `Runfile.json` produced by
655
- `:init` and `:convert`. `indent_style` / `indent_size` / `tab_width` drive indentation, `end_of_line`
656
- sets the newline sequence, `insert_final_newline` and `trim_trailing_whitespace` are applied, and
657
- `charset = utf-8-bom` prepends a BOM. When no `.editorconfig` applies, the previous defaults are kept.
658
-
659
- #### Migrate in seconds
660
-
661
- ```bash
662
- $ run :convert package-json # turns package.json scripts into targets
663
- $ run :convert makefile # turns Makefile recipes into targets
664
- ```
665
-
666
- #### Plus
667
-
668
- Parallel & detached execution · file includes with optional namespacing for monorepos
669
- (`pnpm --recursive --parallel`-style aggregation) · cycle detection · global Runfiles · path aliases ·
670
- confirmation prompts · `--dry-run` · `--stdin-args` (interactive prompting for missing inputs) · `--timings` ·
671
- force-kill on SIGINT (works for GUI apps like Unity) · stdio tailers · shell completions · MCP server.
672
-
673
- ---
674
-
675
- ## Platform Support
676
-
677
- | Platform | Status |
678
- |-----------------------|-----------|
679
- | Linux (x86-64) | Supported |
680
- | Linux (ARM64) | Supported |
681
- | macOS (Apple Silicon) | Supported |
682
- | macOS (Intel) | Supported |
683
- | Windows 10 | Supported |
684
- | Windows 11 | Supported |
685
- | Windows Server | Supported |
686
-
687
- Single static binary. No runtime, no interpreter, no package manager.
688
-
689
- ---
690
-
691
- ## Documentation
692
-
693
- The full reference — every property, every flag, every subcommand — lives in [DOCS.md](.github/DOCS.md).
694
-
695
- - [CLI usage and subcommands](.github/DOCS.md#cli-usage)
696
- - [Runfile.json reference](.github/DOCS.md#runfilejson-reference)
697
- - [Arguments and substitution](.github/DOCS.md#arguments-and-substitution)
698
- - [Encrypted environment variables](.github/DOCS.md#encrypted-environment-variables)
699
- - [Internal targets](.github/DOCS.md#internal-targets)
700
- - [`when:` blocks](.github/DOCS.md#when-guarded-blocks-when)
701
- - [Watch mode](.github/DOCS.md#watch-mode)
702
- - [Editor integration](.github/DOCS.md#editor-integration)
703
-
704
- ---
705
-
706
- ## License
707
-
708
- MIT