@runfile/cli 0.12.1 → 0.13.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.
@@ -111,16 +111,16 @@ Sorted from where Runfile is most differentiated to where it lags behind.
111
111
  | Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
112
112
  | JSON Schema autocomplete in editors | ✅ | ❌ | ❌ | ✅ |
113
113
  | Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
114
+ | Output grouping / prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
114
115
  | Native Windows (no WSL / msys2 needed) | ✅ | ❌ | ✅ | ✅ |
115
- | Parallel execution | ✅ | ✅ | ❌ | ✅ |
116
116
  | Shell completions | ✅ | ❌ | ✅ | ✅ |
117
117
  | Private / internal targets (`_name`) | ✅ | ❌ | ✅ | ✅ |
118
+ | Built-in string functions (upper, replace, trim, regex, base64, …) | ✅ | ❌ | ✅ | ✅ |
119
+ | Parallel execution | ✅ | ✅ | ❌ | ✅ |
118
120
  | Single static binary | ✅ | ✅ | ✅ | ✅ |
119
121
  | Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
120
- | Output grouping / prefixing in parallel mode | ❌ | ❌ | ❌ | ✅ |
121
122
  | Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
122
123
  | Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
123
- | Built-in string functions (upper, replace, trim, …) | ❌ | ❌ | ✅ | ✅ |
124
124
 
125
125
  ---
126
126
 
@@ -244,8 +244,13 @@ and work as full substitution bodies *or* as chain segments:
244
244
  // build : concat, join
245
245
  // split : nth, first, last, count_parts
246
246
  // encoding : base64_encode, base64_decode
247
+ // hashing : sha256, md5
248
+ // files : read_file, file_exists
249
+ // json : json_get, json_set
250
+ // error : try
247
251
  // shell : shell_quote
248
252
  // variables : define
253
+ // cwd : set_cwd
249
254
  "echo deploying-{{ to_upper(ARGS.env) }}",
250
255
  "curl -H \"X-Auth: {{ base64_encode(ENV.TOKEN) }}\" ...",
251
256
  "echo {{ concat('hello-', ARGS.name, '-2026') }}",
@@ -269,6 +274,28 @@ and work as full substitution bodies *or* as chain segments:
269
274
  // `shell_quote` picks the right quoting for the active shell:
270
275
  "some-tool --json {{ shell_quote(base64_decode(ENV.SECRET_BASE64)) }}",
271
276
 
277
+ // Cache keys / content fingerprints. `sha256` for security-sensitive use,
278
+ // `md5` for cheap fingerprinting (NOT cryptographically secure).
279
+ "echo cache-key={{ sha256(read_file('package-lock.json')) }}",
280
+
281
+ // Branch on file presence without forking a shell. `file_exists` returns
282
+ // the literal "true" / "false", so it doubles as a DSL truthy value.
283
+ // "if": "{{ file_exists('.env.local') }}"
284
+
285
+ // Read a file inline — relative paths anchor to the Runfile directory.
286
+ // Use `try(...)` to recover from missing files.
287
+ "echo version={{ try(read_file('VERSION')) ? '0.0.0-dev' }}",
288
+
289
+ // Pull values out of arbitrary JSON without `jq` (works on every shell):
290
+ "echo db_host={{ json_get(read_file('config.json'), 'database.host') }}",
291
+
292
+ // Modify a JSON document in place (returns the new compact JSON):
293
+ "echo {{ json_set('{\"port\":3000}', 'env', 'production') }}",
294
+
295
+ // `try(expr)` swallows inner errors. Standalone returns "" on failure;
296
+ // chained, the next segment runs as a fallback.
297
+ "echo {{ try(base64_decode(ARGS.maybe_b64)) ? ARGS.maybe_b64 }}",
298
+
272
299
  // Nested:
273
300
  "echo {{ to_upper(to_lower(ARGS.x)) }}",
274
301
 
@@ -280,6 +307,13 @@ and work as full substitution bodies *or* as chain segments:
280
307
  "{{ VARS.sdk }}/bin/build",
281
308
  "echo using sdk at {{ VARS.sdk }}",
282
309
 
310
+ // `set_cwd(path)` switches the cwd subsequent commands spawn in — like
311
+ // shell `cd`, but works on every shell / OS without forking. Relative
312
+ // paths chain (matching `cd a; cd b` → `a/b`); absolute paths replace.
313
+ "{{ set_cwd(ARGS.subproject ? 'packages/api') }}",
314
+ "npm install",
315
+ "npm run build",
316
+
283
317
  // Single-quoted strings interpolate nested {{ }} substitutions:
284
318
  "{{ define(cmd, 'docker compose -f {{ VARS.compose }} pull') }}",
285
319
  "{{ VARS.cmd }}"
@@ -291,6 +325,22 @@ to only whitespace (i.e. one consisting solely of a `{{ define(...) }}` call) is
291
325
  being dispatched to the shell. `define`s in a parent target are visible to `@target` children. The `name` MUST
292
326
  be a bareword identifier matching `[A-Za-z_][A-Za-z0-9_-]*` — quotes are NOT allowed on the name.
293
327
 
328
+ `set_cwd(path)` is the cwd analog of `define`: returns an empty string and changes the cwd that subsequent shell
329
+ commands in the *current target* spawn in. Behaves like shell `cd`, but works uniformly across every shell / OS
330
+ (no forked process, no `cd` binary needed). Resolution rules:
331
+
332
+ - **Absolute path** → fully replaces the current override (matches `cd /abs`).
333
+ - **Relative path** → joins onto the existing override, or onto the target's `workingDirectory` if no override
334
+ has been set yet. So `set_cwd('a'); set_cwd('b')` lands subsequent commands in `<workingDirectory>/a/b`,
335
+ matching how `cd a; cd b` chains in a shell.
336
+
337
+ `set_cwd` is **per-target**: each `@target` invocation starts with a clean override (the dispatched target sees
338
+ its own `workingDirectory`, not the caller's `set_cwd` state). Inside `parallel: true` targets each leaf
339
+ captures the override at the moment of its substitution, so siblings don't race on the spawn cwd. Like
340
+ `define`, the side effect is skipped on the redacted-logging pass so log lines don't double-apply it. With
341
+ `sameShell: true`, only the *final* `set_cwd` value applies (everything joins into one shell invocation) — use
342
+ shell `cd` directly between leaves there if you need intermediate cwd changes.
343
+
294
344
  Function args are separated by `, ` (comma + exactly one space).
295
345
 
296
346
  **Quote semantics inside `{{ ... }}`:**
@@ -319,13 +369,18 @@ evaluated by Runfile itself, so the logic works the same on every shell and plat
319
369
  "cases": {
320
370
  "1": "flutter emulators --launch Tier_1_Android_9",
321
371
  "2": "flutter emulators --launch Tier_2_Android_11",
322
- "3": "flutter emulators --launch Tier_3_Android_14"
372
+ "3": "flutter emulators --launch Tier_3_Android_14",
373
+ // Case keys wrapped in `/.../` are treated as regex patterns.
374
+ // Literal cases always win over a regex that would also match.
375
+ "/^v\\d+$/": "echo version-tag",
376
+ "/^pr-\\d+$/": "echo preview-build"
323
377
  } }, // unknown values error out, listing the valid cases
324
378
 
325
379
  { "for": "service",
326
380
  "in": ["api", "web", "worker"],
327
381
  "parallel": true,
328
- "do": ["docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
382
+ // VARS.<name>_index exposes the 0-based iteration counter.
383
+ "do": ["echo [{{ VARS.service_index }}] docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
329
384
 
330
385
  { "for": "file",
331
386
  "shell": "git diff --name-only HEAD~1",
@@ -387,6 +442,27 @@ Wrap any commands in a `when:` block to run them only after a failure, or always
387
442
  ]
388
443
  ```
389
444
 
445
+ #### Single-shell mode with `sameShell`
446
+
447
+ By default each step runs in its own shell process, so `cd`, exported variables, and other shell state don't carry
448
+ over between steps. Set `"sameShell": true` to join every step into a single shell invocation — state changes
449
+ persist for free.
450
+
451
+ ```jsonc
452
+ {
453
+ "deploy": {
454
+ "sameShell": true,
455
+ "commands": ["cd ci-scripts/", "./ci-deploy.sh"]
456
+ }
457
+ }
458
+ ```
459
+
460
+ Steps are joined with `&&` so the run stops at the first failure (or `;` / `&` when `ignoreErrors: true`). `if`,
461
+ `for`, and `match` blocks are evaluated by Runfile and their chosen branches flow into the same joined invocation.
462
+ `@target` calls inside the body are rejected — they have their own shell context and can't share state with the
463
+ parent. `sameShell` also composes with `detach: true` (joined command spawns as one detached process) and is
464
+ available on `globals` for project-wide defaults.
465
+
390
466
  #### Watch mode, built in
391
467
 
392
468
  Add a `watch` array to any target. No flags, no extra tooling.
@@ -405,6 +481,11 @@ $ run :generate zed-tasks
405
481
  $ run :generate jetbrains-run-configurations
406
482
  ```
407
483
 
484
+ Skip a target from the generated configs by setting `metadata.excludeFromGenerateCommand: true`. The
485
+ `metadata` block on `globals` and on each target is **fully open** — any property of any JSON type
486
+ (strings, numbers, booleans, arrays, deeply-nested objects) is accepted and round-trips untouched, so
487
+ editor extensions and CI scripts can stash arbitrary tooling-specific fields here.
488
+
408
489
  #### Migrate in seconds
409
490
 
410
491
  ```bash
Binary file
@@ -111,16 +111,16 @@ Sorted from where Runfile is most differentiated to where it lags behind.
111
111
  | Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
112
112
  | JSON Schema autocomplete in editors | ✅ | ❌ | ❌ | ✅ |
113
113
  | Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
114
+ | Output grouping / prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
114
115
  | Native Windows (no WSL / msys2 needed) | ✅ | ❌ | ✅ | ✅ |
115
- | Parallel execution | ✅ | ✅ | ❌ | ✅ |
116
116
  | Shell completions | ✅ | ❌ | ✅ | ✅ |
117
117
  | Private / internal targets (`_name`) | ✅ | ❌ | ✅ | ✅ |
118
+ | Built-in string functions (upper, replace, trim, regex, base64, …) | ✅ | ❌ | ✅ | ✅ |
119
+ | Parallel execution | ✅ | ✅ | ❌ | ✅ |
118
120
  | Single static binary | ✅ | ✅ | ✅ | ✅ |
119
121
  | Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
120
- | Output grouping / prefixing in parallel mode | ❌ | ❌ | ❌ | ✅ |
121
122
  | Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
122
123
  | Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
123
- | Built-in string functions (upper, replace, trim, …) | ❌ | ❌ | ✅ | ✅ |
124
124
 
125
125
  ---
126
126
 
@@ -244,8 +244,13 @@ and work as full substitution bodies *or* as chain segments:
244
244
  // build : concat, join
245
245
  // split : nth, first, last, count_parts
246
246
  // encoding : base64_encode, base64_decode
247
+ // hashing : sha256, md5
248
+ // files : read_file, file_exists
249
+ // json : json_get, json_set
250
+ // error : try
247
251
  // shell : shell_quote
248
252
  // variables : define
253
+ // cwd : set_cwd
249
254
  "echo deploying-{{ to_upper(ARGS.env) }}",
250
255
  "curl -H \"X-Auth: {{ base64_encode(ENV.TOKEN) }}\" ...",
251
256
  "echo {{ concat('hello-', ARGS.name, '-2026') }}",
@@ -269,6 +274,28 @@ and work as full substitution bodies *or* as chain segments:
269
274
  // `shell_quote` picks the right quoting for the active shell:
270
275
  "some-tool --json {{ shell_quote(base64_decode(ENV.SECRET_BASE64)) }}",
271
276
 
277
+ // Cache keys / content fingerprints. `sha256` for security-sensitive use,
278
+ // `md5` for cheap fingerprinting (NOT cryptographically secure).
279
+ "echo cache-key={{ sha256(read_file('package-lock.json')) }}",
280
+
281
+ // Branch on file presence without forking a shell. `file_exists` returns
282
+ // the literal "true" / "false", so it doubles as a DSL truthy value.
283
+ // "if": "{{ file_exists('.env.local') }}"
284
+
285
+ // Read a file inline — relative paths anchor to the Runfile directory.
286
+ // Use `try(...)` to recover from missing files.
287
+ "echo version={{ try(read_file('VERSION')) ? '0.0.0-dev' }}",
288
+
289
+ // Pull values out of arbitrary JSON without `jq` (works on every shell):
290
+ "echo db_host={{ json_get(read_file('config.json'), 'database.host') }}",
291
+
292
+ // Modify a JSON document in place (returns the new compact JSON):
293
+ "echo {{ json_set('{\"port\":3000}', 'env', 'production') }}",
294
+
295
+ // `try(expr)` swallows inner errors. Standalone returns "" on failure;
296
+ // chained, the next segment runs as a fallback.
297
+ "echo {{ try(base64_decode(ARGS.maybe_b64)) ? ARGS.maybe_b64 }}",
298
+
272
299
  // Nested:
273
300
  "echo {{ to_upper(to_lower(ARGS.x)) }}",
274
301
 
@@ -280,6 +307,13 @@ and work as full substitution bodies *or* as chain segments:
280
307
  "{{ VARS.sdk }}/bin/build",
281
308
  "echo using sdk at {{ VARS.sdk }}",
282
309
 
310
+ // `set_cwd(path)` switches the cwd subsequent commands spawn in — like
311
+ // shell `cd`, but works on every shell / OS without forking. Relative
312
+ // paths chain (matching `cd a; cd b` → `a/b`); absolute paths replace.
313
+ "{{ set_cwd(ARGS.subproject ? 'packages/api') }}",
314
+ "npm install",
315
+ "npm run build",
316
+
283
317
  // Single-quoted strings interpolate nested {{ }} substitutions:
284
318
  "{{ define(cmd, 'docker compose -f {{ VARS.compose }} pull') }}",
285
319
  "{{ VARS.cmd }}"
@@ -291,6 +325,22 @@ to only whitespace (i.e. one consisting solely of a `{{ define(...) }}` call) is
291
325
  being dispatched to the shell. `define`s in a parent target are visible to `@target` children. The `name` MUST
292
326
  be a bareword identifier matching `[A-Za-z_][A-Za-z0-9_-]*` — quotes are NOT allowed on the name.
293
327
 
328
+ `set_cwd(path)` is the cwd analog of `define`: returns an empty string and changes the cwd that subsequent shell
329
+ commands in the *current target* spawn in. Behaves like shell `cd`, but works uniformly across every shell / OS
330
+ (no forked process, no `cd` binary needed). Resolution rules:
331
+
332
+ - **Absolute path** → fully replaces the current override (matches `cd /abs`).
333
+ - **Relative path** → joins onto the existing override, or onto the target's `workingDirectory` if no override
334
+ has been set yet. So `set_cwd('a'); set_cwd('b')` lands subsequent commands in `<workingDirectory>/a/b`,
335
+ matching how `cd a; cd b` chains in a shell.
336
+
337
+ `set_cwd` is **per-target**: each `@target` invocation starts with a clean override (the dispatched target sees
338
+ its own `workingDirectory`, not the caller's `set_cwd` state). Inside `parallel: true` targets each leaf
339
+ captures the override at the moment of its substitution, so siblings don't race on the spawn cwd. Like
340
+ `define`, the side effect is skipped on the redacted-logging pass so log lines don't double-apply it. With
341
+ `sameShell: true`, only the *final* `set_cwd` value applies (everything joins into one shell invocation) — use
342
+ shell `cd` directly between leaves there if you need intermediate cwd changes.
343
+
294
344
  Function args are separated by `, ` (comma + exactly one space).
295
345
 
296
346
  **Quote semantics inside `{{ ... }}`:**
@@ -319,13 +369,18 @@ evaluated by Runfile itself, so the logic works the same on every shell and plat
319
369
  "cases": {
320
370
  "1": "flutter emulators --launch Tier_1_Android_9",
321
371
  "2": "flutter emulators --launch Tier_2_Android_11",
322
- "3": "flutter emulators --launch Tier_3_Android_14"
372
+ "3": "flutter emulators --launch Tier_3_Android_14",
373
+ // Case keys wrapped in `/.../` are treated as regex patterns.
374
+ // Literal cases always win over a regex that would also match.
375
+ "/^v\\d+$/": "echo version-tag",
376
+ "/^pr-\\d+$/": "echo preview-build"
323
377
  } }, // unknown values error out, listing the valid cases
324
378
 
325
379
  { "for": "service",
326
380
  "in": ["api", "web", "worker"],
327
381
  "parallel": true,
328
- "do": ["docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
382
+ // VARS.<name>_index exposes the 0-based iteration counter.
383
+ "do": ["echo [{{ VARS.service_index }}] docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
329
384
 
330
385
  { "for": "file",
331
386
  "shell": "git diff --name-only HEAD~1",
@@ -387,6 +442,27 @@ Wrap any commands in a `when:` block to run them only after a failure, or always
387
442
  ]
388
443
  ```
389
444
 
445
+ #### Single-shell mode with `sameShell`
446
+
447
+ By default each step runs in its own shell process, so `cd`, exported variables, and other shell state don't carry
448
+ over between steps. Set `"sameShell": true` to join every step into a single shell invocation — state changes
449
+ persist for free.
450
+
451
+ ```jsonc
452
+ {
453
+ "deploy": {
454
+ "sameShell": true,
455
+ "commands": ["cd ci-scripts/", "./ci-deploy.sh"]
456
+ }
457
+ }
458
+ ```
459
+
460
+ Steps are joined with `&&` so the run stops at the first failure (or `;` / `&` when `ignoreErrors: true`). `if`,
461
+ `for`, and `match` blocks are evaluated by Runfile and their chosen branches flow into the same joined invocation.
462
+ `@target` calls inside the body are rejected — they have their own shell context and can't share state with the
463
+ parent. `sameShell` also composes with `detach: true` (joined command spawns as one detached process) and is
464
+ available on `globals` for project-wide defaults.
465
+
390
466
  #### Watch mode, built in
391
467
 
392
468
  Add a `watch` array to any target. No flags, no extra tooling.
@@ -405,6 +481,11 @@ $ run :generate zed-tasks
405
481
  $ run :generate jetbrains-run-configurations
406
482
  ```
407
483
 
484
+ Skip a target from the generated configs by setting `metadata.excludeFromGenerateCommand: true`. The
485
+ `metadata` block on `globals` and on each target is **fully open** — any property of any JSON type
486
+ (strings, numbers, booleans, arrays, deeply-nested objects) is accepted and round-trips untouched, so
487
+ editor extensions and CI scripts can stash arbitrary tooling-specific fields here.
488
+
408
489
  #### Migrate in seconds
409
490
 
410
491
  ```bash
Binary file
@@ -111,16 +111,16 @@ Sorted from where Runfile is most differentiated to where it lags behind.
111
111
  | Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
112
112
  | JSON Schema autocomplete in editors | ✅ | ❌ | ❌ | ✅ |
113
113
  | Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
114
+ | Output grouping / prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
114
115
  | Native Windows (no WSL / msys2 needed) | ✅ | ❌ | ✅ | ✅ |
115
- | Parallel execution | ✅ | ✅ | ❌ | ✅ |
116
116
  | Shell completions | ✅ | ❌ | ✅ | ✅ |
117
117
  | Private / internal targets (`_name`) | ✅ | ❌ | ✅ | ✅ |
118
+ | Built-in string functions (upper, replace, trim, regex, base64, …) | ✅ | ❌ | ✅ | ✅ |
119
+ | Parallel execution | ✅ | ✅ | ❌ | ✅ |
118
120
  | Single static binary | ✅ | ✅ | ✅ | ✅ |
119
121
  | Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
120
- | Output grouping / prefixing in parallel mode | ❌ | ❌ | ❌ | ✅ |
121
122
  | Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
122
123
  | Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
123
- | Built-in string functions (upper, replace, trim, …) | ❌ | ❌ | ✅ | ✅ |
124
124
 
125
125
  ---
126
126
 
@@ -244,8 +244,13 @@ and work as full substitution bodies *or* as chain segments:
244
244
  // build : concat, join
245
245
  // split : nth, first, last, count_parts
246
246
  // encoding : base64_encode, base64_decode
247
+ // hashing : sha256, md5
248
+ // files : read_file, file_exists
249
+ // json : json_get, json_set
250
+ // error : try
247
251
  // shell : shell_quote
248
252
  // variables : define
253
+ // cwd : set_cwd
249
254
  "echo deploying-{{ to_upper(ARGS.env) }}",
250
255
  "curl -H \"X-Auth: {{ base64_encode(ENV.TOKEN) }}\" ...",
251
256
  "echo {{ concat('hello-', ARGS.name, '-2026') }}",
@@ -269,6 +274,28 @@ and work as full substitution bodies *or* as chain segments:
269
274
  // `shell_quote` picks the right quoting for the active shell:
270
275
  "some-tool --json {{ shell_quote(base64_decode(ENV.SECRET_BASE64)) }}",
271
276
 
277
+ // Cache keys / content fingerprints. `sha256` for security-sensitive use,
278
+ // `md5` for cheap fingerprinting (NOT cryptographically secure).
279
+ "echo cache-key={{ sha256(read_file('package-lock.json')) }}",
280
+
281
+ // Branch on file presence without forking a shell. `file_exists` returns
282
+ // the literal "true" / "false", so it doubles as a DSL truthy value.
283
+ // "if": "{{ file_exists('.env.local') }}"
284
+
285
+ // Read a file inline — relative paths anchor to the Runfile directory.
286
+ // Use `try(...)` to recover from missing files.
287
+ "echo version={{ try(read_file('VERSION')) ? '0.0.0-dev' }}",
288
+
289
+ // Pull values out of arbitrary JSON without `jq` (works on every shell):
290
+ "echo db_host={{ json_get(read_file('config.json'), 'database.host') }}",
291
+
292
+ // Modify a JSON document in place (returns the new compact JSON):
293
+ "echo {{ json_set('{\"port\":3000}', 'env', 'production') }}",
294
+
295
+ // `try(expr)` swallows inner errors. Standalone returns "" on failure;
296
+ // chained, the next segment runs as a fallback.
297
+ "echo {{ try(base64_decode(ARGS.maybe_b64)) ? ARGS.maybe_b64 }}",
298
+
272
299
  // Nested:
273
300
  "echo {{ to_upper(to_lower(ARGS.x)) }}",
274
301
 
@@ -280,6 +307,13 @@ and work as full substitution bodies *or* as chain segments:
280
307
  "{{ VARS.sdk }}/bin/build",
281
308
  "echo using sdk at {{ VARS.sdk }}",
282
309
 
310
+ // `set_cwd(path)` switches the cwd subsequent commands spawn in — like
311
+ // shell `cd`, but works on every shell / OS without forking. Relative
312
+ // paths chain (matching `cd a; cd b` → `a/b`); absolute paths replace.
313
+ "{{ set_cwd(ARGS.subproject ? 'packages/api') }}",
314
+ "npm install",
315
+ "npm run build",
316
+
283
317
  // Single-quoted strings interpolate nested {{ }} substitutions:
284
318
  "{{ define(cmd, 'docker compose -f {{ VARS.compose }} pull') }}",
285
319
  "{{ VARS.cmd }}"
@@ -291,6 +325,22 @@ to only whitespace (i.e. one consisting solely of a `{{ define(...) }}` call) is
291
325
  being dispatched to the shell. `define`s in a parent target are visible to `@target` children. The `name` MUST
292
326
  be a bareword identifier matching `[A-Za-z_][A-Za-z0-9_-]*` — quotes are NOT allowed on the name.
293
327
 
328
+ `set_cwd(path)` is the cwd analog of `define`: returns an empty string and changes the cwd that subsequent shell
329
+ commands in the *current target* spawn in. Behaves like shell `cd`, but works uniformly across every shell / OS
330
+ (no forked process, no `cd` binary needed). Resolution rules:
331
+
332
+ - **Absolute path** → fully replaces the current override (matches `cd /abs`).
333
+ - **Relative path** → joins onto the existing override, or onto the target's `workingDirectory` if no override
334
+ has been set yet. So `set_cwd('a'); set_cwd('b')` lands subsequent commands in `<workingDirectory>/a/b`,
335
+ matching how `cd a; cd b` chains in a shell.
336
+
337
+ `set_cwd` is **per-target**: each `@target` invocation starts with a clean override (the dispatched target sees
338
+ its own `workingDirectory`, not the caller's `set_cwd` state). Inside `parallel: true` targets each leaf
339
+ captures the override at the moment of its substitution, so siblings don't race on the spawn cwd. Like
340
+ `define`, the side effect is skipped on the redacted-logging pass so log lines don't double-apply it. With
341
+ `sameShell: true`, only the *final* `set_cwd` value applies (everything joins into one shell invocation) — use
342
+ shell `cd` directly between leaves there if you need intermediate cwd changes.
343
+
294
344
  Function args are separated by `, ` (comma + exactly one space).
295
345
 
296
346
  **Quote semantics inside `{{ ... }}`:**
@@ -319,13 +369,18 @@ evaluated by Runfile itself, so the logic works the same on every shell and plat
319
369
  "cases": {
320
370
  "1": "flutter emulators --launch Tier_1_Android_9",
321
371
  "2": "flutter emulators --launch Tier_2_Android_11",
322
- "3": "flutter emulators --launch Tier_3_Android_14"
372
+ "3": "flutter emulators --launch Tier_3_Android_14",
373
+ // Case keys wrapped in `/.../` are treated as regex patterns.
374
+ // Literal cases always win over a regex that would also match.
375
+ "/^v\\d+$/": "echo version-tag",
376
+ "/^pr-\\d+$/": "echo preview-build"
323
377
  } }, // unknown values error out, listing the valid cases
324
378
 
325
379
  { "for": "service",
326
380
  "in": ["api", "web", "worker"],
327
381
  "parallel": true,
328
- "do": ["docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
382
+ // VARS.<name>_index exposes the 0-based iteration counter.
383
+ "do": ["echo [{{ VARS.service_index }}] docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
329
384
 
330
385
  { "for": "file",
331
386
  "shell": "git diff --name-only HEAD~1",
@@ -387,6 +442,27 @@ Wrap any commands in a `when:` block to run them only after a failure, or always
387
442
  ]
388
443
  ```
389
444
 
445
+ #### Single-shell mode with `sameShell`
446
+
447
+ By default each step runs in its own shell process, so `cd`, exported variables, and other shell state don't carry
448
+ over between steps. Set `"sameShell": true` to join every step into a single shell invocation — state changes
449
+ persist for free.
450
+
451
+ ```jsonc
452
+ {
453
+ "deploy": {
454
+ "sameShell": true,
455
+ "commands": ["cd ci-scripts/", "./ci-deploy.sh"]
456
+ }
457
+ }
458
+ ```
459
+
460
+ Steps are joined with `&&` so the run stops at the first failure (or `;` / `&` when `ignoreErrors: true`). `if`,
461
+ `for`, and `match` blocks are evaluated by Runfile and their chosen branches flow into the same joined invocation.
462
+ `@target` calls inside the body are rejected — they have their own shell context and can't share state with the
463
+ parent. `sameShell` also composes with `detach: true` (joined command spawns as one detached process) and is
464
+ available on `globals` for project-wide defaults.
465
+
390
466
  #### Watch mode, built in
391
467
 
392
468
  Add a `watch` array to any target. No flags, no extra tooling.
@@ -405,6 +481,11 @@ $ run :generate zed-tasks
405
481
  $ run :generate jetbrains-run-configurations
406
482
  ```
407
483
 
484
+ Skip a target from the generated configs by setting `metadata.excludeFromGenerateCommand: true`. The
485
+ `metadata` block on `globals` and on each target is **fully open** — any property of any JSON type
486
+ (strings, numbers, booleans, arrays, deeply-nested objects) is accepted and round-trips untouched, so
487
+ editor extensions and CI scripts can stash arbitrary tooling-specific fields here.
488
+
408
489
  #### Migrate in seconds
409
490
 
410
491
  ```bash
Binary file
@@ -111,16 +111,16 @@ Sorted from where Runfile is most differentiated to where it lags behind.
111
111
  | Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
112
112
  | JSON Schema autocomplete in editors | ✅ | ❌ | ❌ | ✅ |
113
113
  | Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
114
+ | Output grouping / prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
114
115
  | Native Windows (no WSL / msys2 needed) | ✅ | ❌ | ✅ | ✅ |
115
- | Parallel execution | ✅ | ✅ | ❌ | ✅ |
116
116
  | Shell completions | ✅ | ❌ | ✅ | ✅ |
117
117
  | Private / internal targets (`_name`) | ✅ | ❌ | ✅ | ✅ |
118
+ | Built-in string functions (upper, replace, trim, regex, base64, …) | ✅ | ❌ | ✅ | ✅ |
119
+ | Parallel execution | ✅ | ✅ | ❌ | ✅ |
118
120
  | Single static binary | ✅ | ✅ | ✅ | ✅ |
119
121
  | Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
120
- | Output grouping / prefixing in parallel mode | ❌ | ❌ | ❌ | ✅ |
121
122
  | Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
122
123
  | Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
123
- | Built-in string functions (upper, replace, trim, …) | ❌ | ❌ | ✅ | ✅ |
124
124
 
125
125
  ---
126
126
 
@@ -244,8 +244,13 @@ and work as full substitution bodies *or* as chain segments:
244
244
  // build : concat, join
245
245
  // split : nth, first, last, count_parts
246
246
  // encoding : base64_encode, base64_decode
247
+ // hashing : sha256, md5
248
+ // files : read_file, file_exists
249
+ // json : json_get, json_set
250
+ // error : try
247
251
  // shell : shell_quote
248
252
  // variables : define
253
+ // cwd : set_cwd
249
254
  "echo deploying-{{ to_upper(ARGS.env) }}",
250
255
  "curl -H \"X-Auth: {{ base64_encode(ENV.TOKEN) }}\" ...",
251
256
  "echo {{ concat('hello-', ARGS.name, '-2026') }}",
@@ -269,6 +274,28 @@ and work as full substitution bodies *or* as chain segments:
269
274
  // `shell_quote` picks the right quoting for the active shell:
270
275
  "some-tool --json {{ shell_quote(base64_decode(ENV.SECRET_BASE64)) }}",
271
276
 
277
+ // Cache keys / content fingerprints. `sha256` for security-sensitive use,
278
+ // `md5` for cheap fingerprinting (NOT cryptographically secure).
279
+ "echo cache-key={{ sha256(read_file('package-lock.json')) }}",
280
+
281
+ // Branch on file presence without forking a shell. `file_exists` returns
282
+ // the literal "true" / "false", so it doubles as a DSL truthy value.
283
+ // "if": "{{ file_exists('.env.local') }}"
284
+
285
+ // Read a file inline — relative paths anchor to the Runfile directory.
286
+ // Use `try(...)` to recover from missing files.
287
+ "echo version={{ try(read_file('VERSION')) ? '0.0.0-dev' }}",
288
+
289
+ // Pull values out of arbitrary JSON without `jq` (works on every shell):
290
+ "echo db_host={{ json_get(read_file('config.json'), 'database.host') }}",
291
+
292
+ // Modify a JSON document in place (returns the new compact JSON):
293
+ "echo {{ json_set('{\"port\":3000}', 'env', 'production') }}",
294
+
295
+ // `try(expr)` swallows inner errors. Standalone returns "" on failure;
296
+ // chained, the next segment runs as a fallback.
297
+ "echo {{ try(base64_decode(ARGS.maybe_b64)) ? ARGS.maybe_b64 }}",
298
+
272
299
  // Nested:
273
300
  "echo {{ to_upper(to_lower(ARGS.x)) }}",
274
301
 
@@ -280,6 +307,13 @@ and work as full substitution bodies *or* as chain segments:
280
307
  "{{ VARS.sdk }}/bin/build",
281
308
  "echo using sdk at {{ VARS.sdk }}",
282
309
 
310
+ // `set_cwd(path)` switches the cwd subsequent commands spawn in — like
311
+ // shell `cd`, but works on every shell / OS without forking. Relative
312
+ // paths chain (matching `cd a; cd b` → `a/b`); absolute paths replace.
313
+ "{{ set_cwd(ARGS.subproject ? 'packages/api') }}",
314
+ "npm install",
315
+ "npm run build",
316
+
283
317
  // Single-quoted strings interpolate nested {{ }} substitutions:
284
318
  "{{ define(cmd, 'docker compose -f {{ VARS.compose }} pull') }}",
285
319
  "{{ VARS.cmd }}"
@@ -291,6 +325,22 @@ to only whitespace (i.e. one consisting solely of a `{{ define(...) }}` call) is
291
325
  being dispatched to the shell. `define`s in a parent target are visible to `@target` children. The `name` MUST
292
326
  be a bareword identifier matching `[A-Za-z_][A-Za-z0-9_-]*` — quotes are NOT allowed on the name.
293
327
 
328
+ `set_cwd(path)` is the cwd analog of `define`: returns an empty string and changes the cwd that subsequent shell
329
+ commands in the *current target* spawn in. Behaves like shell `cd`, but works uniformly across every shell / OS
330
+ (no forked process, no `cd` binary needed). Resolution rules:
331
+
332
+ - **Absolute path** → fully replaces the current override (matches `cd /abs`).
333
+ - **Relative path** → joins onto the existing override, or onto the target's `workingDirectory` if no override
334
+ has been set yet. So `set_cwd('a'); set_cwd('b')` lands subsequent commands in `<workingDirectory>/a/b`,
335
+ matching how `cd a; cd b` chains in a shell.
336
+
337
+ `set_cwd` is **per-target**: each `@target` invocation starts with a clean override (the dispatched target sees
338
+ its own `workingDirectory`, not the caller's `set_cwd` state). Inside `parallel: true` targets each leaf
339
+ captures the override at the moment of its substitution, so siblings don't race on the spawn cwd. Like
340
+ `define`, the side effect is skipped on the redacted-logging pass so log lines don't double-apply it. With
341
+ `sameShell: true`, only the *final* `set_cwd` value applies (everything joins into one shell invocation) — use
342
+ shell `cd` directly between leaves there if you need intermediate cwd changes.
343
+
294
344
  Function args are separated by `, ` (comma + exactly one space).
295
345
 
296
346
  **Quote semantics inside `{{ ... }}`:**
@@ -319,13 +369,18 @@ evaluated by Runfile itself, so the logic works the same on every shell and plat
319
369
  "cases": {
320
370
  "1": "flutter emulators --launch Tier_1_Android_9",
321
371
  "2": "flutter emulators --launch Tier_2_Android_11",
322
- "3": "flutter emulators --launch Tier_3_Android_14"
372
+ "3": "flutter emulators --launch Tier_3_Android_14",
373
+ // Case keys wrapped in `/.../` are treated as regex patterns.
374
+ // Literal cases always win over a regex that would also match.
375
+ "/^v\\d+$/": "echo version-tag",
376
+ "/^pr-\\d+$/": "echo preview-build"
323
377
  } }, // unknown values error out, listing the valid cases
324
378
 
325
379
  { "for": "service",
326
380
  "in": ["api", "web", "worker"],
327
381
  "parallel": true,
328
- "do": ["docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
382
+ // VARS.<name>_index exposes the 0-based iteration counter.
383
+ "do": ["echo [{{ VARS.service_index }}] docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
329
384
 
330
385
  { "for": "file",
331
386
  "shell": "git diff --name-only HEAD~1",
@@ -387,6 +442,27 @@ Wrap any commands in a `when:` block to run them only after a failure, or always
387
442
  ]
388
443
  ```
389
444
 
445
+ #### Single-shell mode with `sameShell`
446
+
447
+ By default each step runs in its own shell process, so `cd`, exported variables, and other shell state don't carry
448
+ over between steps. Set `"sameShell": true` to join every step into a single shell invocation — state changes
449
+ persist for free.
450
+
451
+ ```jsonc
452
+ {
453
+ "deploy": {
454
+ "sameShell": true,
455
+ "commands": ["cd ci-scripts/", "./ci-deploy.sh"]
456
+ }
457
+ }
458
+ ```
459
+
460
+ Steps are joined with `&&` so the run stops at the first failure (or `;` / `&` when `ignoreErrors: true`). `if`,
461
+ `for`, and `match` blocks are evaluated by Runfile and their chosen branches flow into the same joined invocation.
462
+ `@target` calls inside the body are rejected — they have their own shell context and can't share state with the
463
+ parent. `sameShell` also composes with `detach: true` (joined command spawns as one detached process) and is
464
+ available on `globals` for project-wide defaults.
465
+
390
466
  #### Watch mode, built in
391
467
 
392
468
  Add a `watch` array to any target. No flags, no extra tooling.
@@ -405,6 +481,11 @@ $ run :generate zed-tasks
405
481
  $ run :generate jetbrains-run-configurations
406
482
  ```
407
483
 
484
+ Skip a target from the generated configs by setting `metadata.excludeFromGenerateCommand: true`. The
485
+ `metadata` block on `globals` and on each target is **fully open** — any property of any JSON type
486
+ (strings, numbers, booleans, arrays, deeply-nested objects) is accepted and round-trips untouched, so
487
+ editor extensions and CI scripts can stash arbitrary tooling-specific fields here.
488
+
408
489
  #### Migrate in seconds
409
490
 
410
491
  ```bash
package/bin/linux-x64/run CHANGED
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runfile/cli",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "Runfile CLI — an easy-to-use, batteries-included task runner",
5
5
  "repository": "https://github.com/Skiley/runfile",
6
6
  "homepage": "https://github.com/Skiley/runfile",