src/cli/run.ts — vx run parser + handler
Purpose
Section titled “Purpose”Parse vx run’s argv, resolve the run’s options, and invoke the
orchestrator. Handles both real runs (orchestrator.run) and planning
paths (planRun → text/json/DOT formatters). What a run is asked to
run — the --filter resolution, --affected’s orphan-path owners, the
cwd project and the interactive picker — is src/cli/select.ts
(split 2026-09-09, pure code motion), which run.ts calls from
resolveRunOptions; every read of the workspace there goes through
the staged load (loadCliProjects), so the selection is the run’s —
and when the selection had to stage every config (a filter that walks
the graph), that load travels into the run as RunOptions.staged, so
the project stage runs once per project per run. A filter pass also hands its discovered
projects over (RunOptions.discovered), so the workspace is listed once.
Public surface
Section titled “Public surface”export interface RunArgs { continueMode?: ContinueMode // --continue[=never|deps-ok|always] tasks: string[] // bare + `pkg#task` positionals filters: string[] // raw --filter values all: boolean excludeDependencies: 'all' | string[] concurrency: number | undefined // `<n>` or `<n>%` of the cores this process may use cache: CachePolicy // resolved from --cache / --no-cache / --force (default all-on) remoteRequested?: boolean // a --cache spec named a remote axis cacheDir: string | undefined // --cache-dir frozen: boolean // --frozen: run the lock's graph retries: number | undefined // --retry <n> run-level default timeout: number | undefined // --timeout <ms> run-level default outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only' // --output-logs download?: 'all' | 'toplevel' | 'none' // --download forwardArgs: string[] // everything after `--` verbosity: number dry: 'text' | 'json' | undefined graph: string | undefined // '' = stdout; else path summarize: string | undefined // '' = default path; else path profile: string | undefined // 'profile.json' default affected: string | undefined // '' = default base; else ref tags: Record<string, string> // --tag k=v, onto the run record report: 'markdown' | undefined // --report[=markdown] reportFile: string | undefined // --report-file <path> error?: string // parser-error message}
export function parseRunArgs(args: readonly string[]): RunArgsexport function parseConcurrency(v: string, cpus?: number): number | nullexport function detectFlow( parsed: Pick<RunArgs, 'all' | 'filters' | 'affected'>,): 'focused' | 'broad'export async function runCmd(args: readonly string[]): Promise<number>
/** * Shared with `cli/watch.ts`: turn parsed args + cwd + task list into * the orchestrator's `RunOptions`. Returns the options, an error * message (caller prefixes with subcommand name), or `nothingSelected` * — the selection resolved to zero projects (nothing changed since the * `--affected` base), a clean exit, not a failure. Doesn't * handle the interactive picker — `runCmd` does that first, then * passes the resolved task list in. */export async function resolveRunOptions( parsed: RunArgs, cwd: string, tasks: readonly string[],): Promise<RunOptions | { error: string } | { nothingSelected: string }>Parser
Section titled “Parser”parseRunArgs(argv) walks the array once:
- Split on the first
--— everything after isforwardArgs. - Loop the prefix: recognize each flag form. Optional-value flags
(
--dry,--graph,--summarize,--profile,--affected,--exclude-dependencies) accept either the bare form or=<value>. - Unknown flags + missing values + invalid integers → returned via
RunArgs.error. The handler short-circuits to exit 1. An unknown flag names the nearest flagvx runaccepts (flagHint('run', arg)inhelp.ts, which every verb’s refusal shares; it reads the help text, so there is no second list to drift). - Mutually-exclusive combinations checked at the end:
--dry+--graph; either +--summarize/--profile/--report/--report-file(they skip execution; the artifacts need a real run).--no-cachebeats--force, both layered over a--cachespec.
Scope resolution
Section titled “Scope resolution”After parsing, resolveRunOptions builds the orchestrator’s projects field:
| Condition | projects |
|---|---|
Every positional is anchored (pkg#task) | undefined (no scope needed) |
Any bare positional + filters.length > 0 | resolveFilters(...) result |
Any bare positional + --all | undefined (every project) |
| Any bare positional + default | [findCwdProject(cwd)] or error |
--affected[=<base>] is sugar for an extra ...[<base>] filter —
the changed projects and their dependents (#446) — put first in
filterStrings before resolveFilters runs. defaultAffectedBase(root)
resolves the no-value form (origin/HEAD → fall back HEAD~1); a
base that is HEAD itself (a single-branch clone) is named, with the
two bases that would compare something.
Interactive picker
Section titled “Interactive picker”When tasks.length === 0:
- Non-TTY → exits 1 with
missing task name (stdin is not a TTY, so no picker; vx run <task>, e.g. vx run build). - TTY →
pickTask(cwd)loads every project’s tasks, prints a numbered list withdescriptionnext to each id, reads a 1-based index viareadline/promises, emits one anchoredpkg#taskintotasks.
Planning short-circuit
Section titled “Planning short-circuit”If --dry or --graph is set:
planRun(opts)— same setup asrunbut stops before the scheduler.- Pick a formatter from
cli/plan-format.ts:--dry=json→formatPlanJson(plan)→ stdout.--dry=text(default) →formatPlanText(plan)→ stdout.--graph=''→formatGraphDot(plan)→ stdout.--graph=<path>→formatGraphDot(plan)→Bun.write(path, ...).
- A requested name that matched no project (
plan.unresolvedTasks), or an empty plan → exits 1 withno projects declare task(s): …(plusDid you mean <task>?when a declared task, or forpkg#taska runnable spec, is within two edits), before any DOT / JSON is written.
Verbose summary
Section titled “Verbose summary”--verbosity 1 (any value above 0) prints a per-task table after the
framed blocks, the status column being outcomeLabel — the one
vocabulary every surface uses:
TASK STATUS DURATION--------------------------------------@vzn/vx#lint restored-local 4ms@vzn/vx#test success 5200msColumns auto-width to the widest row (the header sets the minimum), the duration right-aligned.
tests/cli.test.ts is the main coverage:
- All flags: presence, value-or-not forms, missing-value errors, unknown-flag errors.
- Scope resolution matrix (default /
--all/--filter/pkg#task). - Interactive picker (TTY input mocked).
--affectedend-to-end against a git fixture.- Planning paths (
--drytext/json,--graphstdout/file). - Verbose summary formatting.
--summarizeand--profileartifact emission.- Forwarded args (cache-key folding tested in
tests/orchestrator.test.ts).
Replacing this module
Section titled “Replacing this module”The internal seam is small: runCmd(argv): Promise<number>. Replace
the body but keep that contract — cli/index.ts dispatches to it by
import. To swap the picker (e.g. for a fuzzy selector), replace
pickTask in-place; nothing else depends on it.