@runfile/cli 0.12.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.
@@ -0,0 +1,449 @@
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": "{{ ARGS.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' && FLAGS.wsl }}",
29
+ "then": "wsl --shell-type login -- vite build"
30
+ }
31
+ ],
32
+ "envFiles": [".env", ".env.{{ ARGS.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
+ ```bash
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
+ ---
89
+
90
+ ## Why Runfile?
91
+
92
+ Other runners assume one platform and one shell. Runfile assumes **none**.
93
+
94
+ Sorted from where Runfile is most differentiated to where it lags behind.
95
+
96
+ | Feature | Runfile | Make | Just | Taskfile |
97
+ |---------------------------------------------------------------------|:-------:|:----:|:----:|:--------:|
98
+ | Encrypted env vars, built-in (AES-256-GCM) | ✅ | ❌ | ❌ | ❌ |
99
+ | MCP server (AI agent integration) | ✅ | ❌ | ❌ | ❌ |
100
+ | `when:` blocks for success/failure/always cleanup | ✅ | ❌ | ❌ | ❌ |
101
+ | Inline OS / shell / cwd / paths / debug branching via `{{ RUN.* }}` | ✅ | ❌ | ❌ | ❌ |
102
+ | Detached background execution | ✅ | ❌ | ❌ | ❌ |
103
+ | IDE task generation (VS Code / Zed / JetBrains) | ✅ | ❌ | ❌ | ❌ |
104
+ | Migrate from `package.json` / `Makefile` | ✅ | ❌ | ❌ | ❌ |
105
+ | Force-kill process tree on SIGINT (GUI apps) | ✅ | ❌ | ❌ | ❌ |
106
+ | Stdio log tailers (`extendStdio`) | ✅ | ❌ | ❌ | ❌ |
107
+ | First-class PowerShell / cmd.exe | ✅ | ❌ | ✅ | ❌ |
108
+ | Fish shell support | ✅ | ❌ | ✅ | ❌ |
109
+ | Per-target shell override | ✅ | ❌ | ✅ | ❌ |
110
+ | Strict parsing (typos are errors) | ✅ | ❌ | ✅ | ❌ |
111
+ | Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
112
+ | JSON Schema autocomplete in editors | ✅ | ❌ | ❌ | ✅ |
113
+ | Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
114
+ | Native Windows (no WSL / msys2 needed) | ✅ | ❌ | ✅ | ✅ |
115
+ | Parallel execution | ✅ | ✅ | ❌ | ✅ |
116
+ | Shell completions | ✅ | ❌ | ✅ | ✅ |
117
+ | Private / internal targets (`_name`) | ✅ | ❌ | ✅ | ✅ |
118
+ | Single static binary | ✅ | ✅ | ✅ | ✅ |
119
+ | Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
120
+ | Output grouping / prefixing in parallel mode | ❌ | ❌ | ❌ | ✅ |
121
+ | Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
122
+ | Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
123
+ | Built-in string functions (upper, replace, trim, …) | ❌ | ❌ | ✅ | ✅ |
124
+
125
+ ---
126
+
127
+ ## What makes it different
128
+
129
+ #### Cross-shell, not just cross-platform
130
+
131
+ Auto-detects the shell, or pin one per target. The same Runfile works for a teammate using `bash` on Linux and another
132
+ using PowerShell on Windows.
133
+
134
+ ```jsonc
135
+ {
136
+ "targets": {
137
+ "ps": { "commands": ["Write-Host 'hi'"], "forceShell": "powershell" },
138
+ "bash": { "commands": ["echo $BASH_VERSION"], "forceShell": "bash" }
139
+ }
140
+ }
141
+ ```
142
+
143
+ #### Encrypted env vars, baked in
144
+
145
+ AES-256-GCM secrets you can commit to git. Auto-decrypted at runtime, never written to disk in plaintext. No external
146
+ tool, no wrapper.
147
+
148
+ ```bash
149
+ $ run :env init -p .env.production
150
+ $ run :env set .env.production DB_PASS "s3cr3t" # auto-encrypted
151
+ $ run :env set .env.production DB_PASS # omit value → read from stdin (no shell history, no escaping)
152
+ ```
153
+
154
+ ```bash
155
+ RUNFILE_ENCRYPTION_PUBLIC_KEY=9f86d081...
156
+ DB_PASS=encrypted:BASE64_CIPHERTEXT...
157
+ ```
158
+
159
+ You can also use Runfile as a drop-in `dotenvx`/`dotenv` replacement to inject env vars (encrypted or plaintext) into
160
+ any command — no Runfile needed:
161
+
162
+ ```bash
163
+ $ run :env inject -- node app.js # uses .env by default
164
+ $ run :env inject -f .env -f .env.local -- pnpm dev # multiple files, last wins; parent env always wins
165
+ $ run :env inject -f .env.production -- ./deploy.sh # encrypted values auto-decrypted
166
+ ```
167
+
168
+ #### Powerful argument substitution
169
+
170
+ Positional, named, flags, env vars, and runtime context (`{{ RUN.os }}` / `{{ RUN.shell }}` / `{{ RUN.cwd }}` /
171
+ `{{ RUN.file }}` / `{{ RUN.parent }}`) — with chained fallbacks and required values. The `{{ ... }}` syntax
172
+ avoids collisions with shell `$(...)` command substitution.
173
+
174
+ ```jsonc
175
+ "PORT": "{{ ARGS.port ? ENV.PORT ? '3000' }}",
176
+ "OPTS": "{{ FLAGS.release ? '--release' : }} {{ FLAGS.verbose ? '-v' : }}",
177
+ "CARGO_TARGET_DIR": "target-{{ RUN.os }}",
178
+ // Inline OS/shell branches — the boolean DSL goes inside a single `{{ ... }}` block:
179
+ { "if": "{{ RUN.os == 'windows' }}", "then": ["del /S /Q build"], "else": ["rm -rf build"] },
180
+ // User-supplied flags branch through `{{ FLAGS.x }}` (resolves to "true" / "false"):
181
+ { "if": "{{ FLAGS.debug }}", "then": ["./tool --verbose"], "else": ["./tool"] },
182
+ // workingDirectory is a free-form path (defaults to {{ RUN.parent }}):
183
+ "workingDirectory": "{{ ARGS.workdir ? RUN.cwd }}"
184
+ ```
185
+
186
+ Strict format: exactly one space after `{{` and before `}}`, exactly one space around `?` and `:` operators.
187
+ **String literals must be wrapped in single quotes** — `'production'`, not `production`. Source references
188
+ (`ARGS.x`, `VARS.x`, etc.) and function calls remain bare. Use `\{{` / `\}}` to emit a literal `{{` / `}}` in
189
+ your output.
190
+
191
+ #### Boolean conditions inside substitutions
192
+
193
+ A substitution body containing `==`, `!=`, `&&`, `||`, or unary `!` at top level is evaluated as a boolean
194
+ expression — the result is the string `"true"` or `"false"`. This is what powers the new `if`-block syntax,
195
+ but you can use it anywhere:
196
+
197
+ ```jsonc
198
+ "commands": [
199
+ // Branch on a CLI flag — `if` checks if the substitution resolves to the literal "true":
200
+ { "if": "{{ ARGS.env == 'production' }}",
201
+ "then": ["./deploy-prod.sh"],
202
+ "else": ["./deploy-staging.sh"] },
203
+
204
+ // Compose AND/OR/NOT freely:
205
+ { "if": "{{ ARGS.env != 'development' && ARGS.env != 'production' }}",
206
+ "then": ["./deploy-staging.sh"] },
207
+
208
+ // FLAGS.x works as a bare boolean inside DSL — no `== 'true'` needed:
209
+ { "if": "{{ RUN.os == 'windows' && FLAGS.wsl }}",
210
+ "then": "wsl --shell-type login -- vite build" },
211
+
212
+ // Inline in a command — useful for passing booleans to other tools:
213
+ "my-command --resolve {{ ARGS.env == 'production' }}"
214
+ ]
215
+ ```
216
+
217
+ **Strict boolean rule.** Any value used as a bare boolean — both inside the DSL Truthy check (e.g. `&& FLAGS.x`,
218
+ `!ARGS.y`) and as the entire `if` condition — must resolve to exactly one of:
219
+
220
+ - `"true"` → truthy (the `then` branch runs / left arm of `&&` continues)
221
+ - `"false"` → falsy
222
+ - `""` (empty) → falsy
223
+
224
+ Anything else (`"True"`, `"1"`, `"yes"`, `"hello"`, etc.) errors out with a clear message pointing you toward
225
+ the explicit comparison form. So `if: "{{ ARGS.x }}"` and `{{ ARGS.x && ... }}` only work when `ARGS.x` is
226
+ exactly `"true"` / `"false"` / empty — for any other check, use a comparison: `{{ ARGS.x == 'yes' }}`.
227
+
228
+ Comparisons (`==` / `!=`) operate on raw strings — *those* don't have the boolean restriction, so
229
+ `{{ ARGS.env == 'staging' }}` works for any value of `ARGS.env`.
230
+
231
+ #### Functions: transform values inline
232
+
233
+ Wrap any source or chain expression in a function call. Functions resolve at substitution time, can be nested,
234
+ and work as full substitution bodies *or* as chain segments:
235
+
236
+ ```jsonc
237
+ "commands": [
238
+ // Built-ins:
239
+ // case : to_upper, to_lower, capitalize
240
+ // trim : trim, trim_start, trim_end
241
+ // inspect : length, starts_with, ends_with, contains
242
+ // transform : escape, repeat, replace_all, remove_all
243
+ // regex : regex_replace, regex_remove, regex_matches
244
+ // build : concat, join
245
+ // encoding : base64_encode, base64_decode
246
+ // shell : shell_quote
247
+ // variables : define
248
+ "echo deploying-{{ to_upper(ARGS.env) }}",
249
+ "curl -H \"X-Auth: {{ base64_encode(ENV.TOKEN) }}\" ...",
250
+ "echo {{ concat('hello-', ARGS.name, '-2026') }}",
251
+ "echo {{ join(' AND ', flag-1, flag-2, ARGS.extra) }}",
252
+ // Tokenise-and-rejoin-style transforms via replace_all:
253
+ "go test {{ replace_all(ARGS.flags, ' ', ' -tag=') }}",
254
+ // Strip every match of a regex (here: collapse whitespace runs to a single space):
255
+ "echo {{ regex_replace(ARGS.text, '\\s+', ' ') }}",
256
+ // Boolean-returning helpers are valid DSL `Truthy` values — use them in `if`:
257
+ // "if": "{{ starts_with(ARGS.path, '/usr') }}"
258
+ // "if": "{{ regex_matches(ARGS.tag, '^v[0-9]+\\.[0-9]+$') }}"
259
+
260
+ // Safely inline arbitrary content (newlines, quotes, JSON) as a CLI arg —
261
+ // `shell_quote` picks the right quoting for the active shell:
262
+ "some-tool --json {{ shell_quote(base64_decode(ENV.SECRET_BASE64)) }}",
263
+
264
+ // Nested:
265
+ "echo {{ to_upper(to_lower(ARGS.x)) }}",
266
+
267
+ // As a chain fallback:
268
+ "echo host={{ ARGS.host ? to_lower(ENV.HOST) }}",
269
+
270
+ // Capture a value with `define(...)` and read it later via `VARS.<name>`:
271
+ "{{ define(sdk, ENV.SDK ? '/opt/sdk') }}",
272
+ "{{ VARS.sdk }}/bin/build",
273
+ "echo using sdk at {{ VARS.sdk }}",
274
+
275
+ // Single-quoted strings interpolate nested {{ }} substitutions:
276
+ "{{ define(cmd, 'docker compose -f {{ VARS.compose }} pull') }}",
277
+ "{{ VARS.cmd }}"
278
+ ]
279
+ ```
280
+
281
+ `define(name, value)` returns an empty string and stores `value` in a run-wide map; a command line that resolves
282
+ to only whitespace (i.e. one consisting solely of a `{{ define(...) }}` call) is silently skipped instead of
283
+ being dispatched to the shell. `define`s in a parent target are visible to `@target` children. The `name` MUST
284
+ be a bareword identifier matching `[A-Za-z_][A-Za-z0-9_-]*` — quotes are NOT allowed on the name.
285
+
286
+ Function args are separated by `, ` (comma + exactly one space).
287
+
288
+ **Quote semantics inside `{{ ... }}`:**
289
+
290
+ - **Single quotes (`'...'`) — interpolated string**: surrounding quotes stripped, with any nested `{{ ... }}`
291
+ resolved through the regular substitution machinery. Use these for almost every literal — they handle the
292
+ full range of values including embedded substitutions: `concat('a, b', ARGS.x)`,
293
+ `'/var/log/{{ RUN.os }}.log'`, `define(cmd, 'docker -f {{ VARS.compose }} pull')`.
294
+ - **Double quotes (`"..."`) — fully literal value**: the quote characters are part of the value (so `"test"` is
295
+ the 6-character string `"test"`). No interpolation. Useful when the literal you want to emit really should
296
+ contain `"` chars.
297
+ - **Plain barewords are rejected** — `{{ ARGS.env ? development }}` is a parse-time error. Wrap in quotes.
298
+
299
+ #### Conditionals and loops, no shell required
300
+
301
+ Drop `if` / `for` / `match` blocks straight into your `commands` array. Conditions, loops, and value dispatch are
302
+ evaluated by Runfile itself, so the logic works the same on every shell and platform.
303
+
304
+ ```jsonc
305
+ "commands": [
306
+ { "if": "{{ ARGS.env == 'production' }}",
307
+ "then": ["./deploy-prod.sh"],
308
+ "else": ["./deploy-staging.sh"] },
309
+
310
+ { "match": "{{ ARGS.tier ? '1' }}", // chain default; case "1" runs when --tier missing
311
+ "cases": {
312
+ "1": "flutter emulators --launch Tier_1_Android_9",
313
+ "2": "flutter emulators --launch Tier_2_Android_11",
314
+ "3": "flutter emulators --launch Tier_3_Android_14"
315
+ } }, // unknown values error out, listing the valid cases
316
+
317
+ { "for": "service",
318
+ "in": ["api", "web", "worker"],
319
+ "parallel": true,
320
+ "do": ["docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
321
+
322
+ { "for": "file",
323
+ "shell": "git diff --name-only HEAD~1",
324
+ "do": ["clang-format -i {{ VARS.file }}"] }
325
+ ]
326
+ ```
327
+
328
+ #### Call other targets inline with `@target`
329
+
330
+ Any command string starting with `@` invokes another target. Forward args with `{{ ARGS }}` or pass anything explicit.
331
+ Each invocation runs (no dedup), inherits the parent's env, and works inside `if` / `for` blocks. Parallel parents
332
+ fan out target calls onto worker threads.
333
+
334
+ Prefix with `@?target` to mark the call **optional**: if the (substituted) target doesn't exist, it's silently
335
+ skipped instead of erroring. Useful with `for in: "namespaces"` when the dispatched target isn't defined in every
336
+ namespace.
337
+
338
+ ```jsonc
339
+ "commands": [
340
+ "@lint",
341
+ "@test --coverage",
342
+ "@build {{ ARGS }}",
343
+ { "if": "{{ RUN.os == 'windows' }}", "then": "@deploy-win", "else": "@deploy-unix" },
344
+ { "for": "ns", "in": "namespaces", "do": "@?{{ VARS.ns }}:adb-forward" } // skip namespaces without the target
345
+ ]
346
+ ```
347
+
348
+ #### Readable parallel output
349
+
350
+ When commands run in parallel (`"parallel": true` on a target or `for` block), each branch's stdout/stderr is
351
+ line-buffered, prefixed with its step number `[N]`, and stripped of cursor-control escapes — so progress-bar
352
+ redraws (`docker compose pull`, `cargo build`, etc.) become chronological append-only lines instead of corrupting
353
+ each other's output. SGR colors flow through unchanged. The prefix propagates through `@target` invocations too,
354
+ so monorepo fan-outs like `{ "for": "ns", "in": "namespaces", "do": "@{{ VARS.ns }}:dev" }` tag every nested shell
355
+ with its branch identity. Set `RUNFILE_NO_LINE_PREFIX=1` to opt out and inherit raw stdio.
356
+
357
+ ```text
358
+ [runfile] (1/3) [parallel] docker compose pull api
359
+ [runfile] (2/3) [parallel] docker compose pull web
360
+ [runfile] (3/3) [parallel] docker compose pull worker
361
+ [1] Pulling api ... 50%
362
+ [2] Pulling web ... 30%
363
+ [3] Pulling worker ... 100%
364
+ [1] Pulling api ... 100%
365
+ [2] Pulling web ... 100%
366
+ ```
367
+
368
+ #### Cleanup on failure / always with `when:` blocks
369
+
370
+ Wrap any commands in a `when:` block to run them only after a failure, or always — interleaved with the rest of
371
+ `commands`, no separate `before`/`after` arrays.
372
+
373
+ ```jsonc
374
+ "commands": [
375
+ "./run-tests.sh", // failure flips state
376
+ "./report.sh", // skipped on failure (default when:success)
377
+ { "when": "failure", "commands": ["./post-failure.sh"] }, // runs only after a failure
378
+ { "when": "always", "commands": ["@cleanup"] } // runs every time
379
+ ]
380
+ ```
381
+
382
+ #### Watch mode, built in
383
+
384
+ Add a `watch` array to any target. No flags, no extra tooling.
385
+
386
+ ```jsonc
387
+ "watch": ["src/**/*.rs", "!target/**"]
388
+ ```
389
+
390
+ #### Editor integrations
391
+
392
+ Generate native tasks for **VS Code**, **Zed**, and **JetBrains** IDEs from your Runfile:
393
+
394
+ ```bash
395
+ $ run :generate vscode-tasks
396
+ $ run :generate zed-tasks
397
+ $ run :generate jetbrains-run-configurations
398
+ ```
399
+
400
+ #### Migrate in seconds
401
+
402
+ ```bash
403
+ $ run :convert package-json # turns package.json scripts into targets
404
+ $ run :convert makefile # turns Makefile recipes into targets
405
+ ```
406
+
407
+ #### Plus
408
+
409
+ Parallel & detached execution · file includes with optional namespacing for monorepos
410
+ (`pnpm --recursive --parallel`-style aggregation) · cycle detection · global Runfiles · path aliases ·
411
+ confirmation prompts · `--dry-run` · `--stdin-args` (interactive prompting for missing inputs) · `--timings` ·
412
+ force-kill on SIGINT (works for GUI apps like Unity) · stdio tailers · shell completions · MCP server.
413
+
414
+ ---
415
+
416
+ ## Platform Support
417
+
418
+ | Platform | Status |
419
+ |-----------------------|-----------|
420
+ | Linux (x86-64) | Supported |
421
+ | Linux (ARM64) | Supported |
422
+ | macOS (Apple Silicon) | Supported |
423
+ | macOS (Intel) | Supported |
424
+ | Windows 10 | Supported |
425
+ | Windows 11 | Supported |
426
+ | Windows Server | Supported |
427
+
428
+ Single static binary. No runtime, no interpreter, no package manager.
429
+
430
+ ---
431
+
432
+ ## Documentation
433
+
434
+ The full reference — every property, every flag, every subcommand — lives in [DOCS.md](.github/DOCS.md).
435
+
436
+ - [CLI usage and subcommands](.github/DOCS.md#cli-usage)
437
+ - [Runfile.json reference](.github/DOCS.md#runfilejson-reference)
438
+ - [Arguments and substitution](.github/DOCS.md#arguments-and-substitution)
439
+ - [Encrypted environment variables](.github/DOCS.md#encrypted-environment-variables)
440
+ - [Internal targets](.github/DOCS.md#internal-targets)
441
+ - [`when:` blocks](.github/DOCS.md#when-guarded-blocks-when)
442
+ - [Watch mode](.github/DOCS.md#watch-mode)
443
+ - [Editor integration](.github/DOCS.md#editor-integration)
444
+
445
+ ---
446
+
447
+ ## License
448
+
449
+ MIT
Binary file
package/bin/run.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ // Picks the bundled binary for the current platform/arch and execs it.
3
+ const path = require('node:path');
4
+ const { spawnSync } = require('node:child_process');
5
+
6
+ const KEY = process.platform + '-' + process.arch;
7
+ const BINARIES = {
8
+ "darwin-arm64": "run",
9
+ "darwin-x64": "run",
10
+ "linux-arm64": "run",
11
+ "linux-x64": "run",
12
+ "win32-arm64": "run.exe",
13
+ "win32-x64": "run.exe"
14
+ };
15
+
16
+ const bin = BINARIES[KEY];
17
+ if (!bin) {
18
+ console.error(`runfile: unsupported platform ${KEY}. Supported: ${Object.keys(BINARIES).join(', ')}`);
19
+ process.exit(1);
20
+ }
21
+
22
+ const binPath = path.join(__dirname, KEY, bin);
23
+ const result = spawnSync(binPath, process.argv.slice(2), { stdio: 'inherit' });
24
+ if (result.error) {
25
+ console.error(`runfile: failed to spawn ${binPath}: ${result.error.message}`);
26
+ process.exit(1);
27
+ }
28
+ process.exit(result.status == null ? 1 : result.status);
Binary file
Binary file
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@runfile/cli",
3
+ "version": "0.12.0",
4
+ "description": "Runfile CLI — an easy-to-use, batteries-included task runner",
5
+ "repository": "https://github.com/Skiley/runfile",
6
+ "homepage": "https://github.com/Skiley/runfile",
7
+ "license": "MIT",
8
+ "bin": {
9
+ "run": "bin/run.js"
10
+ },
11
+ "files": [
12
+ "bin"
13
+ ],
14
+ "engines": {
15
+ "node": ">=16"
16
+ }
17
+ }