Skip to content
GitHubRSS

src/cli/run.ts — vx run parser + handler

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.

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[]): RunArgs
export function parseConcurrency(v: string, cpus?: number): number | null
export 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 }>

parseRunArgs(argv) walks the array once:

  1. Split on the first -- — everything after is forwardArgs.
  2. Loop the prefix: recognize each flag form. Optional-value flags (--dry, --graph, --summarize, --profile, --affected, --exclude-dependencies) accept either the bare form or =<value>.
  3. Unknown flags + missing values + invalid integers → returned via RunArgs.error. The handler short-circuits to exit 1. An unknown flag names the nearest flag vx run accepts (flagHint('run', arg) in help.ts, which every verb’s refusal shares; it reads the help text, so there is no second list to drift).
  4. 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-cache beats --force, both layered over a --cache spec.

After parsing, resolveRunOptions builds the orchestrator’s projects field:

Conditionprojects
Every positional is anchored (pkg#task)undefined (no scope needed)
Any bare positional + filters.length > 0resolveFilters(...) result
Any bare positional + --allundefined (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.

When tasks.length === 0:

  • Non-TTY → exits 1 with missing task name (stdin is not a TTY, so no picker; vx run &lt;task&gt;, e.g. vx run build).
  • TTY → pickTask(cwd) loads every project’s tasks, prints a numbered list with description next to each id, reads a 1-based index via readline/promises, emits one anchored pkg#task into tasks.

If --dry or --graph is set:

  1. planRun(opts) — same setup as run but stops before the scheduler.
  2. 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, ...).
  3. A requested name that matched no project (plan.unresolvedTasks), or an empty plan → exits 1 with no projects declare task(s): … (plus Did you mean <task>? when a declared task, or for pkg#task a runnable spec, is within two edits), before any DOT / JSON is written.

--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 5200ms

Columns 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).
  • --affected end-to-end against a git fixture.
  • Planning paths (--dry text/json, --graph stdout/file).
  • Verbose summary formatting.
  • --summarize and --profile artifact emission.
  • Forwarded args (cache-key folding tested in tests/orchestrator.test.ts).

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.