diff --git a/setup/verify-state.test.ts b/setup/verify-state.test.ts new file mode 100644 index 0000000..24a7417 --- /dev/null +++ b/setup/verify-state.test.ts @@ -0,0 +1,131 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Database } from 'bun:sqlite'; + +import type { ServiceDef } from './service-defs.js'; +import type { ServiceCheck } from './verify-services.js'; +import { + buildVerifySummary, + detectChannelAuth, + detectCredentials, + loadRegisteredGroupsSummary, +} from './verify-state.js'; + +describe('verify state helpers', () => { + const tempRoots: string[] = []; + + afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('detects configured credentials from .env', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); + tempRoots.push(tempRoot); + fs.writeFileSync( + path.join(tempRoot, '.env'), + 'CLAUDE_CODE_OAUTH_TOKEN=test-token\n', + ); + + expect(detectCredentials(tempRoot)).toBe('configured'); + }); + + it('detects channel auth from either env source', () => { + expect( + detectChannelAuth( + { DISCORD_BOT_TOKEN: '' }, + { DISCORD_BOT_TOKEN: 'discord-token' }, + ), + ).toEqual({ + discord: 'configured', + }); + }); + + it('loads registered group counts from the sqlite store', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-')); + tempRoots.push(tempRoot); + const dbPath = path.join(tempRoot, 'messages.db'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE registered_groups ( + jid TEXT PRIMARY KEY, + agent_type TEXT + ); + `); + db.exec(` + INSERT INTO registered_groups (jid, agent_type) VALUES + ('group-1', 'claude-code'), + ('group-2', 'codex'), + ('group-3', 'codex'); + `); + db.close(); + + expect(loadRegisteredGroupsSummary(dbPath)).toEqual({ + registeredGroups: 3, + groupsByAgent: { + 'claude-code': 1, + codex: 2, + }, + }); + }); + + it('builds a successful verification summary when all gates pass', () => { + const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }]; + const serviceDefs: ServiceDef[] = [ + { + kind: 'primary', + name: 'ejclaw', + description: 'EJClaw', + launchdLabel: 'com.ejclaw', + logName: 'ejclaw', + }, + { + kind: 'codex', + name: 'ejclaw-codex', + description: 'Codex', + launchdLabel: 'com.ejclaw.codex', + logName: 'ejclaw-codex', + }, + ]; + + expect( + buildVerifySummary( + services, + serviceDefs, + 'configured', + { discord: 'configured' }, + 2, + { codex: 1 }, + ), + ).toMatchObject({ + status: 'success', + configuredChannels: ['discord'], + codexConfigured: true, + reviewConfigured: false, + servicesSummary: { ejclaw: 'running' }, + }); + }); + + it('fails verification when any required gate is missing', () => { + const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'stopped' }]; + + expect( + buildVerifySummary( + services, + [], + 'missing', + {}, + 0, + {}, + ), + ).toMatchObject({ + status: 'failed', + configuredChannels: [], + servicesSummary: { ejclaw: 'stopped' }, + }); + }); +}); diff --git a/setup/verify-state.ts b/setup/verify-state.ts new file mode 100644 index 0000000..0f3ac6f --- /dev/null +++ b/setup/verify-state.ts @@ -0,0 +1,133 @@ +import fs from 'fs'; +import path from 'path'; + +import { Database } from 'bun:sqlite'; + +import { STORE_DIR } from '../src/config.js'; +import { readEnvFile } from '../src/env.js'; +import type { ServiceDef } from './service-defs.js'; +import type { ServiceCheck } from './verify-services.js'; + +export type CredentialsStatus = 'configured' | 'missing'; +export type VerifyStatus = 'success' | 'failed'; + +export interface RegisteredGroupsSummary { + registeredGroups: number; + groupsByAgent: Record; +} + +export interface VerifySummary extends RegisteredGroupsSummary { + status: VerifyStatus; + servicesSummary: Record; + configuredChannels: string[]; + channelAuth: Record; + codexConfigured: boolean; + reviewConfigured: boolean; +} + +export function detectCredentials(projectRoot: string): CredentialsStatus { + const envFile = path.join(projectRoot, '.env'); + if (!fs.existsSync(envFile)) { + return 'missing'; + } + + const envContent = fs.readFileSync(envFile, 'utf-8'); + return /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(envContent) + ? 'configured' + : 'missing'; +} + +export function detectChannelAuth( + envVars = readEnvFile(['DISCORD_BOT_TOKEN']), + processEnv: NodeJS.ProcessEnv = process.env, +): Record { + const channelAuth: Record = {}; + + if (processEnv.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN) { + channelAuth.discord = 'configured'; + } + + return channelAuth; +} + +export function loadRegisteredGroupsSummary( + dbPath = path.join(STORE_DIR, 'messages.db'), +): RegisteredGroupsSummary { + let registeredGroups = 0; + const groupsByAgent: Record = {}; + + if (!fs.existsSync(dbPath)) { + return { registeredGroups, groupsByAgent }; + } + + try { + const db = new Database(dbPath, { readonly: true }); + const row = db + .prepare('SELECT COUNT(*) as count FROM registered_groups') + .get() as { count: number }; + registeredGroups = row.count; + + try { + const rows = db + .prepare( + 'SELECT agent_type, COUNT(*) as count FROM registered_groups GROUP BY agent_type', + ) + .all() as { agent_type: string; count: number }[]; + for (const current of rows) { + groupsByAgent[current.agent_type || 'unknown'] = current.count; + } + } catch { + // agent_type column might not exist in older schema + } + + db.close(); + } catch { + // Table might not exist + } + + return { registeredGroups, groupsByAgent }; +} + +export function buildVerifySummary( + services: ServiceCheck[], + serviceDefs: ServiceDef[], + credentials: CredentialsStatus, + channelAuth: Record, + registeredGroups: number, + groupsByAgent: Record, +): VerifySummary { + const configuredChannels = Object.keys(channelAuth); + const allConfiguredServicesRunning = services.every( + (service) => service.status === 'running', + ); + const codexConfigured = serviceDefs.some( + (service) => service.kind === 'codex', + ); + const reviewConfigured = serviceDefs.some( + (service) => service.kind === 'review', + ); + + const status = + allConfiguredServicesRunning && + credentials === 'configured' && + configuredChannels.length > 0 && + registeredGroups > 0 + ? 'success' + : 'failed'; + + const servicesSummary: Record = {}; + for (const service of services) { + servicesSummary[service.name] = service.status; + } + + return { + status, + servicesSummary, + configuredChannels, + channelAuth, + registeredGroups, + groupsByAgent, + codexConfigured, + reviewConfigured, + }; +} diff --git a/setup/verify.ts b/setup/verify.ts index fcd229c..d710a1b 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -9,17 +9,16 @@ * * Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks. */ -import fs from 'fs'; -import path from 'path'; - -import { Database } from 'bun:sqlite'; - -import { STORE_DIR } from '../src/config.js'; -import { readEnvFile } from '../src/env.js'; import { logger } from '../src/logger.js'; import { getServiceManager } from './platform.js'; import { getServiceDefs } from './service-defs.js'; import { emitStatus } from './status.js'; +import { + buildVerifySummary, + detectChannelAuth, + detectCredentials, + loadRegisteredGroupsSummary, +} from './verify-state.js'; import { getServiceChecks } from './verify-services.js'; /* ------------------------------------------------------------------ */ @@ -40,84 +39,23 @@ export async function run(_args: string[]): Promise { logger.info({ service: svc.name, status: svc.status }, 'Service status'); } - // 2. Check credentials - let credentials = 'missing'; - const envFile = path.join(projectRoot, '.env'); - if (fs.existsSync(envFile)) { - const envContent = fs.readFileSync(envFile, 'utf-8'); - if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(envContent)) { - credentials = 'configured'; - } - } - - // 3. Check channel auth (detect configured channels by credentials) - const envVars = readEnvFile(['DISCORD_BOT_TOKEN']); - - const channelAuth: Record = {}; - - if (process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN) { - channelAuth.discord = 'configured'; - } - - const configuredChannels = Object.keys(channelAuth); - const anyChannelConfigured = configuredChannels.length > 0; - - // 4. Check registered groups (using better-sqlite3, not sqlite3 CLI) - let registeredGroups = 0; - let groupsByAgent: Record = {}; - const dbPath = path.join(STORE_DIR, 'messages.db'); - if (fs.existsSync(dbPath)) { - try { - const db = new Database(dbPath, { readonly: true }); - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - registeredGroups = row.count; - - // Count by agent type - try { - const rows = db - .prepare( - 'SELECT agent_type, COUNT(*) as count FROM registered_groups GROUP BY agent_type', - ) - .all() as { agent_type: string; count: number }[]; - for (const r of rows) { - groupsByAgent[r.agent_type || 'unknown'] = r.count; - } - } catch { - // agent_type column might not exist in older schema - } - - db.close(); - } catch { - // Table might not exist - } - } - - // Determine overall status - const allConfiguredServicesRunning = services.every( - (service) => service.status === 'running', + const credentials = detectCredentials(projectRoot); + const channelAuth = detectChannelAuth(); + const { registeredGroups, groupsByAgent } = loadRegisteredGroupsSummary(); + const { + status, + servicesSummary, + configuredChannels, + codexConfigured, + reviewConfigured, + } = buildVerifySummary( + services, + serviceDefs, + credentials, + channelAuth, + registeredGroups, + groupsByAgent, ); - const codexConfigured = serviceDefs.some( - (service) => service.kind === 'codex', - ); - const reviewConfigured = serviceDefs.some( - (service) => service.kind === 'review', - ); - - const status = - allConfiguredServicesRunning && - credentials !== 'missing' && - anyChannelConfigured && - registeredGroups > 0 - ? 'success' - : 'failed'; - - // Build service status summary - const servicesSummary: Record = {}; - for (const svc of services) { - servicesSummary[svc.name] = svc.status; - } logger.info({ status, channelAuth, servicesSummary }, 'Verification complete');