diff --git a/packages/cli/README.md b/packages/cli/README.md index 6750b3a896..590f5fa080 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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) @@ -35,6 +37,56 @@ npx @qvac/cli ## 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). @@ -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:** diff --git a/packages/cli/src/doctor/check.ts b/packages/cli/src/doctor/check.ts new file mode 100644 index 0000000000..56ea56c559 --- /dev/null +++ b/packages/cli/src/doctor/check.ts @@ -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 + } +} diff --git a/packages/cli/src/doctor/checks/hardware.ts b/packages/cli/src/doctor/checks/hardware.ts new file mode 100644 index 0000000000..242cb5008d --- /dev/null +++ b/packages/cli/src/doctor/checks/hardware.ts @@ -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) + } +} diff --git a/packages/cli/src/doctor/checks/index.ts b/packages/cli/src/doctor/checks/index.ts new file mode 100644 index 0000000000..14de65a335 --- /dev/null +++ b/packages/cli/src/doctor/checks/index.ts @@ -0,0 +1,61 @@ +import type { CheckContext } from '../check.js' +import { createDefaultContext } from '../check.js' +import type { CheckSection } from '../types.js' +import { checkNodeVersion, checkCliHost } from './runtime.js' +import { checkTotalMemory, checkAvailableMemory, checkGpuAcceleration, checkFreeDiskSpace } from './hardware.js' +import { checkDesktopTargets, checkAndroidTarget, checkIosTarget } from './targets.js' +import { checkFfmpeg, checkBareRuntime, checkBun } from './tools.js' +import { checkSdkInstalled } from './project.js' + +export type { Check, CheckContext, ProbeFn, ProbeResult } from '../check.js' +export { createDefaultContext, probeBinary } from '../check.js' +export { checkNodeVersion, checkCliHost } from './runtime.js' +export { checkTotalMemory, checkAvailableMemory, checkGpuAcceleration, checkFreeDiskSpace } from './hardware.js' +export { checkDesktopTargets, checkAndroidTarget, checkIosTarget } from './targets.js' +export { checkFfmpeg, checkBareRuntime, checkBun } from './tools.js' +export { checkSdkInstalled } from './project.js' + +export interface CollectChecksOptions { + context?: CheckContext | undefined + projectRoot?: string | undefined +} + +export function collectCheckSections (options: CollectChecksOptions = {}): CheckSection[] { + const ctx = options.context ?? createDefaultContext(options.projectRoot ?? process.cwd()) + return [ + { + id: 'runtime', + title: 'Runtime', + checks: [checkNodeVersion(ctx), checkCliHost(ctx)] + }, + { + id: 'hardware', + title: 'Hardware', + checks: [checkTotalMemory(ctx), checkAvailableMemory(ctx), checkGpuAcceleration(ctx), checkFreeDiskSpace(ctx)] + }, + { + id: 'targets', + title: 'Deploy targets (SDK)', + checks: [checkDesktopTargets(ctx), checkAndroidTarget(ctx), checkIosTarget(ctx)] + }, + { + id: 'tools', + title: 'Optional tools', + checks: [checkFfmpeg(ctx), checkBareRuntime(ctx), checkBun(ctx)] + }, + { + id: 'project', + title: 'Project', + checks: [checkSdkInstalled(ctx)] + } + ] +} + +export function isReportOk (sections: CheckSection[]): boolean { + for (const section of sections) { + for (const check of section.checks) { + if (check.status === 'fail') return false + } + } + return true +} diff --git a/packages/cli/src/doctor/checks/project.ts b/packages/cli/src/doctor/checks/project.ts new file mode 100644 index 0000000000..5b08b33eb0 --- /dev/null +++ b/packages/cli/src/doctor/checks/project.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' +import { DEFAULT_SDK_NAME } from '../../bundle-sdk/constants.js' +import type { Check } from '../check.js' + +// Locate @qvac/sdk the same way a consumer project's runtime would, so +// we correctly find the package whether installed locally, hoisted in a +// monorepo, or linked via workspaces. +function resolveSdkPackageJson (projectRoot: string): string | null { + try { + const req = createRequire(path.join(projectRoot, 'package.json')) + for (const spec of [`${DEFAULT_SDK_NAME}/package.json`, `${DEFAULT_SDK_NAME}/package`]) { + try { + return req.resolve(spec) + } catch { + // try the next spec + } + } + return null + } catch { + return null + } +} + +export const checkSdkInstalled: Check = (ctx) => { + const projectRoot = ctx.projectRoot + const pkgPath = resolveSdkPackageJson(projectRoot) + if (pkgPath === null) { + return { + id: 'project-sdk', + label: `${DEFAULT_SDK_NAME} resolvable from project`, + status: 'warn', + severity: 'recommended', + value: 'not found', + hint: `Run 'npm install ${DEFAULT_SDK_NAME}' in ${projectRoot} to install the SDK.` + } + } + try { + const raw = fs.readFileSync(pkgPath, 'utf8') + const pkg = JSON.parse(raw) as { version?: string } + return { + id: 'project-sdk', + label: `${DEFAULT_SDK_NAME} resolvable from project`, + status: 'pass', + severity: 'recommended', + value: pkg.version !== undefined ? `v${pkg.version}` : 'installed' + } + } catch { + return { + id: 'project-sdk', + label: `${DEFAULT_SDK_NAME} resolvable from project`, + status: 'warn', + severity: 'recommended', + value: 'unreadable', + hint: `Found ${pkgPath} but could not read its version.` + } + } +} diff --git a/packages/cli/src/doctor/checks/runtime.ts b/packages/cli/src/doctor/checks/runtime.ts new file mode 100644 index 0000000000..fde23c1422 --- /dev/null +++ b/packages/cli/src/doctor/checks/runtime.ts @@ -0,0 +1,85 @@ +import type { Check } from '../check.js' + +const MIN_NODE_MAJOR = 18 +const RECOMMENDED_NODE_MAJOR = 20 + +// Where the `qvac` CLI itself can run. This is NOT the set of SDK deploy +// targets — the SDK additionally targets Android and iOS via Expo/BareKit, +// which are reported in the "Deploy targets" section. +const SUPPORTED_CLI_HOSTS: ReadonlyArray = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-x64' +] + +function parseNodeMajor (version: string): number | null { + const match = /^v?(\d+)\./.exec(version) + if (!match || match[1] === undefined) return null + const n = Number.parseInt(match[1], 10) + return Number.isFinite(n) ? n : null +} + +export const checkNodeVersion: Check = (ctx) => { + const version = ctx.nodeVersion + const major = parseNodeMajor(version) + if (major === null) { + return { + id: 'node-version', + label: 'Node.js version', + status: 'warn', + severity: 'required', + value: version, + hint: `Could not parse Node.js version; expected v${MIN_NODE_MAJOR} or newer.` + } + } + if (major < MIN_NODE_MAJOR) { + return { + id: 'node-version', + label: 'Node.js version', + status: 'fail', + severity: 'required', + value: `v${version}`, + hint: `Upgrade Node.js to v${MIN_NODE_MAJOR} or newer (current: v${version}).` + } + } + if (major < RECOMMENDED_NODE_MAJOR) { + return { + id: 'node-version', + label: 'Node.js version', + status: 'warn', + severity: 'required', + value: `v${version}`, + hint: `Node.js v${MIN_NODE_MAJOR} is supported but end-of-life; upgrade to v${RECOMMENDED_NODE_MAJOR}+ when possible.` + } + } + return { + id: 'node-version', + label: 'Node.js version', + status: 'pass', + severity: 'required', + value: `v${version}` + } +} + +export const checkCliHost: Check = (ctx) => { + const host = `${ctx.platform}-${ctx.arch}` + if (SUPPORTED_CLI_HOSTS.includes(host)) { + return { + id: 'cli-host', + label: 'CLI host', + status: 'pass', + severity: 'required', + value: host + } + } + return { + id: 'cli-host', + label: 'CLI host', + status: 'fail', + severity: 'required', + value: host, + hint: `The 'qvac' CLI cannot run on "${host}". Supported CLI hosts: ${SUPPORTED_CLI_HOSTS.join(', ')}. (Android/iOS are supported as SDK deploy targets, not as CLI hosts.)` + } +} diff --git a/packages/cli/src/doctor/checks/targets.ts b/packages/cli/src/doctor/checks/targets.ts new file mode 100644 index 0000000000..f9fc371839 --- /dev/null +++ b/packages/cli/src/doctor/checks/targets.ts @@ -0,0 +1,77 @@ +import { DEFAULT_HOSTS } from '../../bundle-sdk/constants.js' +import type { Check } from '../check.js' + +// Deploy targets — the full matrix of platforms the SDK can deploy to, +// which is a superset of CLI hosts (adds Android + iOS via BareKit). +// Informational by default: bare-pack ships prebuilts for every target, +// so bundling is always available. What's checked here is the host +// toolchain needed to *deploy* to each target class. + +function desktopTargetsLine (hostPlatform: NodeJS.Platform, hostArch: string): string { + const nativeHost = `${hostPlatform}-${hostArch}` + const desktops = DEFAULT_HOSTS.filter((h) => !h.startsWith('android') && !h.startsWith('ios')) + return desktops.map((h) => (h === nativeHost ? `${h} (native)` : h)).join(', ') +} + +export const checkDesktopTargets: Check = (ctx) => { + return { + id: 'target-desktop', + label: 'Desktop', + status: 'pass', + severity: 'informational', + value: desktopTargetsLine(ctx.platform, ctx.arch), + hint: 'bare-pack ships prebuilts for every desktop target; cross-bundling is always available.' + } +} + +export const checkAndroidTarget: Check = (ctx) => { + const r = ctx.probe('adb', ['--version']) + if (!r.ok) { + return { + id: 'target-android', + label: 'Android (android-arm64)', + status: 'warn', + severity: 'recommended', + value: 'adb not found', + hint: 'Install Android platform tools to deploy QVAC apps to Android devices: https://developer.android.com/tools/releases/platform-tools' + } + } + return { + id: 'target-android', + label: 'Android (android-arm64)', + status: 'pass', + severity: 'recommended', + value: r.version ?? 'adb installed' + } +} + +export const checkIosTarget: Check = (ctx) => { + if (ctx.platform !== 'darwin') { + return { + id: 'target-ios', + label: 'iOS (ios-arm64 + simulators)', + status: 'info', + severity: 'informational', + value: 'requires macOS host', + hint: 'iOS apps can only be built/deployed from a macOS host with Xcode installed.' + } + } + const r = ctx.probe('xcodebuild', ['-version']) + if (!r.ok) { + return { + id: 'target-ios', + label: 'iOS (ios-arm64 + simulators)', + status: 'warn', + severity: 'recommended', + value: 'Xcode not found', + hint: 'Install Xcode from the App Store (Command Line Tools alone are not sufficient for iOS builds).' + } + } + return { + id: 'target-ios', + label: 'iOS (ios-arm64 + simulators)', + status: 'pass', + severity: 'recommended', + value: r.version ?? 'Xcode installed' + } +} diff --git a/packages/cli/src/doctor/checks/tools.ts b/packages/cli/src/doctor/checks/tools.ts new file mode 100644 index 0000000000..e109b0a6b2 --- /dev/null +++ b/packages/cli/src/doctor/checks/tools.ts @@ -0,0 +1,64 @@ +import type { Check } from '../check.js' + +export const checkFfmpeg: Check = (ctx) => { + const r = ctx.probe('ffmpeg', ['-version']) + if (!r.ok) { + return { + id: 'tool-ffmpeg', + label: 'ffmpeg', + status: 'warn', + severity: 'recommended', + value: 'not found', + hint: 'Install ffmpeg to use transcription / microphone examples (https://ffmpeg.org/download.html).' + } + } + return { + id: 'tool-ffmpeg', + label: 'ffmpeg', + status: 'pass', + severity: 'recommended', + value: r.version ?? 'installed' + } +} + +export const checkBareRuntime: Check = (ctx) => { + const r = ctx.probe('bare', ['--version']) + if (!r.ok) { + return { + id: 'tool-bare', + label: 'Bare runtime', + status: 'warn', + severity: 'recommended', + value: 'not found', + hint: 'Install bare-runtime only if you target the Bare runtime directly (npm i -g bare-runtime).' + } + } + return { + id: 'tool-bare', + label: 'Bare runtime', + status: 'pass', + severity: 'recommended', + value: r.version ?? 'installed' + } +} + +export const checkBun: Check = (ctx) => { + const r = ctx.probe('bun', ['--version']) + if (!r.ok) { + return { + id: 'tool-bun', + label: 'Bun', + status: 'warn', + severity: 'recommended', + value: 'not found', + hint: 'Install Bun only if you build the SDK from source (https://bun.sh).' + } + } + return { + id: 'tool-bun', + label: 'Bun', + status: 'pass', + severity: 'recommended', + value: r.version ?? 'installed' + } +} diff --git a/packages/cli/src/doctor/format.ts b/packages/cli/src/doctor/format.ts new file mode 100644 index 0000000000..ce7cf19975 --- /dev/null +++ b/packages/cli/src/doctor/format.ts @@ -0,0 +1,48 @@ +import type { CheckResult, CheckStatus, DoctorReport } from './types.js' + +const STATUS_ICON: Record = { + pass: '✅', + warn: '⚠️ ', + fail: '❌', + skip: '•', + info: 'ℹ️ ' +} + +function formatCheckLine (check: CheckResult): string { + const icon = STATUS_ICON[check.status] + const value = check.value ? ` — ${check.value}` : '' + return ` ${icon} ${check.label}${value}` +} + +export function formatReport (report: DoctorReport): string { + const lines: string[] = [] + lines.push('🩺 QVAC doctor') + lines.push('') + lines.push( + ` Host: ${report.platform}-${report.arch}, Node ${report.nodeVersion}` + ) + lines.push('') + + for (const section of report.sections) { + lines.push(`${section.title}:`) + for (const check of section.checks) { + lines.push(formatCheckLine(check)) + if (check.status !== 'pass' && check.hint) { + lines.push(` ${check.hint}`) + } + } + lines.push('') + } + + if (report.ok) { + lines.push('✅ All required checks passed.') + } else { + lines.push('❌ One or more required checks failed. See hints above.') + } + + return lines.join('\n') +} + +export function formatJsonReport (report: DoctorReport): string { + return JSON.stringify(report, null, 2) +} diff --git a/packages/cli/src/doctor/index.ts b/packages/cli/src/doctor/index.ts new file mode 100644 index 0000000000..cc13d297a2 --- /dev/null +++ b/packages/cli/src/doctor/index.ts @@ -0,0 +1,28 @@ +import { collectCheckSections, isReportOk } from './checks/index.js' +import { formatJsonReport, formatReport } from './format.js' +import type { DoctorReport, RunDoctorOptions } from './types.js' + +export async function runDoctor ( + options: RunDoctorOptions = {} +): Promise { + const projectRoot = options.projectRoot ?? process.cwd() + const sections = collectCheckSections({ projectRoot }) + + const report: DoctorReport = { + ok: isReportOk(sections), + platform: process.platform, + arch: process.arch, + nodeVersion: process.versions.node, + sections + } + + if (options.json) { + process.stdout.write(`${formatJsonReport(report)}\n`) + } else if (!options.quiet) { + process.stdout.write(`${formatReport(report)}\n`) + } + + return report +} + +export type { DoctorReport, RunDoctorOptions } from './types.js' diff --git a/packages/cli/src/doctor/types.ts b/packages/cli/src/doctor/types.ts new file mode 100644 index 0000000000..365189ac69 --- /dev/null +++ b/packages/cli/src/doctor/types.ts @@ -0,0 +1,34 @@ +export type CheckStatus = 'pass' | 'warn' | 'fail' | 'skip' | 'info' + +export type CheckSeverity = 'required' | 'recommended' | 'informational' + +export interface CheckResult { + id: string + label: string + status: CheckStatus + severity: CheckSeverity + value?: string + detail?: string + hint?: string +} + +export interface CheckSection { + id: 'runtime' | 'hardware' | 'targets' | 'tools' | 'project' + title: string + checks: CheckResult[] +} + +export interface DoctorReport { + ok: boolean + platform: string + arch: string + nodeVersion: string + sections: CheckSection[] +} + +export interface RunDoctorOptions { + projectRoot?: string | undefined + json?: boolean | undefined + quiet?: boolean | undefined + verbose?: boolean | undefined +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c0194ea595..64094dd7de 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -57,6 +57,32 @@ function setupCli (): void { } }) + program + .command('doctor') + .description('Validate that the host satisfies QVAC SDK system requirements') + .option('--json', 'Output the report as JSON') + .option('-q, --quiet', 'Suppress human-readable output (only set exit code)') + .option('-v, --verbose', 'Detailed output') + .action(async (options: { + json?: boolean + quiet?: boolean + verbose?: boolean + }) => { + try { + const { runDoctor } = await import('./doctor/index.js') + const report = await runDoctor({ + projectRoot: process.cwd(), + json: options.json, + quiet: options.quiet, + verbose: options.verbose + }) + if (!report.ok) process.exit(1) + } catch (error: unknown) { + handleError(error) + process.exit(1) + } + }) + const serveCmd = program .command('serve') .description('Start an API server backed by QVAC') diff --git a/packages/cli/system-requirements.md b/packages/cli/system-requirements.md new file mode 100644 index 0000000000..89213ad2c8 --- /dev/null +++ b/packages/cli/system-requirements.md @@ -0,0 +1,113 @@ +# QVAC system requirements + +Minimum host requirements for running `@qvac/sdk` and `@qvac/cli`. You can +validate your environment against this list with: + +```bash +qvac doctor +``` + +Use `--json` for machine-readable output and `--quiet` to only set the exit +code (`0` when all required checks pass, `1` otherwise). + +## Scope: CLI host vs. SDK deploy targets + +The `qvac` CLI itself runs on desktops only. The QVAC SDK, however, ships +to a broader set of **deploy targets** via BareKit/Expo — including Android +and iOS. `qvac doctor` reports both, in two distinct sections of its output: + +- **Runtime → CLI host** — where the `qvac` command can execute. Desktop + platforms only; this is a `fail` if your shell isn't on a supported host. +- **Deploy targets (SDK)** — the full set of platforms your SDK + applications can target. Android and iOS appear here, with host + toolchain checks (`adb`, `xcodebuild`) that indicate whether you can + actually deploy to those targets from this machine. + +## Required + +| Requirement | Notes | +|---|---| +| Node.js `>= 18.0.0` | Node 18 is end-of-life; prefer `>= 20`. Matches `engines.node`. | +| Supported CLI host | `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `win32-x64`. The `qvac` CLI cannot run on mobile; those are deploy targets only. | +| Total RAM `>= 2 GB` (recommended `>= 4 GB`) | Below 4 GB, most LLMs will fail to load. | + +## Recommended + +| Requirement | When it is needed | +|---|---| +| Available RAM `>= 2 GB` | Needed when loading a model. Checked via `os.availableMemory()` on Node 22+, falling back to `os.freemem()` on older Nodes (freemem is known to under-report on Linux/macOS). | +| GPU acceleration (Metal on macOS, Vulkan on Linux/Windows) | QVAC inference backends use Metal (always present on macOS) or Vulkan (`vulkaninfo --summary`) on Linux/Windows. Without a Vulkan ICD, LLM and Whisper inference fall back to CPU and are significantly slower. | +| Free disk `>= 5 GB` in the working directory | Model artifacts are typically multi-GB per model. Uses `fs.statfsSync` (Node 18.15+) with a POSIX `df` fallback. | + +## Deploy targets + +These checks are informational/recommended — they never cause `qvac doctor` +to exit non-zero, because bundling for any target is always supported via +bare-pack's prebuilt binaries. What's checked here is the host toolchain +needed to install/deploy to each target class. + +| Target | Check | Status when missing | +|---|---|---| +| `darwin-{arm64,x64}`, `linux-{arm64,x64}`, `win32-x64` | Listed under the "Desktop" row; native host flagged with `(native)`. | Always `pass` — cross-bundling is built in. | +| `android-arm64` | `adb --version` | `warn` — install [Android platform tools](https://developer.android.com/tools/releases/platform-tools) to deploy to devices. | +| `ios-arm64` + simulators | `xcodebuild -version` (macOS only) | `warn` on macOS without Xcode, `info` on non-macOS hosts (iOS builds require a macOS host). | + +## Optional tools + +Only required if you use the corresponding feature. The checker warns when +they are missing but does not fail. + +| Tool | Required for | +|---|---| +| `ffmpeg` | Microphone capture, transcription examples, and the built-in audio decoder. Install from [ffmpeg.org](https://ffmpeg.org/download.html). | +| [Bare](https://bare.pears.com) runtime | Running the SDK under Bare directly (Node and Bun are supported out of the box). | +| [Bun](https://bun.sh) | Building the SDK from source or running the monorepo development workflow. | + +## Project + +| Check | Notes | +|---|---| +| `@qvac/sdk` resolvable from project | Resolved with `require.resolve('@qvac/sdk/package.json')` rooted at the working directory, so hoisted installs (monorepos, Yarn/Bun workspaces) are correctly detected. | + +## Exit codes + +- `0` — all required checks passed. Warnings, skips, and informational + rows may still be present. +- `1` — one or more required checks failed (unsupported Node version, + unsupported CLI host, insufficient total RAM, …). See the printed hints + for remediation steps. + +## JSON schema + +```ts +interface DoctorReport { + ok: boolean; + platform: string; // e.g. "darwin" + arch: string; // e.g. "arm64" + nodeVersion: string; // e.g. "20.19.5" + sections: Array<{ + id: 'runtime' | 'hardware' | 'targets' | 'tools' | 'project'; + title: string; + checks: Array<{ + id: string; + label: string; + status: 'pass' | 'warn' | 'fail' | 'skip' | 'info'; + severity: 'required' | 'recommended' | 'informational'; + value?: string; + detail?: string; + hint?: string; // typically present for any non-pass result + }>; + }>; +} +``` + +### Status semantics + +- `pass` — check ran and the requirement is satisfied. +- `warn` — recommended requirement not met, or a deploy-target toolchain + is missing; does not cause a non-zero exit. +- `fail` — required check not met; causes exit code `1`. +- `skip` — the check could not be executed on this host (missing Node API + and no fallback, etc.). +- `info` — informational row with no pass/fail judgment (e.g. iOS deploy + target on a non-macOS host). diff --git a/packages/cli/test/cli.bats b/packages/cli/test/cli.bats index ef86f4e40a..0ec62221f5 100755 --- a/packages/cli/test/cli.bats +++ b/packages/cli/test/cli.bats @@ -94,6 +94,22 @@ http_status() { [[ "${output}" =~ "--sdk-path" ]] } +# ── CLI: doctor ─────────────────────────────────────────────────────── + +@test "qvac doctor --help shows options" { + run ${QVAC} doctor --help + [[ "${status}" -eq 0 ]] + [[ "${output}" =~ "--json" ]] + [[ "${output}" =~ "QVAC SDK system requirements" ]] +} + +@test "qvac doctor --json emits valid JSON with ok boolean" { + run ${QVAC} doctor --json + [[ "${status}" -eq 0 || "${status}" -eq 1 ]] + echo "${output}" | jq -e '.ok | type == "boolean"' >/dev/null + echo "${output}" | jq -e '.sections | length >= 1' >/dev/null +} + # ── CLI: error handling ─────────────────────────────────────────────── @test "cli: missing config file exits 1" { diff --git a/packages/cli/test/doctor.test.ts b/packages/cli/test/doctor.test.ts new file mode 100644 index 0000000000..bba6ca3802 --- /dev/null +++ b/packages/cli/test/doctor.test.ts @@ -0,0 +1,410 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import fs from 'node:fs' +import os from 'node:os' +import { + checkNodeVersion, + checkCliHost, + checkTotalMemory, + checkAvailableMemory, + checkGpuAcceleration, + checkFreeDiskSpace, + checkFfmpeg, + checkBareRuntime, + checkBun, + checkDesktopTargets, + checkAndroidTarget, + checkIosTarget, + checkSdkInstalled, + collectCheckSections, + createDefaultContext, + isReportOk +} from '../src/doctor/checks/index.js' +import type { CheckContext } from '../src/doctor/checks/index.js' + +// Build a CheckContext with a minimal, deterministic baseline and spread +// per-test overrides on top. Keeps each test assertion about a single +// variable rather than mocking the whole host. +function makeCtx (overrides: Partial = {}): CheckContext { + return { + projectRoot: process.cwd(), + platform: 'linux', + arch: 'x64', + nodeVersion: '20.11.0', + totalMemoryBytes: 8 * 1024 ** 3, + availableMemoryBytes: 4 * 1024 ** 3, + probe: () => ({ ok: false }), + ...overrides + } +} + +describe('checkNodeVersion', () => { + it('fails on Node < 18', () => { + const r = checkNodeVersion(makeCtx({ nodeVersion: '16.20.0' })) + assert.equal(r.status, 'fail') + assert.equal(r.severity, 'required') + }) + + it('warns on Node 18 (EOL but supported)', () => { + const r = checkNodeVersion(makeCtx({ nodeVersion: '18.19.0' })) + assert.equal(r.status, 'warn') + }) + + it('warns on Node 19 (below recommended)', () => { + const r = checkNodeVersion(makeCtx({ nodeVersion: '19.9.0' })) + assert.equal(r.status, 'warn') + }) + + it('passes on Node 20+', () => { + const r = checkNodeVersion(makeCtx({ nodeVersion: '20.11.0' })) + assert.equal(r.status, 'pass') + }) + + it('handles v-prefixed versions', () => { + const r = checkNodeVersion(makeCtx({ nodeVersion: 'v22.1.0' })) + assert.equal(r.status, 'pass') + }) + + it('warns when version cannot be parsed', () => { + const r = checkNodeVersion(makeCtx({ nodeVersion: 'nightly' })) + assert.equal(r.status, 'warn') + }) +}) + +describe('checkCliHost', () => { + it('passes on darwin-arm64', () => { + const r = checkCliHost(makeCtx({ platform: 'darwin', arch: 'arm64' })) + assert.equal(r.status, 'pass') + assert.equal(r.value, 'darwin-arm64') + }) + + it('passes on linux-x64', () => { + const r = checkCliHost(makeCtx({ platform: 'linux', arch: 'x64' })) + assert.equal(r.status, 'pass') + }) + + it('passes on win32-x64', () => { + const r = checkCliHost(makeCtx({ platform: 'win32', arch: 'x64' })) + assert.equal(r.status, 'pass') + }) + + it('fails on unsupported CLI hosts', () => { + const r = checkCliHost(makeCtx({ platform: 'freebsd' as NodeJS.Platform, arch: 'x64' })) + assert.equal(r.status, 'fail') + }) + + it('fails on win32-arm64 (not in CLI host matrix)', () => { + const r = checkCliHost(makeCtx({ platform: 'win32', arch: 'arm64' })) + assert.equal(r.status, 'fail') + }) + + it('fail hint clarifies that mobile is a deploy target, not a CLI host', () => { + const r = checkCliHost(makeCtx({ platform: 'android' as NodeJS.Platform, arch: 'arm64' })) + assert.equal(r.status, 'fail') + assert.ok(r.hint && /deploy target/i.test(r.hint)) + }) +}) + +describe('checkTotalMemory', () => { + it('fails when total RAM is below the hard minimum', () => { + const r = checkTotalMemory(makeCtx({ totalMemoryBytes: 1 * 1024 ** 3 })) + assert.equal(r.status, 'fail') + }) + + it('warns when total RAM is below recommended', () => { + const r = checkTotalMemory(makeCtx({ totalMemoryBytes: 3 * 1024 ** 3 })) + assert.equal(r.status, 'warn') + }) + + it('passes when total RAM meets recommended', () => { + const r = checkTotalMemory(makeCtx({ totalMemoryBytes: 8 * 1024 ** 3 })) + assert.equal(r.status, 'pass') + }) + + it("reports severity 'required' across fail/warn/pass branches (severity describes the check, not the outcome)", () => { + assert.equal(checkTotalMemory(makeCtx({ totalMemoryBytes: 1 * 1024 ** 3 })).severity, 'required') + assert.equal(checkTotalMemory(makeCtx({ totalMemoryBytes: 3 * 1024 ** 3 })).severity, 'required') + assert.equal(checkTotalMemory(makeCtx({ totalMemoryBytes: 8 * 1024 ** 3 })).severity, 'required') + }) +}) + +describe('checkAvailableMemory', () => { + it('warns when available RAM is below recommended', () => { + const r = checkAvailableMemory(makeCtx({ availableMemoryBytes: 1 * 1024 ** 3 })) + assert.equal(r.status, 'warn') + assert.equal(r.label, 'Available RAM') + }) + + it('passes when available RAM is above recommended', () => { + const r = checkAvailableMemory(makeCtx({ availableMemoryBytes: 4 * 1024 ** 3 })) + assert.equal(r.status, 'pass') + }) +}) + +describe('checkGpuAcceleration', () => { + it('passes with Metal on darwin (always available)', () => { + const r = checkGpuAcceleration(makeCtx({ platform: 'darwin', probe: () => ({ ok: false }) })) + assert.equal(r.status, 'pass') + assert.ok(r.value && r.value.toLowerCase().includes('metal')) + }) + + it('warns on linux when vulkaninfo is missing', () => { + const r = checkGpuAcceleration(makeCtx({ platform: 'linux', probe: () => ({ ok: false }) })) + assert.equal(r.status, 'warn') + assert.equal(r.severity, 'recommended') + assert.equal(r.value, 'Vulkan ICD not found') + assert.ok(r.hint && /vulkan-tools|libvulkan/i.test(r.hint)) + }) + + it('warns on win32 when vulkaninfo is missing (with Windows-specific hint)', () => { + const r = checkGpuAcceleration(makeCtx({ platform: 'win32', probe: () => ({ ok: false }) })) + assert.equal(r.status, 'warn') + assert.ok(r.hint && /vulkan sdk|GPU drivers/i.test(r.hint)) + }) + + it('passes on linux with a Vulkan ICD, extracting device names', () => { + const stdout = [ + 'VULKANINFO', + 'Vulkan Instance Version: 1.3.268', + '', + 'GPUs:', + '=====', + 'GPU0:', + '\tapiVersion = 1.3.268', + '\tdeviceName = NVIDIA GeForce RTX 3080', + '\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU' + ].join('\n') + const r = checkGpuAcceleration(makeCtx({ + platform: 'linux', + probe: () => ({ ok: true, stdout }) + })) + assert.equal(r.status, 'pass') + assert.ok(r.value && r.value.includes('NVIDIA GeForce RTX 3080')) + }) + + it('passes on linux but hints when vulkaninfo reports no devices', () => { + const r = checkGpuAcceleration(makeCtx({ + platform: 'linux', + probe: () => ({ ok: true, stdout: 'VULKANINFO\n' }) + })) + assert.equal(r.status, 'pass') + assert.ok(r.hint && /no GPU devices/i.test(r.hint)) + }) + + it('is informational on unknown platforms', () => { + const r = checkGpuAcceleration(makeCtx({ platform: 'freebsd' as NodeJS.Platform, probe: () => ({ ok: false }) })) + assert.equal(r.status, 'info') + assert.equal(r.severity, 'informational') + }) +}) + +describe('checkFreeDiskSpace', () => { + it('returns a result for the current working directory', () => { + const r = checkFreeDiskSpace(makeCtx({ projectRoot: process.cwd() })) + assert.ok(['pass', 'warn', 'skip'].includes(r.status)) + assert.equal(r.severity, 'recommended') + }) +}) + +describe('optional tool probes', () => { + const probePresent = () => ({ ok: true, version: '1.2.3' }) + const probeMissing = () => ({ ok: false }) + + it('ffmpeg passes when probe reports installed', () => { + const r = checkFfmpeg(makeCtx({ probe: probePresent })) + assert.equal(r.status, 'pass') + assert.equal(r.value, '1.2.3') + }) + + it('ffmpeg warns when probe reports missing', () => { + const r = checkFfmpeg(makeCtx({ probe: probeMissing })) + assert.equal(r.status, 'warn') + assert.ok(r.hint && r.hint.includes('ffmpeg')) + }) + + it('Bare runtime warns when missing (recommended only)', () => { + const r = checkBareRuntime(makeCtx({ probe: probeMissing })) + assert.equal(r.status, 'warn') + assert.equal(r.severity, 'recommended') + }) + + it('Bun warns when missing (recommended only)', () => { + const r = checkBun(makeCtx({ probe: probeMissing })) + assert.equal(r.status, 'warn') + assert.equal(r.severity, 'recommended') + }) +}) + +describe('deploy-target checks', () => { + const probePresent = () => ({ ok: true, version: 'Xcode 15.2' }) + const probeMissing = () => ({ ok: false }) + + it('desktop targets lists the native host first-class with (native) suffix', () => { + const r = checkDesktopTargets(makeCtx({ platform: 'linux', arch: 'x64' })) + assert.equal(r.status, 'pass') + assert.ok(r.value && r.value.includes('linux-x64 (native)')) + assert.ok(r.value && r.value.includes('darwin-arm64')) + }) + + it('desktop targets still pass on non-desktop CLI hosts (bare-pack cross-bundles)', () => { + const r = checkDesktopTargets(makeCtx({ platform: 'freebsd' as NodeJS.Platform, arch: 'x64' })) + assert.equal(r.status, 'pass') + assert.ok(r.value && !r.value.includes('(native)')) + }) + + it('android warns when adb is missing', () => { + const r = checkAndroidTarget(makeCtx({ probe: probeMissing })) + assert.equal(r.status, 'warn') + assert.ok(r.hint && /platform-tools|adb/i.test(r.hint)) + }) + + it('android passes when adb is present', () => { + const r = checkAndroidTarget(makeCtx({ probe: probePresent })) + assert.equal(r.status, 'pass') + }) + + it('iOS is informational (not a warning) on non-darwin hosts', () => { + const r = checkIosTarget(makeCtx({ platform: 'linux', probe: probeMissing })) + assert.equal(r.status, 'info') + assert.equal(r.severity, 'informational') + }) + + it('iOS warns on darwin when Xcode is missing', () => { + const r = checkIosTarget(makeCtx({ platform: 'darwin', probe: probeMissing })) + assert.equal(r.status, 'warn') + }) + + it('iOS passes on darwin when Xcode is present', () => { + const r = checkIosTarget(makeCtx({ platform: 'darwin', probe: probePresent })) + assert.equal(r.status, 'pass') + assert.equal(r.value, 'Xcode 15.2') + }) +}) + +describe('checkSdkInstalled', () => { + it('warns when @qvac/sdk cannot be resolved from the project', () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qvac-check-')) + try { + const r = checkSdkInstalled(makeCtx({ projectRoot: emptyDir })) + assert.equal(r.status, 'warn') + assert.equal(r.value, 'not found') + } finally { + fs.rmSync(emptyDir, { recursive: true, force: true }) + } + }) + + it('passes when @qvac/sdk is directly installed in node_modules', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qvac-check-')) + const sdkDir = path.join(root, 'node_modules', '@qvac', 'sdk') + fs.mkdirSync(sdkDir, { recursive: true }) + fs.writeFileSync( + path.join(sdkDir, 'package.json'), + JSON.stringify({ name: '@qvac/sdk', version: '0.9.0', main: 'index.js' }) + ) + fs.writeFileSync(path.join(sdkDir, 'index.js'), 'module.exports = {}') + try { + const r = checkSdkInstalled(makeCtx({ projectRoot: root })) + assert.equal(r.status, 'pass') + assert.equal(r.value, 'v0.9.0') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it('passes when @qvac/sdk is hoisted to a parent node_modules (monorepo case)', () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qvac-check-')) + const hoistedSdk = path.join(workspaceRoot, 'node_modules', '@qvac', 'sdk') + fs.mkdirSync(hoistedSdk, { recursive: true }) + fs.writeFileSync( + path.join(hoistedSdk, 'package.json'), + JSON.stringify({ name: '@qvac/sdk', version: '0.9.1', main: 'index.js' }) + ) + fs.writeFileSync(path.join(hoistedSdk, 'index.js'), 'module.exports = {}') + + const nestedProject = path.join(workspaceRoot, 'packages', 'app') + fs.mkdirSync(nestedProject, { recursive: true }) + fs.writeFileSync(path.join(nestedProject, 'package.json'), JSON.stringify({ name: 'app' })) + try { + const r = checkSdkInstalled(makeCtx({ projectRoot: nestedProject })) + assert.equal(r.status, 'pass', `expected pass, got ${r.status} (value=${r.value ?? ''})`) + assert.equal(r.value, 'v0.9.1') + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }) + } + }) +}) + +describe('collectCheckSections + isReportOk', () => { + it('returns the expected section order and ids', () => { + const sections = collectCheckSections({ projectRoot: process.cwd() }) + assert.deepEqual( + sections.map((s) => s.id), + ['runtime', 'hardware', 'targets', 'tools', 'project'] + ) + }) + + it('includes RAM, GPU, and disk checks in the hardware section', () => { + const sections = collectCheckSections({ projectRoot: process.cwd() }) + const hardware = sections.find((s) => s.id === 'hardware') + assert.ok(hardware) + assert.deepEqual( + hardware.checks.map((c) => c.id), + ['memory-total', 'memory-available', 'gpu-acceleration', 'disk-free'] + ) + }) + + it('includes desktop, android, and ios in the targets section', () => { + const sections = collectCheckSections({ projectRoot: process.cwd() }) + const targets = sections.find((s) => s.id === 'targets') + assert.ok(targets) + assert.deepEqual( + targets.checks.map((c) => c.id), + ['target-desktop', 'target-android', 'target-ios'] + ) + }) + + it('accepts an explicit CheckContext override for deterministic test runs', () => { + const sections = collectCheckSections({ context: makeCtx({ probe: () => ({ ok: true, version: 'x' }) }) }) + const tools = sections.find((s) => s.id === 'tools') + assert.ok(tools) + assert.ok(tools.checks.every((c) => c.status === 'pass')) + }) + + it('createDefaultContext reflects the live host', () => { + const ctx = createDefaultContext(process.cwd()) + assert.equal(ctx.platform, process.platform) + assert.equal(ctx.arch, process.arch) + assert.equal(ctx.nodeVersion, process.versions.node) + assert.ok(ctx.totalMemoryBytes > 0) + }) + + it('isReportOk returns false when any check has failed', () => { + const sections = [ + { + id: 'runtime' as const, + title: 'Runtime', + checks: [ + { id: 'x', label: 'x', status: 'pass' as const, severity: 'required' as const }, + { id: 'y', label: 'y', status: 'fail' as const, severity: 'required' as const } + ] + } + ] + assert.equal(isReportOk(sections), false) + }) + + it('isReportOk returns true when only warnings/skips/info are present', () => { + const sections = [ + { + id: 'runtime' as const, + title: 'Runtime', + checks: [ + { id: 'x', label: 'x', status: 'warn' as const, severity: 'required' as const }, + { id: 'y', label: 'y', status: 'skip' as const, severity: 'recommended' as const }, + { id: 'z', label: 'z', status: 'info' as const, severity: 'informational' as const } + ] + } + ] + assert.equal(isReportOk(sections), true) + }) +})