From 1e0e32dd6393d57fc7b3c7cf9ed886b3daf59b92 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Fri, 3 Jul 2026 21:40:44 +0300 Subject: [PATCH] feat(framework): preflight checks + framework doctor Turnkey should fail early and clearly, not mid-run. A live run now verifies its prerequisites (Claude Code installed + runnable) before starting the dashboard and driver; if missing, it prints install guidance and exits instead of a cryptic spawn error deep in the flow. - preflight(): checks node + the agent CLI (injectable probe for tests) - framework doctor: reports the checks and exits by their outcome - --skip-preflight escape hatch; --fake never runs preflight (needs no CLI) 6 new tests (42 total); doctor smoke verified (node + claude versions); full typecheck + build clean. --- .changeset/framework-preflight.md | 10 ++++ packages/framework/src/cli.test.ts | 22 +++++++++ packages/framework/src/cli.ts | 31 ++++++++++++ packages/framework/src/index.ts | 7 +++ packages/framework/src/preflight.test.ts | 27 +++++++++++ packages/framework/src/preflight.ts | 62 ++++++++++++++++++++++++ 6 files changed, 159 insertions(+) create mode 100644 .changeset/framework-preflight.md create mode 100644 packages/framework/src/preflight.test.ts create mode 100644 packages/framework/src/preflight.ts diff --git a/.changeset/framework-preflight.md b/.changeset/framework-preflight.md new file mode 100644 index 0000000..6b55e24 --- /dev/null +++ b/.changeset/framework-preflight.md @@ -0,0 +1,10 @@ +--- +"@gemstack/framework": minor +--- + +feat: preflight checks + `framework doctor` + +A live run now checks its prerequisites first and fails early with a clear fix +("`claude` not found - install Claude Code ...") instead of a cryptic mid-run +spawn error. Adds a `framework doctor` command that reports the checks, and a +`--skip-preflight` escape hatch. `--fake` never runs preflight (it needs no CLI). diff --git a/packages/framework/src/cli.test.ts b/packages/framework/src/cli.test.ts index a4444f8..94e1318 100644 --- a/packages/framework/src/cli.test.ts +++ b/packages/framework/src/cli.test.ts @@ -59,6 +59,28 @@ test('runCli errors when --deploy dokploy lacks its config', async () => { assert.equal(code, 2) }) +test('parseArgs reads the doctor subcommand, not as intent', () => { + const opts = parseArgs(['doctor']) + assert.equal(opts.doctor, true) + assert.equal(opts.intent, '') +}) + +test('runCli doctor reports checks and exits by their outcome', async () => { + const { io, out } = capture() + const code = await runCli(['doctor'], io) + const text = out.join('\n') + assert.match(text, /node:/) + assert.match(text, /claude-code:/) + assert.ok(code === 0 || code === 1) // depends on whether claude is installed here +}) + +test('runCli --fake skips preflight (offline never needs the agent CLI)', async () => { + const { io } = capture() + // No claude probe is invoked for --fake; this must succeed regardless of env. + const code = await runCli(['--fake', '--no-dashboard'], io) + assert.equal(code, 0) +}) + test('runCli --fake --no-dashboard runs the whole flow offline to production-grade', async () => { const { io, out } = capture() const code = await runCli(['--fake', '--no-dashboard'], io) diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index f7b268c..8a91c27 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -7,6 +7,7 @@ import { startDashboard, type Dashboard } from './dashboard/index.js' import { formatFrameworkEvent, type FrameworkEvent } from './events.js' import { runFramework, type DeployDecision, type RunFrameworkOptions, type ServeConfig } from './run.js' import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' +import { preflight } from './preflight.js' /** Where the CLI writes. Injectable so tests capture output. */ export interface CliIO { @@ -26,6 +27,7 @@ const HELP = `The Framework — turnkey AI orchestration that wraps a coding age Usage: framework [intent...] Build what you describe, from scratch. framework --fake Run the offline demo (no CLI, no model, deterministic). + framework doctor Check prerequisites (Claude Code installed, etc.). Options: --fake Use the fake driver + scripted run (offline / CI). @@ -47,6 +49,7 @@ Options: --dokploy-app Dokploy application id (required for --deploy dokploy). --port Dashboard port (default: 4477). --no-dashboard Do not start the localhost dashboard. + --skip-preflight Skip the prerequisite checks before a live run. --session-link Link to the live agent session (shown on the dashboard). -h, --help Show this help. -v, --version Print the version. @@ -61,6 +64,8 @@ export interface CliOptions { help: boolean version: boolean fake: boolean + doctor: boolean + skipPreflight: boolean intent: string cwd?: string | undefined model?: string | undefined @@ -89,6 +94,8 @@ export function parseArgs(argv: string[]): CliOptions { help: false, version: false, fake: false, + doctor: false, + skipPreflight: false, intent: '', scope: 'full', dashboard: true, @@ -113,6 +120,9 @@ export function parseArgs(argv: string[]): CliOptions { case '--no-dashboard': opts.dashboard = false break + case '--skip-preflight': + opts.skipPreflight = true + break case '--dangerously-skip-permissions': opts.skipPermissions = true break @@ -187,6 +197,11 @@ export function parseArgs(argv: string[]): CliOptions { else words.push(arg) } } + // `framework doctor` is a subcommand, not an intent. + if (words[0] === 'doctor') { + opts.doctor = true + words.shift() + } opts.intent = words.join(' ').trim() return opts } @@ -211,6 +226,12 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise !c.ok)) io.err(`✗ ${check.name}: ${check.detail}`) + io.err('Preflight failed. Fix the above, or pass --skip-preflight, or try `framework --fake`.') + return 2 + } + } + const claudeOpts: ClaudeCodeDriverOptions = { ...(opts.permissionMode ? { permissionMode: opts.permissionMode } : {}), ...(opts.skipPermissions ? { dangerouslySkipPermissions: true } : {}), diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 4573b00..8631dfc 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -58,6 +58,13 @@ export { hostExecutor, type HostExecutorOptions } from './host-exec.js' export { type FrameworkEvent, formatFrameworkEvent } from './events.js' export { startDashboard, dashboardHtml, type Dashboard, type DashboardOptions } from './dashboard/index.js' export { runCli, parseArgs, buildDeployTarget, type CliIO, type CliOptions } from './cli.js' +export { + preflight, + type PreflightResult, + type PreflightCheck, + type PreflightOptions, + type VersionProbe, +} from './preflight.js' export { fakeDriver, FAKE_INTENT, diff --git a/packages/framework/src/preflight.test.ts b/packages/framework/src/preflight.test.ts new file mode 100644 index 0000000..9375927 --- /dev/null +++ b/packages/framework/src/preflight.test.ts @@ -0,0 +1,27 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { preflight } from './preflight.js' + +test('preflight passes when the agent CLI is present', async () => { + const result = await preflight({ probe: () => Promise.resolve({ ok: true, stdout: '1.2.3 (Claude Code)' }) }) + assert.equal(result.ok, true) + const cc = result.checks.find(c => c.name === 'claude-code') + assert.equal(cc?.ok, true) + assert.match(cc!.detail, /1\.2\.3/) +}) + +test('preflight fails with install guidance when the agent CLI is missing', async () => { + const result = await preflight({ bin: 'claude', probe: () => Promise.resolve({ ok: false, stdout: '' }) }) + assert.equal(result.ok, false) + const cc = result.checks.find(c => c.name === 'claude-code') + assert.equal(cc?.ok, false) + assert.match(cc!.detail, /not found/) + assert.match(cc!.detail, /claude\.com\/claude-code/) +}) + +test('preflight always reports the node version', async () => { + const result = await preflight({ probe: () => Promise.resolve({ ok: true, stdout: 'x' }) }) + const node = result.checks.find(c => c.name === 'node') + assert.equal(node?.ok, true) + assert.equal(node?.detail, process.version) +}) diff --git a/packages/framework/src/preflight.ts b/packages/framework/src/preflight.ts new file mode 100644 index 0000000..770185c --- /dev/null +++ b/packages/framework/src/preflight.ts @@ -0,0 +1,62 @@ +import { execFile } from 'node:child_process' + +/** + * Preflight checks for a live run. A turnkey tool should fail *early and + * clearly* when a prerequisite is missing, not spawn a broken process mid-run. + * The main one: is the wrapped agent's CLI (Claude Code) actually installed and + * runnable? `--fake` needs none of this, so preflight only gates live runs. + */ + +/** One preflight check's outcome. */ +export interface PreflightCheck { + name: string + ok: boolean + /** Human-readable detail: the version when ok, or how to fix it when not. */ + detail: string +} + +/** The result of running all preflight checks. */ +export interface PreflightResult { + ok: boolean + checks: PreflightCheck[] +} + +/** Probe a binary for a version string. Injectable so tests need no real CLI. */ +export type VersionProbe = (bin: string) => Promise<{ ok: boolean; stdout: string }> + +const CLAUDE_INSTALL_HINT = 'install Claude Code and make sure `claude` is on your PATH: https://claude.com/claude-code' + +function defaultProbe(bin: string): Promise<{ ok: boolean; stdout: string }> { + return new Promise(resolvePromise => { + execFile(bin, ['--version'], { timeout: 10_000 }, (err, stdout) => { + resolvePromise({ ok: !err, stdout: String(stdout) }) + }) + }) +} + +/** Options for {@link preflight}. */ +export interface PreflightOptions { + /** The agent CLI binary to probe. Default `"claude"`. */ + bin?: string + /** Version probe override (tests). Default runs ` --version`. */ + probe?: VersionProbe +} + +/** + * Run the preflight checks: Node is implicit (we are running), and the wrapped + * agent CLI must be installed. Returns every check plus an overall `ok`. + */ +export async function preflight(opts: PreflightOptions = {}): Promise { + const bin = opts.bin ?? 'claude' + const probe = opts.probe ?? defaultProbe + const checks: PreflightCheck[] = [{ name: 'node', ok: true, detail: process.version }] + + const cc = await probe(bin) + checks.push( + cc.ok + ? { name: 'claude-code', ok: true, detail: cc.stdout.trim() || 'installed' } + : { name: 'claude-code', ok: false, detail: `\`${bin}\` not found — ${CLAUDE_INSTALL_HINT}` }, + ) + + return { ok: checks.every(c => c.ok), checks } +}