Architecture
Monorepo layout
spindoctor is a pnpm-workspace monorepo with three packages:
apps/backend— Fastify + TypeScript. REST API, live events over for live progress, and (in production) static serving of the built SPA.apps/web— Vue 3 + Vite + TypeScript, using Vuetify for components and Pinia for state, fed by the backend's event stream.packages/shared— wire types (drive/run/stage views, verdict types, thresholds, events) imported by both sides. It's source-only: itspackage.jsonexportspoint straight at./src/index.tswith no build step, which resolves under Vite and Vitest but not bare Node — see thetsxruntime below for why that matters.
Backend layers
Under apps/backend/src/:
device/— a mockableDeviceApiwrapping the CLI tools the container ships (smartctl --json,badblocks,lsblk). The real implementation (RealDeviceApi) shells out vianode:child_process; nothing real ever runs in unit tests, which inject a fakeDeviceApiinstead.engine/— the durable state machine (TestEngine) that drives aTestRunthrough the ordered regime stages (see How it works), persists every transition, and emitsrun:update/stage:progressevents. Reconciles interrupted runs on startup — a self-test resumes by polling, a killed surface stage restarts.AutoModePollersits alongside it, polling drive discovery on an interval and enqueuing destructive runs when auto-mode is on.safety/—checkRunAllowed, the single guard function consulted by every start path, destructive or read-only (manual API route, the resume path, the pre-write re-check, and the auto-mode poller) — see Safety.verdict/—evaluateVerdict, a pure function: before/after SMART metrics + self-test result + surface result + thresholds → PASS/WARN/FAIL with structured reasons. No I/O, heavily table-tested.api/— Fastify route registration (drives,runs,settings,audit,diagnostics) plus a uniform JSON error shape, andrealtime.ts, which attaches Socket.IO for live run/stage events.db/— SQLite via Drizzle ORM +better-sqlite3; repositories for drives, runs, stage results, SMART snapshots, config, and audit log.
Everything is keyed on the drive's serial number — device paths (/dev/sdX) are treated as transient and re-resolved by serial after any long-running stage, since a device node can be reassigned or reused across a multi-hour regime.
Live progress
The web UI doesn't poll. apps/backend/src/api/realtime.ts attaches Socket.IO to Fastify's raw HTTP server and bridges TestEngine's run:update and stage:progress events straight onto the wire; the Pinia store in apps/web subscribes once and updates reactively as stages progress, self-tests tick forward, and verdicts land.
The tsx runtime
The backend runs under tsx (not a bundler) in both development and production — tsx src/main.ts is the literal start command in the Docker image, not just a dev convenience. This is a deliberate, non-default choice: bundling the backend would break @spindoctor/shared's source-only .ts exports (which resolve fine under tsx's on-the-fly transpilation, but not through a bundler that expects .js) and the Drizzle migrations path the DB client resolves relative to its own module. Running from source under tsx in production too keeps dev and prod resolving both of those identically, at the cost of not having a compiled build artifact.
The Docker image
Multi-stage, Debian-slim based (node:22-bookworm-slim for both the build and runtime stages):
- Build stage — installs the full workspace, builds the
apps/webSPA, then produces a self-contained backend deploy directory viapnpm --filter @spindoctor/backend deploy --prod --legacy(source + Drizzle migrations + a productionnode_moduleswith@spindoctor/shared,tsx, andbetter-sqlite3resolved into it). - Runtime stage — the same slim base, with the CLI tools the device layer shells out to installed via
apt:smartmontools,e2fsprogs,nvme-cli,hdparm, andutil-linux(forlsblk).smartmontoolsspecifically comes frombookworm-backports: Debian bookworm ships 7.3, which predates NVMe self-test support, sosmartctl -t longagainst an NVMe drive there does nothing at all while still exiting 0 — leaving every NVMe run stuck at "self-test did not complete". The deploy output and the built SPA are copied in, and the container runsnode_modules/.bin/tsx src/main.tsas its entrypoint.
The container runs as root: smartctl/badblocks/hdparm need raw block-device access, which already requires the operator to grant --device/--cap-add/--privileged for the specific disks under test — see Install & run — so there's no meaningful non-root story without also granting those same capabilities.