Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b7c2a58
feat[api]: add qvac check-system command and system-requirements doc …
simon-iribarren Apr 21, 2026
c0c69e2
mod: rename qvac check-system to qvac doctor (QVAC-12239)
simon-iribarren Apr 21, 2026
f22ffd1
mod: expand qvac doctor checks for correctness and completion (QVAC-1…
simon-iribarren Apr 21, 2026
1c27aca
Merge branch 'main' into feat/qvac-12239-system-requirements-checker
simon-iribarren Apr 22, 2026
48eb5b1
Merge branch 'main' into feat/qvac-12239-system-requirements-checker
simon-iribarren Apr 22, 2026
e1cfdae
fix: cap doctor probeBinary at 3s to prevent hangs (QVAC-12239)
simon-iribarren Apr 23, 2026
50f5407
mod: normalize checkTotalMemory severity to 'required' (QVAC-12239)
simon-iribarren Apr 23, 2026
1f5c7c0
refactor: split doctor checks into per-section modules behind a unifo…
simon-iribarren Apr 23, 2026
ccb89d3
feat: add GPU acceleration check to qvac doctor (QVAC-12239)
simon-iribarren Apr 23, 2026
f425bd3
Merge branch 'main' into feat/qvac-12239-system-requirements-checker
simon-iribarren Apr 27, 2026
35ee5c5
Merge branch 'main' into feat/qvac-12239-system-requirements-checker
simon-iribarren Apr 27, 2026
6f0add8
Merge branch 'main' into feat/qvac-12239-system-requirements-checker
simon-iribarren Apr 28, 2026
eef4fcf
Merge branch 'main' into feat/qvac-12239-system-requirements-checker
simon-iribarren Apr 28, 2026
26d7f4a
Merge branch 'main' into feat/qvac-12239-system-requirements-checker
simon-iribarren Apr 28, 2026
551dcf7
Merge branch 'main' into feat/qvac-12239-system-requirements-checker
simon-iribarren Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ This package is published to npm as **`@qvac/cli`** and lives in the QVAC monore

- [Installation](#installation)
- [Command Reference](#command-reference)
- [`doctor`](#doctor)
- [`bundle sdk`](#bundle-sdk)
- [Configuration](#configuration)
- [System Requirements](#system-requirements)
- [Development](#development)
- [License](#license)

Expand All @@ -35,6 +37,56 @@ npx @qvac/cli <command>

## Command Reference

### `doctor`

Validate that the current host can run `@qvac/sdk` + `@qvac/cli` before you
hit runtime errors. The command prints a human-readable report by default and
exits `1` when any required check fails.

```bash
qvac doctor [options]
```

**Options:**

| Flag | Description |
|------|-------------|
| `--json` | Output the report as JSON. |
| `-q, --quiet` | Suppress stdout — only set the exit code. |
| `-v, --verbose` | Detailed output. |

**What it checks:**

- **Runtime** — Node.js version (`>= 18`) and supported CLI host
(desktop platforms only; Android/iOS are SDK deploy targets reported
separately below).
- **Hardware** — total RAM, available RAM (via `os.availableMemory()` on
Node 22+), GPU acceleration (Metal on macOS, `vulkaninfo` on
Linux/Windows), and free disk space in the current working directory.
- **Deploy targets (SDK)** — desktop target matrix, Android (`adb`), and
iOS (`xcodebuild` on macOS). Missing mobile toolchains produce
warnings, not failures.
- **Optional tools** — `ffmpeg` (microphone/transcription), Bare runtime,
Bun.
- **Project** — whether `@qvac/sdk` is resolvable from the current
working directory (works for hoisted monorepo installs too).

See [`system-requirements.md`](./system-requirements.md) for the full list of
thresholds and rationale.

**Examples:**

```bash
# Human-readable report
qvac doctor

# JSON for CI / scripts
qvac doctor --json

# Fail-fast in a script (exit 1 on any required check)
qvac doctor --quiet || exit 1
```

### `bundle sdk`

Generate a tree-shaken Bare worker bundle containing the plugins you select (defaults to all built-in plugins).
Expand Down Expand Up @@ -138,6 +190,16 @@ This file is primarily the SDK runtime config, but `qvac bundle sdk` also reads
}
```

## System Requirements

See [`system-requirements.md`](./system-requirements.md) for the full list of
required and recommended host dependencies. You can validate your environment
at any time with:

```bash
qvac doctor
```

## Development

**Prerequisites:**
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/doctor/check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import os from 'node:os'
import { spawnSync } from 'node:child_process'
import type { CheckResult } from './types.js'

export interface ProbeResult {
ok: boolean
version?: string
// Full trimmed stdout. Checks that only need a version line use
// `version` (first line); checks that need to parse multi-line output
// — e.g. `vulkaninfo --summary` device names — read `stdout`.
stdout?: string
}

export type ProbeFn = (command: string, args: string[]) => ProbeResult

// Read once at context creation rather than from each check so checks
// are pure functions of CheckContext. This is the contract that makes
// tests deterministic: build a context with the inputs you care about,
// invoke the check, assert the result.
export interface CheckContext {
projectRoot: string
platform: NodeJS.Platform
arch: string
nodeVersion: string
totalMemoryBytes: number
availableMemoryBytes: number
probe: ProbeFn
}

export type Check = (ctx: CheckContext) => CheckResult

// Cap probes at 3s so a hung/interactive binary (or an adb that is
// waiting for a device) cannot make `qvac doctor` hang. 3s is generous
// for a `--version` style call while still short enough to keep the
// command snappy.
const PROBE_TIMEOUT_MS = 3000

export const probeBinary: ProbeFn = (command, args) => {
try {
const r = spawnSync(command, args, {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: PROBE_TIMEOUT_MS
})
if (r.error || r.status !== 0 || r.signal === 'SIGTERM') return { ok: false }
const stdout = r.stdout.toString('utf8').trim()
const firstLine = stdout.split('\n')[0]?.trim() ?? ''
const result: ProbeResult = { ok: true }
if (firstLine) result.version = firstLine
if (stdout) result.stdout = stdout
return result
} catch {
return { ok: false }
}
}

// Prefer os.availableMemory() (Node 22+) which reports memory actually
// available for allocation. os.freemem() is known to be misleading on
// Linux and macOS because it excludes reclaimable page cache, which
// causes noisy false warnings on otherwise healthy systems.
function readAvailableMemoryBytes (): number {
const available = (os as unknown as { availableMemory?: () => number }).availableMemory
if (typeof available === 'function') return available()
return os.freemem()
}

export function createDefaultContext (projectRoot: string = process.cwd()): CheckContext {
return {
projectRoot,
platform: process.platform,
arch: process.arch,
nodeVersion: process.versions.node,
totalMemoryBytes: os.totalmem(),
availableMemoryBytes: readAvailableMemoryBytes(),
probe: probeBinary
}
}
204 changes: 204 additions & 0 deletions packages/cli/src/doctor/checks/hardware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import fs from 'node:fs'
import { spawnSync } from 'node:child_process'
import type { Check } from '../check.js'

const MIN_TOTAL_MEMORY_GB = 2
const RECOMMENDED_TOTAL_MEMORY_GB = 4
const RECOMMENDED_AVAILABLE_MEMORY_GB = 2
const RECOMMENDED_FREE_DISK_GB = 5

function toGB (bytes: number): number {
return bytes / (1024 ** 3)
}

function fmtGB (bytes: number): string {
return `${toGB(bytes).toFixed(2)} GB`
}

// Total RAM has a hard gate (<2 GB fails the report) with a recommended
// band on top, so the check is 'required' as a whole — the same pattern
// as checkNodeVersion. Severity describes the check itself, not the
// outcome of a particular branch.
export const checkTotalMemory: Check = (ctx) => {
const totalBytes = ctx.totalMemoryBytes
const gb = toGB(totalBytes)
if (gb < MIN_TOTAL_MEMORY_GB) {
return {
id: 'memory-total',
label: 'Total RAM',
status: 'fail',
severity: 'required',
value: fmtGB(totalBytes),
hint: `At least ${MIN_TOTAL_MEMORY_GB} GB of RAM is required to run QVAC models.`
}
}
if (gb < RECOMMENDED_TOTAL_MEMORY_GB) {
return {
id: 'memory-total',
label: 'Total RAM',
status: 'warn',
severity: 'required',
value: fmtGB(totalBytes),
hint: `Less than ${RECOMMENDED_TOTAL_MEMORY_GB} GB RAM detected; most LLMs will fail to load.`
}
}
return {
id: 'memory-total',
label: 'Total RAM',
status: 'pass',
severity: 'required',
value: fmtGB(totalBytes)
}
}

export const checkAvailableMemory: Check = (ctx) => {
const availableBytes = ctx.availableMemoryBytes
const gb = toGB(availableBytes)
if (gb < RECOMMENDED_AVAILABLE_MEMORY_GB) {
return {
id: 'memory-available',
label: 'Available RAM',
status: 'warn',
severity: 'recommended',
value: fmtGB(availableBytes),
hint: `Less than ${RECOMMENDED_AVAILABLE_MEMORY_GB} GB available; close other applications before loading large models.`
}
}
return {
id: 'memory-available',
label: 'Available RAM',
status: 'pass',
severity: 'recommended',
value: fmtGB(availableBytes)
}
}

interface StatfsLike { bsize: number, bavail: number }

function readFreeDiskBytes (dir: string): number | null {
const statfs = (fs as unknown as { statfsSync?: (p: string) => StatfsLike }).statfsSync
if (typeof statfs === 'function') {
try {
const info = statfs(dir)
return info.bsize * info.bavail
} catch {
// fall through to shell fallback
}
}
// Fallback for Node 18.0–18.14 on unix (statfsSync was added in 18.15).
if (process.platform !== 'win32') {
try {
const r = spawnSync('df', ['-Pk', dir], { stdio: ['ignore', 'pipe', 'pipe'] })
if (r.error || r.status !== 0) return null
const lines = r.stdout.toString('utf8').trim().split('\n')
const row = lines[1]
if (!row) return null
const cols = row.trim().split(/\s+/)
// Columns: Filesystem 1024-blocks Used Available Capacity Mounted
const kb = cols.length >= 4 && cols[3] !== undefined ? Number.parseInt(cols[3], 10) : NaN
if (!Number.isFinite(kb)) return null
return kb * 1024
} catch {
return null
}
}
return null
}

// QVAC inference backends rely on Metal on macOS and Vulkan on Linux/Windows
// (see the ggml-metal / whisper-cpp+vulkan CMake configs). Running LLM or
// Whisper inference without a GPU backend falls back to CPU, which is
// roughly an order of magnitude slower — worth flagging in the report.
function parseVulkanDeviceNames (stdout: string): string[] {
const names: string[] = []
for (const line of stdout.split('\n')) {
const m = /deviceName\s*=\s*(.+)/.exec(line)
if (m && m[1] !== undefined) names.push(m[1].trim())
}
return names
}

export const checkGpuAcceleration: Check = (ctx) => {
if (ctx.platform === 'darwin') {
return {
id: 'gpu-acceleration',
label: 'GPU acceleration',
status: 'pass',
severity: 'recommended',
value: 'Metal (native macOS backend)'
}
}
if (ctx.platform !== 'linux' && ctx.platform !== 'win32') {
return {
id: 'gpu-acceleration',
label: 'GPU acceleration',
status: 'info',
severity: 'informational',
value: `not checked on ${ctx.platform}`,
hint: `'qvac doctor' does not validate GPU acceleration on ${ctx.platform}.`
}
}
const r = ctx.probe('vulkaninfo', ['--summary'])
if (!r.ok) {
const installHint = ctx.platform === 'win32'
? 'Install the Vulkan runtime via the latest GPU drivers or the Vulkan SDK (https://vulkan.lunarg.com/).'
: 'Install a Vulkan loader and vulkan-tools (Debian/Ubuntu: `apt install libvulkan1 vulkan-tools`; Fedora: `dnf install vulkan-tools vulkan-loader`).'
return {
id: 'gpu-acceleration',
label: 'GPU acceleration',
status: 'warn',
severity: 'recommended',
value: 'Vulkan ICD not found',
hint: `${installHint} Without a Vulkan ICD, QVAC inference falls back to CPU and is significantly slower.`
}
}
const devices = parseVulkanDeviceNames(r.stdout ?? '')
if (devices.length === 0) {
return {
id: 'gpu-acceleration',
label: 'GPU acceleration',
status: 'pass',
severity: 'recommended',
value: 'Vulkan ICD present',
hint: 'vulkaninfo reported no GPU devices; ensure a GPU driver is installed if you expect hardware acceleration.'
}
}
return {
id: 'gpu-acceleration',
label: 'GPU acceleration',
status: 'pass',
severity: 'recommended',
value: `Vulkan: ${devices.join(', ')}`
}
}

export const checkFreeDiskSpace: Check = (ctx) => {
const dir = ctx.projectRoot
const free = readFreeDiskBytes(dir)
if (free === null) {
return {
id: 'disk-free',
label: `Free disk space (${dir})`,
status: 'skip',
severity: 'recommended',
hint: 'Disk space check requires Node.js v18.15+ (fs.statfsSync) or a POSIX `df` on PATH.'
}
}
if (toGB(free) < RECOMMENDED_FREE_DISK_GB) {
return {
id: 'disk-free',
label: `Free disk space (${dir})`,
status: 'warn',
severity: 'recommended',
value: fmtGB(free),
hint: `Less than ${RECOMMENDED_FREE_DISK_GB} GB free; model downloads are typically multi-GB.`
}
}
return {
id: 'disk-free',
label: `Free disk space (${dir})`,
status: 'pass',
severity: 'recommended',
value: fmtGB(free)
}
}
Loading
Loading