Skip to content

CLI reference

The vx binary is the user-facing entry point. The implementation is intentionally simple: a hand-rolled argv parser (no commander / yargs) in src/cli.ts dispatches to per-subcommand handlers under src/cli/<name>.ts. The flag surface is aligned with Turborepo’s turbo run so existing Turbo users can swap in with minimal muscle-memory churn.

Terminal window
# Standalone binary (no Bun required on target):
curl -fsSL https://raw.githubusercontent.com/vznjs/vx/main/install.sh | sh
# From source (Bun ≥ 1.3):
bun src/bin.ts --version
vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]
vx watch [OPTIONS] TASK [-- forwarded-args...]
vx cache prune [--older-than <duration>] [--max-size <bytes>]
vx lock [--check]
vx migrate [--dry] [--force]
vx show [PROJECT[#TASK]] [--format pretty|json]
vx info
vx stats # deprecated alias of vx info
vx help
vx --help, -h
vx version
vx --version

Multiple positional tasks run in one orchestrator invocation with a shared task graph: vx run build lint test fans out all three across the resolved project scope. Anchored entries (pkg#task) target a specific project; bare entries follow the usual scope rules (default = the cwd project; broaden with --all / --filter / --affected).

(No -V for version; vx --version only — matches Turbo.)

vx run [OPTIONS] [TASK | PKG#TASK ...] [-- forwarded-args...]

Run the named task(s). By default only the project containing the current working directory is selected — dependsOn still expands so the project’s upstream workspace deps run too. Override with --all, --filter, --affected, or an explicit pkg#task.

If no task name is given:

  • In a TTY — an interactive picker lists every pkg#task entry across the workspace, prints description next to each, prompts for a number, runs the chosen one.
  • Not a TTY — exits 1 with missing task name (stdin is not a TTY).

Exit codes:

CodeWhen
0Every task finished success or cache-hit (local or remote).
1At least one task ended failed or skipped; or parse/setup error.
FormEffect
(default)The project that contains cwd. Errors if cwd is not inside a project.
pkg#taskJust that project.
--allEvery project that declares the task.
--filter <pat> (repeatable)pnpm-style filter DSL (see below).
--affected[=<base>]Sugar for --filter '[<base>]' — git-changed projects only.

Combining: --filter and --affected stack (the affected base is appended as another filter pattern); --all overrides scope to the full workspace.

The full DSL lives in src/workspace/filter.ts; this is the user- facing summary.

FormMeaning
<pattern>Match by package name. * is a wildcard (no /).
./<dir>Match packages whose dir is at or under <dir> (relative to workspace root).
{<dir>}Same as ./<dir>.
<pattern>...Match + all transitive workspace dependencies.
...<pattern>Match + all transitive workspace dependents.
<pattern>^...Only the transitive dependencies, excluding the matched package itself.
...^<pattern>Only the transitive dependents, excluding the matched package itself.
!<pattern>Exclude packages matching <pattern>.
[<git-ref>]Projects whose files changed since <git-ref> (main, HEAD~5, …).

Examples:

Terminal window
vx run build --filter @scope/* # all packages under @scope
vx run build --filter app... # app and its transitive deps
vx run build --filter ...util # util and everything depending on it
vx run build --filter app^... # only app's deps (not app)
vx run build --filter '*' --filter '!docs' # everything except docs
vx run build --filter '[origin/main]' # projects with files changed since main

Run the task only in projects whose files changed since <base>.

  • --affected (no value) uses origin/HEAD, falling back to HEAD~1 if origin/HEAD isn’t resolvable.
  • --affected=<ref> uses the given git ref.

It’s a pure sugar for --filter '[<base>]'; both are resolved by src/workspace/affected.ts shelling out to git diff --name-only.

Anything after -- is forwarded (shell-quoted via JSON.stringify) to the task’s exec.command:

Terminal window
vx run test -- --watch # underlying test runner sees "--watch"
vx run build -- --sourcemap # build command gets "--sourcemap"

Forwarded args are folded into the cache key — different args produce different cache entries. They scope to user-requested tasks only; dependsOn-pulled deps don’t see them (so upstream cache identity stays clean).

FlagTypeDefaultDescription
--filter <pattern>repeatable(none)pnpm-style filter DSL (see above).
--allbooleanoffSelect every project that declares the task.
--affected[=<base>]optional valueoffFilter to projects changed since <base> (default origin/HEAD).
--excludeDependencies[=<names>]optional valueoffDrop dependsOn edges. No value = all (just the requested task runs); comma-list = drop only those names.
--concurrency <n>positive intnavigator.hardwareConcurrencyMaximum parallel tasks. 1 serializes.
--no-cache, --forcebooleanoffSkip cache reads AND writes; output globs are NOT cleaned.
--verbosity <n>int (0+)01 prints a per-task summary table after the framed blocks; 2+ reserved.
--dry[=text|json]optional valueoffPrint the task graph + predicted cache hit/miss; skip execution.
--graph[=<path>]optional valueoffEmit Graphviz DOT (stdout if no path); skip execution.
--summarize[=<path>]optional valueoffWrite per-run JSON to <cacheDir>/runs/<run_id>.json (or the explicit path).
--profile[=<path>]optional valueoff (profile.json when set)Write Chrome-trace JSON of the run’s wallclock spans.

Mutual exclusion:

  • --dry and --graph — both skip execution; pick one.
  • --dry or --graph with --summarize or --profile — the latter two need a real run.

Unknown flags are a parse error (unknown flag: --foo).

What a run prints is derived from the run’s intent (its “flow”), unless explicitly overridden:

  • FOCUSED — no selection flag was passed. The user is running “their” task; cwd and task count are irrelevant to the classification.
  • BROAD — the invocation used --all, --filter, or --affected. The user asked about a swath of the workspace and wants news, not output.
  • CI — the CI env var is truthy (CI=0 / CI=false don’t count). Wins over the flow.

Per-task visibility by outcome:

Outcomefocused (requested task)focused (dependency)broadCI / full
executedraw output, streamed livesilent● id ── executed • tframe
restored-local/-remotereplayed stdout, streamedsilentsilentframe, or one-liner if quiet
up-to-dateone-liner (nothing to show)silentsilentone-liner
failedraw output, streamed livefull framefull frameframe
skippedframesilentsilentframe

The header and end-of-run summary always print; cache-hit counts that broad mode silences per-task surface there. A focused vx run test is meant to feel like running the test command directly — same output, just faster.

Live streaming applies only when there is exactly one requested task. Live open/close framing assumes a single task owns the terminal between its open (┌─) and close (└─) lines — with two requested tasks running concurrently their frames would interleave into garbage. So when more than one task is requested (vx run build test), each requested task instead buffers its output and renders as a single atomic block at completion (success/failure/cache-hit-with-replay get a full frame, up-to-date/skipped get a one-liner). The blocks are blank-line separated and never interleave. A single vx run test keeps the live-stream experience unchanged.

On an interactive terminal (TTY stdout, not CI) a status region tracks the run live. Top to bottom:

  1. Pinned failures✗ <id> ── failed (exit N), one per failed task, accumulating as failures happen and staying until run end so they can never scroll out of sight. Capped at 5 lines plus a dim … +K more failed.
  2. Pinned persistent tasks▸ <id> ── running for every persistent task that became ready. The pin lives until run end (vx SIGTERMs persistent children when the graph finishes), so it is the visible evidence the dev server is still alive.
  3. Worker rows — one per worker (sized from concurrency, capped at 10 — the header states the pool as (N tasks, C workers)), each showing a spinner, the running task’s identity-colored id, and its elapsed time. A task stays in its row for its whole life and idle rows hold their place dimmed, so nothing ever jumps.
  4. Stats line — every bucket in fixed order:
▶ 1 failed · 78 success · 759 left · 1090 total │ 79 miss · 252 up-to-date · 0 local · 0 remote │ 00:16

(red failed/miss, green success/up-to-date, yellow left/local, cyan total/remote; +k more appears when more tasks run than rows). The region is redrawn in place (cursor-up + clear; not a TUI — no alternate screen) and erased before the summary prints. In the focused flow it only lives while dependencies run; it disappears for good the moment the requested task starts streaming.

Redraw cost is bounded: task events force a redraw, but forced redraws within 30 ms of the last draw coalesce into a single trailing draw when the floor expires (the final state always lands). On a 3,270-task warm run this cuts ~6.7 MB of redraw ANSI to ~20 KB.

Identity coloring: every project#task renders its project half in a stable hue hashed from the project name (same project = same color in every run and every surface) and its task half in a fixed pink — both deliberately outside the status palette, so an id can never read as an outcome.

On GitHub Actions (GITHUB_ACTIONS truthy, full output mode), each task’s block is wrapped in ::group::<id> (<outcome> <duration>) / ::endgroup:: so it collapses in the log viewer. Failed tasks stay pre-expanded and emit an ::error title=<id>::failed (exit N) annotation instead.

Explicit override; always beats the flow and CI defaults. full (frames for executed work, one-liners for quiet cache hits), errors-only (only failed tasks print; the CI noise budget), none (no per-task output). The header and end-of-run summary always print.

Both flags short-circuit execution. They build the full task graph, compute every task’s cache key, and probe the cache to predict the hit/miss outcome.

$ vx run ci --dry
would run:
◉ @vzn/vx#format-check cache hit (local) 02bfe8a9
◉ @vzn/vx#lint cache hit (local) d66cfed2
▶ @vzn/vx#test cache miss — would exec 68595e49
3 task(s) planned, 2 cache hits (2 local), 1 would run.

Status legend:

SymbolMeaning
cache hit (local) — entry already in <cacheDir>/
cache hit (remote) — entry would be fetched from the layer
cache miss — task would execute
·no-cache — task opts out (no cache block, or --no-cache)
group task (suppressed in human view; in DOT + JSON)

--dry=json emits the same data as a structured object:

{
"tasks": [
{
"id": "@vzn/vx#lint",
"project": "@vzn/vx",
"task": "lint",
"description": "oxlint with tsgolint-backed type-aware checks",
"hash": "d66cfed2...",
"cacheStatus": "hit-local",
"deps": []
}
]
}

--graph prints Graphviz DOT (stdout by default; --graph=path writes a file):

vx run ci --graph | dot -Tsvg > graph.svg
vx run ci --graph=graph.dot

Node fillcolor varies by predicted status (green = local hit, sky-blue = remote hit, orange = miss, gray = no-cache, fuchsia = group). Edges are unstyled.

Both flags add a side-effect after a real run completes. Errors writing the artifact are surfaced via vx: failed to write … but don’t change the run’s exit code — the run already happened.

Writes a per-run JSON file:

{
"runId": "01HKQ...",
"startedAt": "2026-05-13T22:00:00.123Z",
"endedAt": "2026-05-13T22:00:05.567Z",
"totalMs": 5443.7,
"tasks": [
{
"id": "@vzn/vx#lint",
"project": "@vzn/vx",
"task": "lint",
"status": "cache-hit",
"exitCode": 0,
"durationMs": 4,
"hash": "...",
"cpuMs": 123,
"peakRssBytes": 45678,
"wallclockStartNs": "12345678",
"wallclockEndNs": "12356789"
}
],
"summary": {
"successful": 3,
"failed": 0,
"skipped": 0,
"cachedLocal": 2,
"cachedRemote": 0,
"total": 3
}
}

Default path: <cacheDir>/runs/<run_id>.json. hrtime fields are strings (bigints serialized as strings) to preserve ns precision through JSON.

Writes a Chrome-trace JSON of every task’s wallclock span. Open in chrome://tracing or https://ui.perfetto.dev.

{
"traceEvents": [
{
"name": "@vzn/vx#lint",
"cat": "cache-hit",
"ph": "X",
"ts": 12345,
"dur": 4321,
"pid": 1,
"tid": 1,
"args": {
"exitCode": 0,
"hash": "...",
"cpuMs": 123,
"peakRssBytes": 45678
}
}
]
}

Each project gets a distinct tid so concurrent tasks across packages render on separate lanes. ts and dur are microseconds derived from the per-task hrtime.bigint() spans the runner captures. cat carries the task’s final status (success, cache-hit, cache-hit-remote, failed).

Default path: profile.json (cwd-relative).

Sandbox isolation is opt-in per task via a sandbox: {} block in the task’s config — there is no --sandbox CLI flag. See modules/sandbox-runtime.md for the full reference.

vx.config.ts
export default {
tasks: {
build: {
exec: { command: 'tsc' },
cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } },
sandbox: {
allowRead: ['../../node_modules'], // workspace-root node_modules
allowWrite: ['/tmp'],
},
},
},
}

Policy: fail on violation. The sandbox enforces declared inputs at the kernel level; any task that tries to read a path it didn’t declare either fails naturally (Linux: ENOENT from bwrap’s mount-namespace hide) or is flagged via the macOS violation store and forced to exit non-zero. No cache is written for a failed task.

vx run lazily initialises the sandbox runtime only when at least one task in the graph declares sandbox: {}. If runtime deps are missing (bwrap on Linux, sandbox-exec on macOS) or the platform is unsupported, the orchestrator errors out with a clear message before any task runs.

vx watch [OPTIONS] TASK [-- forwarded-args...]

Run the named task, then re-run it on every filesystem change in the projects in scope. Press Ctrl+C to stop.

Terminal window
vx watch test # cwd project; re-test on changes
vx watch test --all # every project that declares `test`
vx watch lint --filter '@scope/*' # filtered scope
vx watch build -- --sourcemap # forwarded args carry through every cycle
  1. Initial run. Same code path as vx run — same scope resolution, same task graph, same cache behaviour. The line vx watch: initial run... precedes it.
  2. Watch loop. After the initial run finishes, every project’s directory in scope is watched recursively. The workspace root is watched (non-recursively) for lockfile / pnpm-workspace.yaml changes.
  3. On change. The triggering path is logged (vx watch: <project> <relpath>; re-running...) and the orchestrator is invoked again with the same options. Events arriving while a run is in flight queue and drain after the current cycle. Re-runs are debounced ~150ms after the last event.
  4. Exit. SIGINT (Ctrl+C) prints vx watch: stopped and exits 0.

Always ignored (no re-trigger):

  • node_modules/, .git/, .vx/ anywhere in the path.
  • Files ending in .tsbuildinfo or ~ (editor swap files).

Everything else triggers a cycle. We deliberately don’t filter events against per-task cache.inputs.files — the cache hash is the source of truth. A change to an irrelevant file produces a cache-hit run (typically tens of ms); the cost is much smaller than the engineering cost of a per-event glob match.

Edits to a lockfile (pnpm-lock.yaml, bun.lock, …) or pnpm-workspace.yaml at the root invalidate every task’s cache key via the workspace fingerprint. Watch mode hears those because it watches the workspace root (non-recursively).

The following flags are rejected (parser exits 1 before the initial run):

  • --dry / --graph — those skip execution; nothing to watch.
  • --summarize / --profile — would overwrite their target per cycle.

Persistent tasks (exec.persistent) re-spawn each cycle: the previous SIGTERM happens between cycles, then the next cycle launches a fresh child. For dev-server workflows where you want the server to stay up across changes, use the dev tool’s own watch (vite, tsc -b -w, bun --watch) rather than vx watch.

  • 0 — clean Ctrl+C / SIGTERM exit.
  • 1 — parser error or missing scope.

Re-run cycles whose orchestrator returns { ok: false } do NOT exit the watch loop — a failed cycle just prints the framed FAILED block and waits for the next change. This matches turbo watch / nx watch.

Evict old or oversized cache entries. Operates on <cacheDir>/cache.db plus the on-disk <hash>/ directories (each one carrying its own outputs/, stdout, and stderr).

vx cache prune --older-than <duration> # Drop entries last accessed before now - duration.
vx cache prune --max-size <size> # After age-based pruning, evict LRU until under <size>.

At least one of --older-than / --max-size is required. Both may be combined: age-based eviction runs first, then LRU eviction if the total is still over the size cap.

Duration units: s, m, h, d. Examples: 30d, 24h, 60m, 30s.

Size units: K, M, G, T (powers of 1024). Optional B suffix is accepted. Examples: 500M, 1G, 100K, 2T, 500MB.

$ vx cache prune --older-than 30d
Pruned 42 entries (1.3 GB freed)
$ vx cache prune --older-than 7d --max-size 500M
Pruned 18 entries (320.1 MB freed)

Exit codes:

  • 0 — pruning completed (zero or more entries evicted).
  • 1 — parse error, missing policy, or workspace-discovery error.

vx cache prune resolves the cache directory via src/workspace/workspace.ts:findWorkspaceRoot from cwd. Note: the prune command currently always uses the default .vx/cache/; a workspace-config cacheDir override is not yet honored by this path (tracked).

vx run ... --frozen loads configs from the committed vx-lock.json instead of evaluating them — CI reproducibility mode. Plain vx run always evaluates live (a byte hash can’t see a config’s import closure, so silently consuming the lock locally would risk stale freezes). --frozen errors only when no lock exists or a project is missing from it — it performs NO staleness checks of its own: run vx lock --check first in the pipeline; that audit re-evaluates everything, making any per-run re-check redundant.

Freeze every project’s resolved config into vx-lock.json at the workspace root. Configs are programs; vx lock evaluates them in the current environment and stores the post-evaluation objects plus a content hash of each config file.

vx lock # Evaluate all vx.config.* now; write vx-lock.json.
vx lock --check # Audit: hash checks + full re-evaluation vs the lock. Exit 1 on drift.

Plain runs ALWAYS evaluate live — the lock’s existence changes nothing. Only vx run --frozen consumes it: configs come from the lock with no evaluation and no staleness checks of its own (frozen-env semantics: env reads in a config keep their lock-time values; a project absent from the lock or a missing lock is a hard error).

--check is the audit: it reports changed config files via the stored hashes AND re-evaluates every config in the current environment, Bun.deepEquals-comparing against the frozen objects — catching eval-time env and import-closure drift that byte hashes cannot see. The CI recipe is vx lock --check && vx run … --frozen. Full design: docs/design/config-lock-2026-06.md.

Exit codes:

  • 0 — lock written / lock is up to date.
  • 1 — parse error, workspace-discovery error, missing lock (--check without one), or any drift (every mismatched project is listed on stderr).

Self-update the compiled binary in place: downloads the release asset for this platform and atomically replaces the running executable (vx upgrade <tag> pins a specific release; default latest). Named upgrade per CLI convention (bun upgrade, deno upgrade). Refuses when running from source — use git pull. Re-running install.sh remains equivalent.

Generate one vx.config.ts per workspace package from an existing Turbo or Nx setup. The source is auto-detected at the workspace root:

  • turbo.jsonTurbo path. Reads the root pipeline (tasks in turbo 2, pipeline in turbo 1), per-package turbo.json extends overlays (per-key merge over the root task), and each package’s package.json scripts. A task is emitted for a package only when the package declares the matching script (turbo semantics); the script body is inlined as exec.command.
  • .nx/workspace-data/project-graph.jsonNx path. Migrates from the resolved graph snapshot ONLY — plugin-inferred targets are frozen as static config (noted in the report header). When nx.json exists but the graph file is missing, the error tells you to run any nx command once (or nx graph --file=.nx/workspace-data/project-graph.json).
  • Both present → pass --from turbo or --from nx to disambiguate from.
vx migrate # write vx.config.ts files (and vx-preset.ts when needed)
vx migrate --dry # print the generated file contents instead of writing
vx migrate --force # overwrite existing vx.config.* / vx-preset.ts

Existing vx.config.* files are never overwritten without --force — conflicts abort the whole run before anything is written.

Mapping highlights:

  • Turbo: dependsOn copies verbatim (same micro-syntax); inputscache.inputs.files ($TURBO_DEFAULT$ expands to '**/*' in place, ! negation passes through); outputscache.outputs.files (vx outputs have no negation — negated entries become TODOs); envcache.inputs.env AND exec.env.passThrough (vx child envs are isolated, so a hashed env var must also be forwarded); passThroughEnv → passThrough only; cache: false omits the cache block; persistent: trueexec.persistent: {} plus a TODO suggesting readyWhen. globalEnv / globalPassThroughEnv / globalDependencies become exported arrays in a generated root vx-preset.ts that each config imports and spreads — TypeScript composition replaces turbo’s global fields (globalInputs spreads into cache.inputs.workspaceFiles: globalDependencies are root-relative by definition). $TURBO_ROOT$/<path> inputs map to cache.inputs.workspaceFiles (negation keeps !), outputs to cache.outputs.workspaceFiles; $TURBO_ROOT$ in dependsOn (and non-prefix forms) stays a TODO — vx has no workspace-root tasks.
  • Nx: nx:run-commands joins commands with ' && ' (a cwd differing from the project root is a TODO); nx:run-script inlines the package.json script body; any other executor emits a valid placeholder command (echo 'TODO(vx-migrate): fill in' && exit 1) with a TODO carrying the executor + its options JSON. Inputs strip {projectRoot}/, map {workspaceRoot}/<path> to cache.inputs.workspaceFiles (negation keeps !), expand named inputs from nx.json, route {env: X} to cache.inputs.env + passThrough, and TODO the rest (^deps-inputs, externalDependencies, dependentTasksOutputFiles — vx folds upstream via dependsOn already). Outputs strip {projectRoot}/, map {workspaceRoot}/<path> to cache.outputs.workspaceFiles, resolve literal {options.x} tokens, and append /** to bare directory paths. dependsOn objects map projects: 'dependencies''^target', 'self'/absent → 'target', project lists → 'proj#target'. The graph’s dependency edges are ignored (vx derives package edges from manifests); edges with no manifest counterpart produce one report line (“N implicit Nx deps not representable”).

Everything unmappable becomes a // TODO(vx-migrate): … comment in the generated file — TODOs are always comments, never values, so every generated config loads and validates as-is. The run ends with a report: tasks migrated clean, TODO count with project#task: reason lines, and the files written.

Exit codes: 0 success (TODOs don’t fail the run); 1 parse error, detection error, or overwrite conflict without --force.

Introspect the workspace’s live resolved configs — what a run would see right now. Configs are evaluated with the same loader the run path uses; vx show never reads vx-lock.json (the lock is already the frozen JSON — open it directly if you want the frozen view).

vx show # list every project
vx show <project> # one project's resolved config
vx show <pkg>#<task> # a single task
vx show ... --format json # machine-readable (default: pretty)

No target: one line per project — name, root-relative dir, declared task count, and a (no vx config) marker for config-less packages. With --format json it’s an array of { name, dir, tasks: string[] }.

$ vx show
app packages/app 3 tasks
bare packages/bare (no vx config)

vx show <project> prints a block per task: description, command ((group) for group tasks), dependsOn, cache.inputs.files / .env / .tasks, cache.outputs.files, and persistent fields. --format json emits { name, dir, config } with the config exactly as resolved. vx show <pkg>#<task> narrows to one task ({ name, dir, task, config } in JSON).

$ vx show app#build
app — packages/app
build
description: compile the app
command: tsc -b
dependsOn: ^build
inputs.files: src/**
inputs.env: NODE_ENV
outputs.files: dist/**

Unknown project / task names exit 1 with includes-match suggestions (unknown project: "ap" — did you mean app?).

Exit codes: 0 success; 1 parse error or unknown target.

Workspace doctor — one screen of facts for bug reports and sanity checks (pretty only):

$ vx info
vx: 0.0.0
bun: 1.3.14
git: 2.53.0
workspace root: /work/repo
projects: 12 (34 tasks)
cache dir: /work/repo/.vx/cache
cache entries: 42 (1.3 GB)
runs (24h): 7 (5 cache hits)
vx-lock.json: yes
remote cache: no
  • git shows (not found) when the binary is missing; a broken project config contributes zero tasks instead of failing the printout.
  • remote cache is yes when both VX_REMOTE_CACHE_URL and VX_REMOTE_CACHE_TOKEN are set.
  • vx stats is a deprecated alias of vx info (info absorbed it); it prints byte-identical output.

vx run emits framed blocks. Stdout/stderr from each task is buffered until completion, then dumped inside the block — so concurrent tasks never interleave their lines.

Frame anatomy:

┌─ <id> > <outcome header> restored-local • abc12345 / failed (exit N) / …
├─ command only for executed tasks (success or failed)
<the command, raw>
├─ stdout only when non-empty
<stdout lines, raw>
├─ stderr only when non-empty
<stderr lines, raw>
├─ sandbox violations (N) when the sandbox recorded violations
<violation lines, raw>
└─ <id> ── (<duration>) <outcome word>

Section headers (├─ …) and frame corners render dim; the id keeps its identity coloring. Content lines are raw — no left border, no indent — so long lines wrap without colliding with frame glyphs and copy/paste yields the verbatim output. Every block (and every live frame close in focused flow) is followed by a blank line so frames never collide with the next one-liner.

• vx 0.0.0
• Running ci in 1 package (3 tasks)
• Remote caching disabled
◌ @vzn/vx#format-check ── restored-local • 02bfe8a9
┌─ @vzn/vx#lint > restored-local • d66cfed2
├─ stdout
Found 0 warnings and 0 errors.
└─ @vzn/vx#lint ── (4ms) restored-local
┌─ @vzn/vx#test > success
├─ command
bun test
├─ stdout
... test output ...
└─ @vzn/vx#test ── (5.20s) success
tasks <stacked 50-cell meter>
3 success
cache <stacked 50-cell meter>
1 miss · 2 local
time 5.34s

Group tasks emit no framed block by design (they aren’t real tasks).

ANSI truecolor (ansi-16m) sequences, gated by env:

VarEffect
NO_COLOR=…Force off. Overrides FORCE_COLOR.
FORCE_COLOR=…Force on.
(neither)On iff stdout.isTTY.

Programmatic callers passing a custom log to the run options always see plain text.

If VX_REMOTE_CACHE_URL and VX_REMOTE_CACHE_TOKEN are set in the environment, vx run layers a remote cache on top of the local one.

Reads try local first, then remote (hydrating local on remote hit). Writes go to local immediately, then upload to remote in the background — failures are logged via onRemoteError but do not fail the user’s build.

Env varRequired?Notes
VX_REMOTE_CACHE_URLyesBase URL, e.g. https://cache.example.com.
VX_REMOTE_CACHE_TOKENyesBearer token sent on every request.
VX_REMOTE_CACHE_TEAM_IDnoSent as ?teamId= (Turbo tenancy).
VX_REMOTE_CACHE_SLUGnoSent as ?slug=.
VX_REMOTE_CACHE_TIMEOUT_MSnoPer-request timeout. Default 60000.
VX_REMOTE_CACHE_SIGNATURE_KEYnoHMAC artifact signing (Turbo-compatible x-artifact-tag). When set, uploads are signed and downloads are verified — a missing or mismatched tag degrades to a cache miss.

Wire spec is Turborepo /v8/artifacts/. Compatible servers include ducktors/turborepo-remote-cache, Fox32/openturbo-remote-cache, and Vercel’s hosted Turbo cache. See design/remote-cache.md for the full protocol.

vx info surfaces the aggregate cache stats (entry count, total size, runs + hits in the last 24 h). For anything deeper, vx records every task to a runs table in cache.db — ULID run_id, hrtime wallclock spans, cpu_ms, peak RSS, status, cache_hit flag, bytes_uploaded / _downloaded for the remote layer. The SQLite file IS the API:

Terminal window
sqlite3 .vx/cache/cache.db "
SELECT project, task, status, duration_ms
FROM runs
WHERE run_id = (SELECT run_id FROM runs ORDER BY id DESC LIMIT 1)
ORDER BY duration_ms DESC;
"

The schema is documented in caching.md § SQLite tables.

Tracked in detail in comparison.md. Recap of the gaps visible from the CLI:

  • --continue=<mode> (current behavior: independent siblings continue; dependents are skipped).
  • --output-logs full|errors-only|hash-only|none.
  • --cache-dir <path> CLI flag (workspace-config field works; CLI flag doesn’t).
  • --remote-cache-timeout, --token, --team on the CLI (env vars work).
  • vx prune (workspace subset for Docker builds).
import { run, planRun, defineProject, defineWorkspace } from '@vzn/vx'
const summary = await run({
cwd: process.cwd(),
tasks: ['build', 'test'],
concurrency: 4,
noCache: false,
})
// summary.ok: boolean; summary.outcomes: TaskOutcome[]
const plan = await planRun({
cwd: process.cwd(),
tasks: ['build'],
})
// plan.tasks: PlannedTask[]

Surface:

  • run(options) — execute. Returns Promise<RunSummary>.
  • planRun(options) — predict, no execute. Returns Promise<RunPlan>. Used by --dry / --graph.
  • defineProject / defineWorkspace — identity helpers for type inference in user configs.
  • RunOptions / RunSummary / RunPlan / TaskOutcome types are re-exported from @vzn/vx.

A log: Logger option lets embedders swap the default framed-block logger for a custom one (e.g. JSON-line emission). Custom loggers always see plain text (colors are off when a non-default logger is provided).

The CLI dispatcher (run(argv) in src/cli.ts) is not part of the public package exports yet; bin.ts calls it directly.