diff --git a/README.md b/README.md index 8f3cdbf..e958243 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,12 @@ VibeStack v1 targets a single-host Docker Compose installation with: - App lifecycle controls: deploy, update, start, stop, delete, rollback. - Latest version plus two previous versions available for rollback. - App logs, deployment history, audit logs, and lifecycle events. +- VibeStack Doctor diagnostics with root-cause categories, evidence, and copyable coding-agent fix prompts. - App secrets as environment variables, never revealed after creation. - Optional Postgres per app, using separate databases in the same Postgres server as VibeStack metadata. - Persistent app volumes. - Maintenance mode and admin-configurable announcement banner. +- Optional OpenRouter-powered Doctor enrichment using `openai/gpt-5.5`. - OpenAPI-first API design. ## Quickstart Paths @@ -55,6 +57,8 @@ The initial Claude Code companion skill lives in: It describes how an AI coding agent should prepare a web app for VibeStack, create or validate `vibestack.json`, package the source as a tarball, submit it to the deployment API, and poll for status. +When a deployment fails, the helper now fetches VibeStack Doctor output from the server. Doctor classifies common generated-app failures such as missing health routes, wrong bind hosts, port mismatches, missing environment secrets, hard-coded localhost Postgres connections, missing database tables or migrations, build failures, and container startup failures. The management UI shows the same diagnosis and includes a copyable fix prompt for Claude Code, Codex, or another coding agent. + The skill also includes a reference API contract and a helper script: - [skills/deploy-to-vibestack/references/api.md](skills/deploy-to-vibestack/references/api.md) @@ -100,7 +104,7 @@ The Cloudflare token must be able to edit DNS records in the zone used by the ho ## Current Status -This repository is an initial public release. It includes the VibeStack API, worker, web app, shared package, deployment skill, and sample application fixtures. APIs and operational behavior may change before a 1.0 release. +This repository is an initial public release. It includes the VibeStack API, worker, web app, shared package, deployment skill, VibeStack Doctor troubleshooting workflow, and sample application fixtures. APIs and operational behavior may change before a 1.0 release. ## Contributing diff --git a/apps/api/package.json b/apps/api/package.json index 94fe8ac..b38cd14 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@vibestack/api", - "version": "0.2.2-alpha.0", + "version": "0.2.3-alpha.0", "private": true, "type": "module", "main": "dist/server.js", diff --git a/apps/api/src/doctor.test.ts b/apps/api/src/doctor.test.ts new file mode 100644 index 0000000..4e6a4cb --- /dev/null +++ b/apps/api/src/doctor.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { buildDoctorPacket, normalizeOpenRouterSetting, publicOpenRouterSetting } from './doctor.js'; +import { loadConfig } from './config.js'; +import { decryptSecret } from './crypto.js'; +import type { Db } from './db.js'; +import type { AppRow, DeploymentRow } from './types.js'; + +const app: AppRow = { + id: 'de52380f-282b-44de-a741-17118f331b01', + team_id: '8f90c863-78f2-4837-a98b-02b812ef765d', + name: 'okr-dashboard', + slug: 'okr-dashboard', + hostname: 'platform-admins-okr-dashboard.example.com', + status: 'failed', + creator_user_id: 'b2f2f26f-3a5c-4226-844f-b54808bd7baf', + last_updated_by_user_id: null, + current_deployment_id: null, + postgres_enabled: false, + external_password_enabled: false, + login_access_enabled: true, + created_at: new Date(), + updated_at: new Date(), + deleted_at: null +}; + +function deployment(overrides: Partial): DeploymentRow { + return { + id: 'f5c483c3-9466-4eef-8854-cab7de56c657', + app_id: app.id, + version_number: 1, + type: 'deploy', + source_commit_sha: null, + source_tarball_sha256: null, + docker_image_tag: null, + manifest: { name: 'okr-dashboard', port: 3000, healthCheckPath: '/health', requiredSecrets: [] }, + status: 'failed', + started_by_user_id: app.creator_user_id, + rollback_source_deployment_id: null, + error_code: null, + error_message: null, + error_details_json: null, + log_excerpt: null, + started_at: new Date(), + finished_at: new Date(), + created_at: new Date(), + ...overrides + }; +} + +describe('VibeStack Doctor', () => { + it('classifies missing health routes from failed health checks', async () => { + const packet = await buildDoctorPacket({ + app, + deployments: [ + deployment({ + error_code: 'HEALTH_CHECK_FAILED', + error_message: 'The container did not return a successful response.', + error_details_json: { + checkedUrl: 'http://127.0.0.1:49152/health', + port: 3000, + healthCheckPath: '/health', + logExcerpt: 'GET /health 404\nCannot GET /health' + } + }) + ], + secrets: [], + appLogs: [], + postgres: { enabled: false, logs: [] } + }); + + expect(packet.rootCauseCategory).toBe('missing_health_route'); + expect(packet.healthCheckResult.status).toBe('failed'); + expect(packet.suggestedFixPrompt).toContain('health route'); + }); + + it('classifies missing manifest required secrets', async () => { + const packet = await buildDoctorPacket({ + app, + deployments: [ + deployment({ + manifest: { name: 'okr-dashboard', port: 3000, healthCheckPath: '/health', requiredSecrets: ['OPENAI_API_KEY'] }, + error_code: 'HEALTH_CHECK_FAILED' + }) + ], + secrets: [], + appLogs: [], + postgres: { enabled: false, logs: [] } + }); + + expect(packet.rootCauseCategory).toBe('missing_env_secret'); + expect(packet.evidence.some((item) => item.value === 'OPENAI_API_KEY')).toBe(true); + }); + + it('classifies localhost database connections from logs', async () => { + const packet = await buildDoctorPacket({ + app: { ...app, postgres_enabled: true }, + deployments: [deployment({ error_code: 'HEALTH_CHECK_FAILED' })], + secrets: [], + appLogs: ['Error: connect ECONNREFUSED 127.0.0.1:5432'], + postgres: { enabled: true, logs: [] } + }); + + expect(packet.rootCauseCategory).toBe('database_connection_localhost'); + expect(packet.suggestedFixPrompt).toContain('DATABASE_URL'); + }); + + it('stores OpenRouter API keys encrypted and exposes only configured flags', async () => { + const config = loadConfig({ + DATABASE_URL: 'postgres://vibestack:vibestack@localhost:5432/vibestack', + VIBESTACK_SECRET_KEY: 'test-secret-key-for-doctor-settings' + }); + const rows = new Map(); + const db = { + maybeOne: async () => { + const value = rows.get('openRouter'); + return value ? { value_json: value } : null; + } + } as unknown as Db; + + const setting = await normalizeOpenRouterSetting(db, config, { + enabled: true, + model: 'openai/gpt-5.5', + apiKey: 'sk-or-test' + }); + rows.set('openRouter', setting); + + expect(setting.encryptedApiKey).toMatch(/^v1:/); + expect(decryptSecret(setting.encryptedApiKey ?? '', config.secretKey)).toBe('sk-or-test'); + expect(publicOpenRouterSetting(setting)).toEqual({ + enabled: true, + model: 'openai/gpt-5.5', + configured: true, + apiKeyConfigured: true + }); + }); +}); diff --git a/apps/api/src/doctor.ts b/apps/api/src/doctor.ts new file mode 100644 index 0000000..625bba1 --- /dev/null +++ b/apps/api/src/doctor.ts @@ -0,0 +1,498 @@ +import type { Config } from './config.js'; +import { decryptSecret, encryptSecret } from './crypto.js'; +import type { Db } from './db.js'; +import type { AppRow, DeploymentRow } from './types.js'; + +export type DoctorRootCause = + | 'missing_health_route' + | 'wrong_bind_host' + | 'wrong_port' + | 'missing_env_secret' + | 'database_connection_localhost' + | 'missing_table_or_migration' + | 'build_failure' + | 'container_start_failure' + | 'health_check_failure' + | 'unknown'; + +export type DoctorEvidence = { + source: string; + label: string; + value: string; + severity: 'info' | 'warning' | 'error'; +}; + +export type DoctorPacket = { + summary: string; + rootCauseCategory: DoctorRootCause; + evidence: DoctorEvidence[]; + suggestedFixPrompt: string; + safeToRetry: boolean; + relatedDeploymentId: string | null; + healthCheckResult: { + status: 'failed' | 'passed' | 'unknown'; + checkedUrl?: string; + port?: number; + path?: string; + message?: string; + }; + postgresHints: { + enabled: boolean; + issue?: 'localhost_connection' | 'missing_table_or_migration' | 'unknown'; + evidence: DoctorEvidence[]; + }; + aiEnhancement?: { + model: string; + summary: string; + suggestedFixPrompt: string; + }; +}; + +type OpenRouterSetting = { + enabled?: boolean; + model?: string; + apiKey?: string; + encryptedApiKey?: string; +}; + +type DoctorInput = { + app: AppRow; + deployments: DeploymentRow[]; + secrets: string[]; + appLogs: string[]; + postgres: { + enabled: boolean; + logs: string[]; + }; +}; + +const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-5.5'; + +export function publicOpenRouterSetting(setting: OpenRouterSetting): Record { + const configured = Boolean(setting.apiKey || setting.encryptedApiKey); + return { + enabled: setting.enabled ?? configured, + model: setting.model ?? DEFAULT_OPENROUTER_MODEL, + configured, + apiKeyConfigured: configured + }; +} + +export async function normalizeOpenRouterSetting( + db: Db, + config: Config, + value: unknown +): Promise { + const previous = await getStoredOpenRouterSetting(db); + const input = asRecord(value); + const apiKey = typeof input.apiKey === 'string' ? input.apiKey.trim() : undefined; + const api_key = typeof input.api_key === 'string' ? input.api_key.trim() : undefined; + const clearApiKey = input.apiKey === null || input.api_key === null; + const model = typeof input.model === 'string' && input.model.trim() ? input.model.trim() : previous.model; + const enabled = typeof input.enabled === 'boolean' ? input.enabled : previous.enabled; + + return { + enabled: enabled ?? Boolean(previous.encryptedApiKey || apiKey || api_key), + model: model ?? DEFAULT_OPENROUTER_MODEL, + encryptedApiKey: clearApiKey + ? undefined + : apiKey || api_key + ? encryptSecret(apiKey || api_key || '', config.secretKey) + : previous.encryptedApiKey + }; +} + +export async function getOpenRouterSetting(db: Db, config: Config): Promise { + const stored = await getStoredOpenRouterSetting(db); + const apiKey = stored.encryptedApiKey ? decryptSecret(stored.encryptedApiKey, config.secretKey) : undefined; + return { + enabled: stored.enabled ?? Boolean(apiKey), + model: stored.model ?? DEFAULT_OPENROUTER_MODEL, + apiKey, + encryptedApiKey: stored.encryptedApiKey + }; +} + +async function getStoredOpenRouterSetting(db: Db): Promise { + const row = await db.maybeOne<{ value_json: unknown }>("SELECT value_json FROM platform_settings WHERE key = 'openRouter'"); + const stored = asRecord(row?.value_json); + return { + enabled: typeof stored.enabled === 'boolean' ? stored.enabled : undefined, + model: typeof stored.model === 'string' ? stored.model : undefined, + encryptedApiKey: typeof stored.encryptedApiKey === 'string' ? stored.encryptedApiKey : undefined + }; +} + +export async function buildDoctorPacket(input: DoctorInput): Promise { + const failedDeployment = + input.deployments.find((deployment) => deployment.status === 'failed') ?? + (input.app.status === 'failed' ? input.deployments[0] : undefined); + const details = asRecord(failedDeployment?.error_details_json); + const manifest = asRecord(failedDeployment?.manifest ?? input.deployments[0]?.manifest); + const healthPath = stringValue(details.healthCheckPath) ?? stringValue(manifest.healthCheckPath); + const checkedUrl = stringValue(details.checkedUrl); + const port = numberValue(details.port) ?? numberValue(manifest.port); + const text = diagnosticText({ + deployment: failedDeployment, + details, + appLogs: input.appLogs, + postgresLogs: input.postgres.logs + }); + const evidence: DoctorEvidence[] = []; + + if (!failedDeployment) { + evidence.push({ + source: 'app', + label: 'Status', + value: `No failed deployment found. Current app status is ${input.app.status}.`, + severity: 'info' + }); + return packetFor({ + app: input.app, + category: 'unknown', + evidence, + relatedDeploymentId: null, + healthCheckResult: { status: 'unknown', checkedUrl, port, path: healthPath }, + postgresEnabled: input.postgres.enabled + }); + } + + addDeploymentEvidence(evidence, failedDeployment, details); + addMissingRequiredSecretEvidence(evidence, manifest, input.secrets); + + const category = classifyRootCause({ + deployment: failedDeployment, + details, + manifest, + text, + evidence + }); + const postgresEvidence = postgresHints(input.postgres.enabled, input.postgres.logs); + for (const item of postgresEvidence.evidence) evidence.push(item); + + return packetFor({ + app: input.app, + category: postgresEvidence.issue === 'localhost_connection' ? 'database_connection_localhost' : postgresEvidence.issue === 'missing_table_or_migration' ? 'missing_table_or_migration' : category, + evidence, + relatedDeploymentId: failedDeployment.id, + healthCheckResult: { + status: failedDeployment.error_code === 'HEALTH_CHECK_FAILED' ? 'failed' : 'unknown', + checkedUrl, + port, + path: healthPath, + message: failedDeployment.error_message ?? undefined + }, + postgresEnabled: input.postgres.enabled, + postgresIssue: postgresEvidence.issue, + postgresEvidence: postgresEvidence.evidence + }); +} + +export async function enrichDoctorWithOpenRouter(db: Db, config: Config, packet: DoctorPacket): Promise { + if (!packet.relatedDeploymentId) return packet; + const setting = await getOpenRouterSetting(db, config); + if (!setting.enabled || !setting.apiKey) return packet; + + const prompt = [ + 'You are VibeStack Doctor. Improve this deployment troubleshooting packet for a coding agent.', + 'Do not invent evidence. Do not include secrets. Keep the result concise and actionable.', + 'Return JSON only with keys summary and suggestedFixPrompt.', + JSON.stringify({ + summary: packet.summary, + rootCauseCategory: packet.rootCauseCategory, + evidence: packet.evidence.slice(0, 12), + deterministicSuggestedFixPrompt: packet.suggestedFixPrompt, + healthCheckResult: packet.healthCheckResult, + postgresHints: packet.postgresHints + }) + ].join('\n\n'); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 12000); + try { + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${setting.apiKey}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': config.publicUrl, + 'X-Title': 'VibeStack Doctor' + }, + body: JSON.stringify({ + model: setting.model ?? DEFAULT_OPENROUTER_MODEL, + messages: [{ role: 'user', content: prompt }], + temperature: 0.2 + }), + signal: controller.signal + }); + if (!response.ok) return packet; + const body = asRecord(await response.json()); + const choices = Array.isArray(body.choices) ? body.choices : []; + const first = asRecord(choices[0]); + const message = asRecord(first.message); + const content = typeof message.content === 'string' ? message.content : ''; + const parsed = parseJsonObject(content); + const summary = typeof parsed.summary === 'string' && parsed.summary.trim() ? parsed.summary.trim() : undefined; + const suggestedFixPrompt = + typeof parsed.suggestedFixPrompt === 'string' && parsed.suggestedFixPrompt.trim() + ? parsed.suggestedFixPrompt.trim() + : undefined; + if (!summary && !suggestedFixPrompt) return packet; + return { + ...packet, + aiEnhancement: { + model: setting.model ?? DEFAULT_OPENROUTER_MODEL, + summary: summary ?? packet.summary, + suggestedFixPrompt: suggestedFixPrompt ?? packet.suggestedFixPrompt + } + }; + } catch { + return packet; + } finally { + clearTimeout(timeout); + } +} + +function classifyRootCause(input: { + deployment: DeploymentRow; + details: Record; + manifest: Record; + text: string; + evidence: DoctorEvidence[]; +}): DoctorRootCause { + const code = input.deployment.error_code ?? ''; + const text = input.text.toLowerCase(); + + if (input.evidence.some((item) => item.label === 'Missing required secret')) return 'missing_env_secret'; + if (code === 'PORT_MISMATCH' || input.details.manifestPort || input.details.exposedPorts) return 'wrong_port'; + if (looksLikeBuildFailure(code, text)) return 'build_failure'; + if (/missing|required|not set/.test(text) && /(env|environment|secret|api[_ -]?key|token)/.test(text)) return 'missing_env_secret'; + if (/(localhost|127\.0\.0\.1).{0,80}(5432|postgres|database)|econnrefused.{0,80}(localhost|127\.0\.0\.1)/.test(text)) { + return 'database_connection_localhost'; + } + if (/(relation|table).{0,80}(does not exist|not found)|no such table|undefined_table|migration/.test(text)) { + return 'missing_table_or_migration'; + } + if (code === 'HEALTH_CHECK_FAILED') { + if (/(cannot get|404|not found).{0,80}(health|\/health)/.test(text)) return 'missing_health_route'; + if (/(127\.0\.0\.1|localhost).{0,80}(listen|listening|server|local)/.test(text)) return 'wrong_bind_host'; + if (input.details.port || input.manifest.port) return 'health_check_failure'; + } + if (/docker run|container start|exited|cannot find module|command not found|exec format error/.test(text)) { + return 'container_start_failure'; + } + return 'unknown'; +} + +function packetFor(input: { + app: AppRow; + category: DoctorRootCause; + evidence: DoctorEvidence[]; + relatedDeploymentId: string | null; + healthCheckResult: DoctorPacket['healthCheckResult']; + postgresEnabled: boolean; + postgresIssue?: DoctorPacket['postgresHints']['issue']; + postgresEvidence?: DoctorEvidence[]; +}): DoctorPacket { + return { + summary: summaryFor(input.app, input.category), + rootCauseCategory: input.category, + evidence: input.evidence.slice(0, 18), + suggestedFixPrompt: promptFor(input.app, input.category, input.evidence, input.healthCheckResult), + safeToRetry: input.category === 'unknown', + relatedDeploymentId: input.relatedDeploymentId, + healthCheckResult: input.healthCheckResult, + postgresHints: { + enabled: input.postgresEnabled, + issue: input.postgresIssue, + evidence: input.postgresEvidence ?? [] + } + }; +} + +function summaryFor(app: AppRow, category: DoctorRootCause): string { + const prefix = `${app.name} diagnosis`; + switch (category) { + case 'missing_health_route': + return `${prefix}: the configured health check route appears to be missing or returning a non-2xx response.`; + case 'wrong_bind_host': + return `${prefix}: the app likely binds to localhost instead of 0.0.0.0 inside the container.`; + case 'wrong_port': + return `${prefix}: the app port, Dockerfile EXPOSE, and vibestack.json port appear to disagree.`; + case 'missing_env_secret': + return `${prefix}: the app appears to be missing a required environment variable or secret.`; + case 'database_connection_localhost': + return `${prefix}: the app appears to connect to Postgres on localhost instead of the injected DATABASE_URL.`; + case 'missing_table_or_migration': + return `${prefix}: the app database appears to be missing a table or startup migration.`; + case 'build_failure': + return `${prefix}: the Docker image build failed before VibeStack could start the app.`; + case 'container_start_failure': + return `${prefix}: the container appears to exit or fail during startup.`; + case 'health_check_failure': + return `${prefix}: the app did not pass the configured VibeStack health check.`; + case 'unknown': + return `${prefix}: VibeStack found a failure, but it could not classify a deterministic root cause.`; + } +} + +function promptFor( + app: AppRow, + category: DoctorRootCause, + evidence: DoctorEvidence[], + health: DoctorPacket['healthCheckResult'] +): string { + const evidenceText = evidence + .slice(0, 8) + .map((item) => `- ${item.label}: ${item.value}`) + .join('\n'); + const healthText = health.checkedUrl + ? `VibeStack checked ${health.checkedUrl} and expected HTTP 2xx.` + : health.path || health.port + ? `VibeStack expected health path ${health.path ?? '(unknown)'} on port ${health.port ?? '(unknown)'}.` + : 'VibeStack could not determine a concrete health-check URL.'; + const instruction = instructionFor(category); + return [ + `Fix the VibeStack deployment for app "${app.name}".`, + `Root cause category: ${category}.`, + healthText, + 'Evidence:', + evidenceText || '- No concrete evidence was available.', + '', + instruction, + 'After fixing, run the local VibeStack deploy helper with --smoke-test, then redeploy only if the smoke test passes.' + ].join('\n'); +} + +function instructionFor(category: DoctorRootCause): string { + switch (category) { + case 'missing_health_route': + return 'Add or repair a fast unauthenticated health route that returns HTTP 2xx, then set vibestack.json healthCheckPath to that route.'; + case 'wrong_bind_host': + return 'Change the web server to listen on 0.0.0.0 inside the container, not localhost or 127.0.0.1.'; + case 'wrong_port': + return 'Align the application listen port, Dockerfile EXPOSE, and vibestack.json port.'; + case 'missing_env_secret': + return 'Identify the missing environment variable, add it as a VibeStack app secret or remove the hard requirement, and make startup errors explicit.'; + case 'database_connection_localhost': + return 'Use process.env.DATABASE_URL for Postgres. Do not hard-code localhost, database names, users, or passwords.'; + case 'missing_table_or_migration': + return 'Add idempotent startup migrations or table initialization so the app can boot against a fresh VibeStack-managed database.'; + case 'build_failure': + return 'Fix the Dockerfile or dependency installation failure so docker build succeeds from a clean packaged context.'; + case 'container_start_failure': + return 'Fix the container command, missing runtime files, or startup exception so the server process stays in the foreground.'; + case 'health_check_failure': + return 'Ensure the app starts quickly, listens on the manifest port, binds to 0.0.0.0, and returns HTTP 2xx at the configured health path.'; + case 'unknown': + return 'Inspect the deployment error and logs, make one concrete fix, and do not retry the same unchanged artifact repeatedly.'; + } +} + +function addDeploymentEvidence( + evidence: DoctorEvidence[], + deployment: DeploymentRow, + details: Record +): void { + if (deployment.error_code) { + evidence.push({ source: 'deployment', label: 'Error code', value: deployment.error_code, severity: 'error' }); + } + if (deployment.error_message) { + evidence.push({ source: 'deployment', label: 'Error message', value: deployment.error_message, severity: 'error' }); + } + const checkedUrl = stringValue(details.checkedUrl); + if (checkedUrl) { + evidence.push({ source: 'health_check', label: 'Checked URL', value: checkedUrl, severity: 'error' }); + } + const logExcerpt = stringValue(details.logExcerpt) ?? deployment.log_excerpt; + if (logExcerpt) { + evidence.push({ source: 'logs', label: 'Relevant log excerpt', value: trimLines(logExcerpt), severity: 'error' }); + } +} + +function addMissingRequiredSecretEvidence( + evidence: DoctorEvidence[], + manifest: Record, + configuredSecrets: string[] +): void { + const requiredSecrets = Array.isArray(manifest.requiredSecrets) ? manifest.requiredSecrets : []; + const configured = new Set(configuredSecrets); + for (const secret of requiredSecrets) { + if (typeof secret === 'string' && !configured.has(secret)) { + evidence.push({ source: 'manifest', label: 'Missing required secret', value: secret, severity: 'error' }); + } + } +} + +function postgresHints(enabled: boolean, logs: string[]): { + issue?: DoctorPacket['postgresHints']['issue']; + evidence: DoctorEvidence[]; +} { + if (!enabled || logs.length === 0) return { evidence: [] }; + const text = logs.join('\n').toLowerCase(); + const evidence = logs.slice(-5).map((line) => ({ + source: 'postgres', + label: 'Postgres log', + value: line, + severity: 'warning' as const + })); + if (/(localhost|127\.0\.0\.1).{0,80}(5432|postgres|database)|econnrefused/.test(text)) { + return { issue: 'localhost_connection', evidence }; + } + if (/(relation|table).{0,80}(does not exist|not found)|no such table|undefined_table|migration/.test(text)) { + return { issue: 'missing_table_or_migration', evidence }; + } + return { issue: 'unknown', evidence }; +} + +function looksLikeBuildFailure(code: string, text: string): boolean { + return ( + code === 'BUILD_FAILED' || + /docker build|failed to solve|npm err!|pnpm|yarn install|pip install|cargo build|go build|dockerfile/.test(text) + ); +} + +function diagnosticText(input: { + deployment?: DeploymentRow; + details: Record; + appLogs: string[]; + postgresLogs: string[]; +}): string { + return [ + input.deployment?.error_code, + input.deployment?.error_message, + input.deployment?.log_excerpt, + JSON.stringify(input.details), + ...input.appLogs, + ...input.postgresLogs + ] + .filter(Boolean) + .join('\n'); +} + +function trimLines(value: string): string { + const lines = value.split('\n').filter(Boolean).slice(-20); + const text = lines.join('\n'); + return text.length > 1800 ? text.slice(-1800) : text; +} + +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function parseJsonObject(value: string): Record { + const trimmed = value.trim().replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim(); + try { + return asRecord(JSON.parse(trimmed)); + } catch { + return {}; + } +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 21807de..6fc5131 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -44,6 +44,12 @@ import { publicCloudflareSetting } from './cloudflare.js'; import { getSelfUpdateStatus, startSelfUpdate } from './updater.js'; import { schemaCompatibilityForRef } from './schema-compatibility.js'; import { createSystemBackup, restoreSystemBackup } from './system-backup.js'; +import { + buildDoctorPacket, + enrichDoctorWithOpenRouter, + normalizeOpenRouterSetting, + publicOpenRouterSetting +} from './doctor.js'; type AppContext = { config: Config; @@ -91,11 +97,23 @@ async function currentSettings(db: Db, config: Config): Promise) ?? {}; + return [ + row.key, + publicOpenRouterSetting({ + enabled: typeof setting.enabled === 'boolean' ? setting.enabled : undefined, + model: typeof setting.model === 'string' ? setting.model : undefined, + encryptedApiKey: typeof setting.encryptedApiKey === 'string' ? setting.encryptedApiKey : undefined + }) + ]; + } return [row.key, row.encrypted ? { configured: true } : row.value_json]; }) ); return { updateChannel: config.updateChannel, + openRouter: publicOpenRouterSetting({}), ...settings }; } @@ -1039,13 +1057,19 @@ async function registerRoutes(app: FastifyInstance, ctx: AppContext): Promise { + const actor = await requireActor(db, request); + const { id } = parseParams(IdParam, request); + const query = parseQuery(LogQuery, request); + const appRow = await getAuthorizedApp(db, actor, id, 'creator'); + const deployments = await db.query( + `SELECT * + FROM deployments + WHERE app_id = $1 + ORDER BY created_at DESC + LIMIT 5`, + [id] + ); + const secrets = await db.query<{ key: string }>('SELECT key FROM app_secrets WHERE app_id = $1 ORDER BY key', [id]); + const appLogs = + config.runtimeDriver === 'docker' && appRow.current_deployment_id + ? await dockerLogsForDeployment(config, id, appRow.current_deployment_id, query.tail).catch(() => null) + : null; + const postgresCredentials = await db.maybeOne<{ database_name: string; database_user: string }>( + 'SELECT database_name, database_user FROM app_db_credentials WHERE app_id = $1 AND deleted_at IS NULL', + [id] + ); + const postgresLogs = postgresCredentials + ? await dockerLogsForPostgres( + config, + [id, postgresCredentials.database_name, postgresCredentials.database_user], + query.tail + ).catch(() => null) + : null; + const packet = await buildDoctorPacket({ + app: appRow, + deployments: deployments.rows, + secrets: secrets.rows.map((row) => row.key), + appLogs: appLogs ? appLogs.split('\n').filter(Boolean) : [], + postgres: { + enabled: Boolean(postgresCredentials), + logs: postgresLogs?.logs ?? [] + } + }); + return { doctor: await enrichDoctorWithOpenRouter(db, config, packet) }; + }); + app.get('/api/v1/audit-logs', async (request) => { const actor = await requireActor(db, request); requirePlatformAdmin(actor); diff --git a/apps/web/package.json b/apps/web/package.json index b161ee7..4fc9728 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@vibestack/web", - "version": "0.2.2-alpha.0", + "version": "0.2.3-alpha.0", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 24e9036..22644d5 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -6,6 +6,7 @@ import type { AppSummary, AuditLog, Deployment, + DoctorPacket, LifecycleEvent, LogLine, MeResponse, @@ -212,6 +213,10 @@ export const api = { return normalizeList(response, ['logs']); }, + getDoctor(appId: string): Promise { + return request(`/apps/${appId}/doctor`).then((value) => unwrap(value, 'doctor')); + }, + async listEvents(appId: string): Promise { return normalizeList(await request>(`/apps/${appId}/events`), ['events']); }, diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 5a06036..9775d6c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -42,6 +42,7 @@ import type { ApiToken, AuditLog, Deployment, + DoctorPacket, LifecycleEvent, LogLine, PlatformSettings, @@ -58,13 +59,15 @@ type DetailState = { secrets: AppSecret[]; events: LifecycleEvent[]; logs: LogLine[]; + doctor?: DoctorPacket | null; }; const emptyDetail: DetailState = { deployments: [], secrets: [], events: [], - logs: [] + logs: [], + doctor: null }; function nameOf(user: User): string { @@ -302,13 +305,14 @@ function App() { setDetailError(undefined); try { - const [deployments, secrets, events, logs] = await Promise.all([ + const [deployments, secrets, events, logs, doctor] = await Promise.all([ api.listDeployments(appId), api.listSecrets(appId), api.listEvents(appId), - api.listLogs(appId) + api.listLogs(appId), + api.getDoctor(appId) ]); - setDetail({ deployments, secrets, events, logs }); + setDetail({ deployments, secrets, events, logs, doctor }); } catch (caught) { setDetailError(formatApiError(caught)); } finally { @@ -804,6 +808,8 @@ function AppDetail(props: Parameters[0] & { app: AppSummary }) + +
@@ -951,6 +957,69 @@ function AppDetail(props: Parameters[0] & { app: AppSummary }) ); } +function DoctorPanel({ doctor, loading }: { doctor?: DoctorPacket | null; loading: boolean }) { + const [copied, setCopied] = useState(false); + const smartSummary = doctor?.aiEnhancement?.summary; + const fixPrompt = doctor?.aiEnhancement?.suggestedFixPrompt ?? doctor?.suggestedFixPrompt ?? ''; + const health = doctor?.healthCheckResult; + const postgres = doctor?.postgresHints; + const evidence = doctor?.evidence ?? []; + + async function copyPrompt() { + if (!fixPrompt) return; + await navigator.clipboard.writeText(fixPrompt); + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + } + + return ( +
+
+ + +
+ {loading && !doctor ?
Loading diagnosis
: null} + {doctor ? ( + <> +
+ + {statusLabel(doctor.rootCauseCategory)} + +

{smartSummary ?? doctor.summary}

+
+
+
+ Health check + {health?.status ?? 'unknown'}{health?.path ? ` at ${health.path}` : ''}{health?.port ? ` on port ${health.port}` : ''} +
+
+ Postgres + {postgres?.enabled ? (postgres.issue ? statusLabel(postgres.issue) : 'enabled, no specific issue') : 'not enabled'} +
+
+ Next action + {doctor.safeToRetry ? 'Review and retry if nothing changed externally.' : 'Fix the app before retrying.'} +
+
+
+ {evidence.length === 0 ?

No focused evidence available yet.

: evidence.slice(0, 4).map((item, index) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ {doctor.aiEnhancement ?

OpenRouter enhancement: {doctor.aiEnhancement.model}

: null} + + ) : !loading ? ( +

No diagnosis available.

+ ) : null} +
+ ); +} + function UsersView({ users, onCreate, onUpdate }: { users: User[]; teams: Team[]; onCreate: (payload: { email: string; displayName: string; password: string; isPlatformAdmin: boolean }) => Promise; onUpdate: (userId: string, payload: Partial) => Promise }) { const [email, setEmail] = useState(''); const [displayName, setDisplayName] = useState(''); @@ -1085,6 +1154,7 @@ function SettingsView({ }) { const [draft, setDraft] = useState(settings); const [cloudflareToken, setCloudflareToken] = useState(''); + const [openRouterKey, setOpenRouterKey] = useState(''); const [saved, setSaved] = useState(false); const [updateBusy, setUpdateBusy] = useState<'check' | 'apply'>(); const [backupBusy, setBackupBusy] = useState<'download' | 'restore'>(); @@ -1094,6 +1164,7 @@ function SettingsView({ useEffect(() => { setDraft(settings); setCloudflareToken(''); + setOpenRouterKey(''); }, [settings]); async function submit(event: React.FormEvent) { @@ -1112,10 +1183,15 @@ function SettingsView({ cloudflare: { ...(draft.cloudflare ?? {}), ...(cloudflareToken ? { apiToken: cloudflareToken } : {}) + }, + openRouter: { + ...(draft.openRouter ?? {}), + ...(openRouterKey ? { apiKey: openRouterKey } : {}) } }); setSaved(true); setCloudflareToken(''); + setOpenRouterKey(''); } catch (caught) { setError(formatApiError(caught)); } @@ -1124,6 +1200,9 @@ function SettingsView({ const cloudflare = draft.cloudflare ?? {}; const cloudflareZoneId = cloudflare.zoneId ?? cloudflare.zone_id ?? ''; const cloudflareConfigured = Boolean(cloudflare.configured ?? cloudflare.apiTokenConfigured ?? cloudflare.api_token_configured); + const openRouter = draft.openRouter ?? {}; + const openRouterConfigured = Boolean(openRouter.configured ?? openRouter.apiKeyConfigured ?? openRouter.api_key_configured); + const openRouterModel = openRouter.model ?? 'openai/gpt-5.5'; const updateAvailable = Boolean(update?.updateAvailable); const updateRunning = update?.state === 'running'; const currentVersionLabel = versionLabel(update?.currentVersion, update?.currentTag, update?.currentRevision); @@ -1211,6 +1290,16 @@ function SettingsView({ {cloudflareConfigured ? 'configured' : 'not configured'}
+
+ + + + +

The key is encrypted in VibeStack settings storage and used only to enrich Doctor packets. Deterministic diagnosis still runs without it.

+ + {openRouterConfigured ? 'configured' : 'not configured'} + +
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index a63ebb6..5041dbe 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -691,6 +691,17 @@ textarea:focus { margin-bottom: 14px; } +.panel-title-row { + align-items: start; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.panel-title-row .panel-title { + margin-bottom: 0; +} + .panel-title svg { color: var(--accent); } @@ -729,6 +740,69 @@ textarea:focus { margin-top: 12px; } +.doctor-panel { + display: grid; + gap: 14px; +} + +.doctor-summary { + align-items: start; + display: grid; + gap: 10px; + grid-template-columns: auto minmax(0, 1fr); +} + +.doctor-summary p { + line-height: 1.45; + margin: 0; +} + +.doctor-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.doctor-grid div, +.evidence-item { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 7px; + display: grid; + gap: 5px; + padding: 10px; +} + +.doctor-grid strong, +.evidence-item strong { + color: var(--ink); + font-size: 0.82rem; +} + +.doctor-grid span { + color: var(--muted); + line-height: 1.35; +} + +.doctor-evidence { + display: grid; + gap: 8px; +} + +.evidence-item.error { + border-color: #efc5c1; +} + +.evidence-item.warning { + border-color: #efd58c; +} + +.evidence-item code { + max-height: 140px; + overflow: auto; + white-space: pre-wrap; +} + .version-list { display: grid; gap: 8px; @@ -937,6 +1011,7 @@ th { } .summary-grid, + .doctor-grid, .rollback-row, .token-reveal, .secret-form { @@ -950,6 +1025,10 @@ th { .login-aside { min-height: auto; } + + .doctor-summary { + grid-template-columns: 1fr; + } } @media (max-width: 560px) { diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index d1425a0..abaa7e5 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -152,6 +152,42 @@ export type LogLine = { stream?: string; }; +export type DoctorPacket = { + summary: string; + rootCauseCategory: string; + evidence: { + source: string; + label: string; + value: string; + severity: 'info' | 'warning' | 'error' | string; + }[]; + suggestedFixPrompt: string; + safeToRetry: boolean; + relatedDeploymentId: string | null; + healthCheckResult: { + status: 'failed' | 'passed' | 'unknown' | string; + checkedUrl?: string; + port?: number; + path?: string; + message?: string; + }; + postgresHints: { + enabled: boolean; + issue?: string; + evidence: { + source: string; + label: string; + value: string; + severity: 'info' | 'warning' | 'error' | string; + }[]; + }; + aiEnhancement?: { + model: string; + summary: string; + suggestedFixPrompt: string; + }; +}; + export type ApiToken = { id: string; name: string; @@ -221,6 +257,15 @@ export type PlatformSettings = { apiTokenConfigured?: boolean; api_token_configured?: boolean; }; + openRouter?: { + enabled?: boolean; + model?: string; + apiKey?: string; + api_key?: string; + configured?: boolean; + apiKeyConfigured?: boolean; + api_key_configured?: boolean; + }; dataDirectory?: string; data_directory?: string; buildTimeoutSeconds?: number; diff --git a/package-lock.json b/package-lock.json index f31f144..a6d81b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vibestack", - "version": "0.2.2-alpha.0", + "version": "0.2.3-alpha.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vibestack", - "version": "0.2.2-alpha.0", + "version": "0.2.3-alpha.0", "license": "AGPL-3.0-or-later", "workspaces": [ "apps/*", @@ -22,7 +22,7 @@ }, "apps/api": { "name": "@vibestack/api", - "version": "0.2.2-alpha.0", + "version": "0.2.3-alpha.0", "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.2.0", @@ -49,7 +49,7 @@ }, "apps/web": { "name": "@vibestack/web", - "version": "0.2.2-alpha.0", + "version": "0.2.3-alpha.0", "dependencies": { "lucide-react": "^0.468.0", "react": "^18.3.1", @@ -7554,7 +7554,7 @@ }, "packages/shared": { "name": "@vibestack/shared", - "version": "0.2.2-alpha.0", + "version": "0.2.3-alpha.0", "dependencies": { "zod": "^3.23.8" } diff --git a/package.json b/package.json index 5d90c58..2222b1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vibestack", - "version": "0.2.2-alpha.0", + "version": "0.2.3-alpha.0", "description": "Self-hosted platform for deploying AI-generated web applications.", "private": true, "author": "Dendrix AI", @@ -29,5 +29,5 @@ "typescript": "^5.4.5", "vitest": "^4.1.5" }, - "vibestackRelease": "0.2c" + "vibestackRelease": "0.2d" } diff --git a/packages/shared/package.json b/packages/shared/package.json index 2d0d39d..d7f288f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@vibestack/shared", - "version": "0.2.2-alpha.0", + "version": "0.2.3-alpha.0", "private": true, "type": "module", "main": "dist/index.js", diff --git a/skills/deploy-to-vibestack/SKILL.md b/skills/deploy-to-vibestack/SKILL.md index 82558f6..a14b5d1 100644 --- a/skills/deploy-to-vibestack/SKILL.md +++ b/skills/deploy-to-vibestack/SKILL.md @@ -134,7 +134,7 @@ Read `references/manifest.md` for the manifest contract. 12. Poll every 30 seconds until status is terminal. 13. Report the live URL on success. 14. If the helper generated an external app password, relay it to the user exactly once and explain that VibeStack stores only a hash. -15. On failure, use the returned `agentHint`, error code, and details to fix the project and retry when appropriate. +15. On failure, use the returned `agentHint`, error code, details, and VibeStack Doctor output to fix the project and retry when appropriate. ## Runtime Diagnostics @@ -142,7 +142,7 @@ When a deployed app behaves incorrectly after a successful deployment, do not gu ```bash python3 skills/deploy-to-vibestack/scripts/vibestack_deploy.py \ - --diagnostics \ + --doctor \ --app-id de52380f-282b-44de-a741-17118f331b01 ``` @@ -156,6 +156,16 @@ python3 skills/deploy-to-vibestack/scripts/vibestack_deploy.py \ Diagnostics include the current deployment, recent deployments, app container logs, and VibeStack-managed Postgres metadata plus matching Postgres log lines. Use them to identify failing routes, uncaught exceptions, database connection problems, missing tables, failed migrations, and hard-coded credentials. If Postgres is enabled, the app must use the injected `DATABASE_URL`; do not hard-code `localhost`, `127.0.0.1`, database names, usernames, or passwords. Do not print secrets or full logs back to the user; summarize the relevant error lines and fix the app code directly. +VibeStack also exposes a Doctor packet: + +```bash +python3 skills/deploy-to-vibestack/scripts/vibestack_deploy.py \ + --diagnostics \ + --app-id de52380f-282b-44de-a741-17118f331b01 +``` + +The deploy helper automatically fetches `GET /api/v1/apps/{appId}/doctor` after a failed deployment. If Doctor returns a deterministic `rootCauseCategory`, tell the user: "I found the issue: . I'm fixing it now." Then patch the app directly, run the helper with `--smoke-test`, and retry only after a concrete fix. Do not resubmit the same artifact. + ## Helper Script Use `scripts/vibestack_deploy.py` when possible: diff --git a/skills/deploy-to-vibestack/references/api.md b/skills/deploy-to-vibestack/references/api.md index 5141986..f795e1c 100644 --- a/skills/deploy-to-vibestack/references/api.md +++ b/skills/deploy-to-vibestack/references/api.md @@ -200,6 +200,47 @@ Success: } ``` +## App Doctor + +Use this after a failed deployment or unhealthy app state. Doctor returns a compact, coding-agent-friendly repair packet. The deterministic fields are always available; `aiEnhancement` appears only when a platform admin has configured OpenRouter in VibeStack settings. + +```http +GET /api/v1/apps/{appId}/doctor?tail=300 +``` + +Success: + +```json +{ + "doctor": { + "summary": "todo-notes diagnosis: the app database appears to be missing a table or startup migration.", + "rootCauseCategory": "missing_table_or_migration", + "relatedDeploymentId": "dep_456", + "safeToRetry": false, + "healthCheckResult": { + "status": "failed", + "checkedUrl": "http://127.0.0.1:3000/health", + "port": 3000, + "path": "/health" + }, + "postgresHints": { + "enabled": true, + "issue": "missing_table_or_migration", + "evidence": [] + }, + "evidence": [ + { + "source": "logs", + "label": "Relevant log excerpt", + "value": "ERROR: relation notes does not exist", + "severity": "error" + } + ], + "suggestedFixPrompt": "Fix the VibeStack deployment for app \"todo-notes\"..." + } +} +``` + ## Rollback ```http diff --git a/skills/deploy-to-vibestack/scripts/vibestack_deploy.py b/skills/deploy-to-vibestack/scripts/vibestack_deploy.py index 1504d39..5b39c7b 100755 --- a/skills/deploy-to-vibestack/scripts/vibestack_deploy.py +++ b/skills/deploy-to-vibestack/scripts/vibestack_deploy.py @@ -498,10 +498,70 @@ def diagnostics(args: argparse.Namespace) -> None: print(json.dumps(payload, indent=2)) +def doctor(args: argparse.Namespace) -> None: + defaults = load_defaults(args.config, args.credentials) + args.endpoint = args.endpoint or defaults.get("endpoint") + args.team = args.team or defaults.get("team") + args.app_id = args.app_id or defaults.get("app_id") + args.token = args.token or defaults.get("token") + + args.endpoint = require_deploy_value(args.endpoint, "VibeStack API URL", "--api-url") + args.token = require_deploy_value(args.token, "VibeStack API token", "--token") + endpoint = args.endpoint.rstrip("/") + + if not args.app_id: + if not args.app: + raise SystemExit("APP_REQUIRED: pass --app-id or --app when requesting Doctor output.") + args.app_id = resolve_existing_app_id(endpoint, args.token, args.app, args.team, args.insecure_tls) + + payload = http_json( + "GET", + f"{endpoint}/api/v1/apps/{args.app_id}/doctor?tail={args.diagnostics_tail}", + args.token, + insecure_tls=args.insecure_tls, + ) + print(json.dumps(payload, indent=2)) + + +def fetch_doctor(endpoint: str, token: str, app_id: str, insecure_tls: bool, tail: int = 300) -> dict[str, Any] | None: + try: + payload = http_json( + "GET", + f"{endpoint}/api/v1/apps/{app_id}/doctor?tail={tail}", + token, + insecure_tls=insecure_tls, + ) + except RuntimeError: + return None + doctor = payload.get("doctor") + return doctor if isinstance(doctor, dict) else None + + +def print_doctor_guidance(doctor: dict[str, Any]) -> None: + summary = doctor.get("summary") + ai = doctor.get("aiEnhancement") + if isinstance(ai, dict) and isinstance(ai.get("summary"), str): + summary = ai["summary"] + category = doctor.get("rootCauseCategory") or "unknown" + prompt = doctor.get("suggestedFixPrompt") + if isinstance(ai, dict) and isinstance(ai.get("suggestedFixPrompt"), str): + prompt = ai["suggestedFixPrompt"] + + print(f"Doctor diagnosis: {summary or category}") + if category != "unknown": + print(f"I found the issue: {category}. Fix the app before retrying this deployment.") + if isinstance(prompt, str) and prompt.strip(): + print("Suggested fix prompt for a coding agent:") + print(prompt.strip()) + + def deploy(args: argparse.Namespace) -> None: if args.diagnostics: diagnostics(args) return + if args.doctor: + doctor(args) + return defaults = load_defaults(args.config, args.credentials) args.endpoint = args.endpoint or defaults.get("endpoint") @@ -629,6 +689,11 @@ def deploy(args: argparse.Namespace) -> None: return print("Deployment failed:") print(json.dumps(status.get("error") or status, indent=2)) + app_id = status.get("appId") or (status.get("app") or {}).get("id") or created.get("appId") or args.app_id + if app_id: + doctor = fetch_doctor(endpoint, args.token, str(app_id), args.insecure_tls, args.diagnostics_tail) + if doctor: + print_doctor_guidance(doctor) raise SystemExit(1) time.sleep(args.poll_interval) @@ -663,6 +728,7 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--smoke-timeout", type=int, default=90, help="seconds to wait for local smoke health") parser.add_argument("--diagnostics", action="store_true", help="fetch app diagnostics instead of deploying") + parser.add_argument("--doctor", action="store_true", help="fetch VibeStack Doctor output instead of deploying") parser.add_argument("--diagnostics-tail", type=int, default=300, help="number of app and Postgres log lines to scan") return parser diff --git a/skills/deploy-to-vibestack/scripts/vibestack_deploy_test.py b/skills/deploy-to-vibestack/scripts/vibestack_deploy_test.py index e262638..1282f1f 100644 --- a/skills/deploy-to-vibestack/scripts/vibestack_deploy_test.py +++ b/skills/deploy-to-vibestack/scripts/vibestack_deploy_test.py @@ -228,6 +228,57 @@ def fake_http_json(method, url, token, body=None, content_type=None, insecure_tl self.assertEqual(calls, ["https://vibestack.local.test/api/v1/apps/app-1/diagnostics?tail=25"]) self.assertIn('"logs": [', output.getvalue()) + def test_doctor_fetches_app_doctor_by_app_id(self) -> None: + module = load_helper_module() + calls: list[str] = [] + + def fake_http_json(method, url, token, body=None, content_type=None, insecure_tls=False): + calls.append(url) + return {"doctor": {"summary": "health route is missing", "rootCauseCategory": "missing_health_route"}} + + module.http_json = fake_http_json + args = module.build_parser().parse_args( + [ + "--doctor", + "--api-url", + "https://vibestack.local.test", + "--token", + "test-token", + "--app-id", + "app-1", + "--diagnostics-tail", + "25", + ] + ) + + output = StringIO() + with redirect_stdout(output): + module.doctor(args) + + self.assertEqual(calls, ["https://vibestack.local.test/api/v1/apps/app-1/doctor?tail=25"]) + self.assertIn('"rootCauseCategory": "missing_health_route"', output.getvalue()) + + def test_print_doctor_guidance_prefers_ai_enhancement(self) -> None: + module = load_helper_module() + output = StringIO() + + with redirect_stdout(output): + module.print_doctor_guidance( + { + "summary": "deterministic", + "rootCauseCategory": "wrong_port", + "suggestedFixPrompt": "fix deterministic", + "aiEnhancement": { + "summary": "ai summary", + "suggestedFixPrompt": "fix ai", + }, + } + ) + + self.assertIn("Doctor diagnosis: ai summary", output.getvalue()) + self.assertIn("I found the issue: wrong_port", output.getvalue()) + self.assertIn("fix ai", output.getvalue()) + def test_dry_run_fails_without_dockerfile(self) -> None: result = self.run_helper("missing-dockerfile")