diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 8c3be73e..2bc14392 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -5,6 +5,7 @@ import path from 'path'; import * as fs from 'fs'; +import { createRequire } from 'node:module'; import { getUI, type SpinnerHandle } from '@ui'; import { debug, logToFile, initLogFile, getLogFilePath } from '@utils/debug'; import type { WizardRunOptions } from '@utils/types'; @@ -54,8 +55,13 @@ async function getSDKModule(): Promise { * This ensures we use the SDK's bundled version rather than the user's installed Claude Code. */ function getClaudeCodeExecutablePath(): string { - // require.resolve finds the package's main entry, then we get cli.js from same dir - const sdkPackagePath = require.resolve('@anthropic-ai/claude-agent-sdk'); + // Bare `require` is undefined in ESM (tsx dev runs) — fall back to createRequire. + const resolver = + typeof require !== 'undefined' + ? require + : createRequire(process.argv[1] ?? `${process.cwd()}/`); + // resolve finds the package's main entry, then we get cli.js from same dir + const sdkPackagePath = resolver.resolve('@anthropic-ai/claude-agent-sdk'); return path.join(path.dirname(sdkPackagePath), 'cli.js'); } @@ -795,6 +801,7 @@ export async function runAgent( abortCases = [], } = config ?? {}; + logToFile('Starting agent run'); const { query } = await getSDKModule(); spinner.start(spinnerMessage); diff --git a/src/lib/agent/agent-runner.ts b/src/lib/agent/agent-runner.ts index 78bb01e9..5df463e8 100644 --- a/src/lib/agent/agent-runner.ts +++ b/src/lib/agent/agent-runner.ts @@ -192,6 +192,14 @@ export async function runProgram( const skillsBaseUrl = getSkillsBaseUrl(session.localMcp); // 2. Health check (guarded — skip if TUI already ran it) + if (session.readinessResult) { + logToFile( + `[agent-runner] readiness pre-computed by TUI: decision=${session.readinessResult.decision}` + + `${ + session.outageDismissed ? ' (outage dismissed by user)' : '' + } — skipping re-check`, + ); + } if (!session.readinessResult) { logToFile('[agent-runner] evaluating wizard readiness'); const readinessConfig = session.signup @@ -360,6 +368,8 @@ export async function runProgram( sessionToOptions(session), ); + logToFile('[agent-runner] agent initialized'); + const middleware = session.benchmark ? createBenchmarkPipeline(spinner, sessionToOptions(session)) : undefined; @@ -371,6 +381,7 @@ export async function runProgram( host, skillPath, }); + logToFile(`[agent-runner] prompt assembled (${prompt.length} chars)`); // 8. Run agent const agentResult = await executeAgent( diff --git a/src/lib/health-checks/__tests__/health-checks.test.ts b/src/lib/health-checks/__tests__/health-checks.test.ts index e6c97c70..981596d3 100644 --- a/src/lib/health-checks/__tests__/health-checks.test.ts +++ b/src/lib/health-checks/__tests__/health-checks.test.ts @@ -22,6 +22,7 @@ import { checkCloudflareComponentHealth, checkCloudflareOverallHealth, checkGithubHealth, + checkGithubReleasesHealth, checkLlmGatewayHealth, checkMcpHealth, checkNpmComponentHealth, @@ -696,6 +697,18 @@ describe('health-checks', () => { ); }); + it('returns down on 302 — the gateway probe stays strict, redirects are not OK here', async () => { + (global.fetch as jest.Mock).mockImplementation( + overrideFetch({ + [URLS.llmGatewayLiveness]: () => + Promise.resolve(new Response(null, { status: 302 })), + }), + ); + const result = await checkLlmGatewayHealth(); + expect(result.status).toBe(ServiceHealthStatus.Down); + expect(result.error).toBe('HTTP 302'); + }); + it('returns down when gateway responds 503 (e.g. deploying)', async () => { (global.fetch as jest.Mock).mockImplementation( overrideFetch({ @@ -746,7 +759,7 @@ describe('health-checks', () => { ); const result = await checkLlmGatewayHealth(); expect(result.status).toBe(ServiceHealthStatus.Down); - expect(result.error).toBe('Request timed out'); + expect(result.error).toBe('Request timed out after 5000ms'); }); }); @@ -765,6 +778,34 @@ describe('health-checks', () => { ); }); + it('returns healthy when worker responds 302 (redirect to docs, not followed)', async () => { + (global.fetch as jest.Mock).mockImplementation( + overrideFetch({ + [URLS.mcpLanding]: () => + Promise.resolve(new Response(null, { status: 302 })), + }), + ); + const result = await checkMcpHealth(); + expect(result.status).toBe(ServiceHealthStatus.Healthy); + expect(result.rawIndicator).toBe('HTTP 302'); + expect(global.fetch).toHaveBeenCalledWith( + URLS.mcpLanding, + expect.objectContaining({ redirect: 'manual' }), + ); + }); + + it('returns down on 400 — only 2xx-3xx counts as up', async () => { + (global.fetch as jest.Mock).mockImplementation( + overrideFetch({ + [URLS.mcpLanding]: () => + Promise.resolve(new Response('Bad Request', { status: 400 })), + }), + ); + const result = await checkMcpHealth(); + expect(result.status).toBe(ServiceHealthStatus.Down); + expect(result.error).toBe('HTTP 400'); + }); + it('returns down when worker responds 500', async () => { (global.fetch as jest.Mock).mockImplementation( overrideFetch({ @@ -803,6 +844,34 @@ describe('health-checks', () => { }); }); + // ----------------------------------------------------------------------- + // GitHub Releases (fetchEndpointHealth – skill-menu.json) + // ----------------------------------------------------------------------- + + describe('checkGithubReleasesHealth', () => { + it('returns healthy on a final 200 and follows redirects (GitHub 302s asset URLs even for missing assets)', async () => { + const result = await checkGithubReleasesHealth(); + expect(result.status).toBe(ServiceHealthStatus.Healthy); + expect(result.rawIndicator).toBe('HTTP 200'); + expect(global.fetch).toHaveBeenCalledWith( + URLS.githubReleasesSkillMenu, + expect.objectContaining({ redirect: 'follow' }), + ); + }); + + it('returns down on 404 (release published without the asset)', async () => { + (global.fetch as jest.Mock).mockImplementation( + overrideFetch({ + [URLS.githubReleasesSkillMenu]: () => + Promise.resolve(new Response('Not Found', { status: 404 })), + }), + ); + const result = await checkGithubReleasesHealth(); + expect(result.status).toBe(ServiceHealthStatus.Down); + expect(result.error).toBe('HTTP 404'); + }); + }); + // ----------------------------------------------------------------------- // checkAllExternalServices // ----------------------------------------------------------------------- diff --git a/src/lib/health-checks/endpoints.ts b/src/lib/health-checks/endpoints.ts index af1abf7c..191566d7 100644 --- a/src/lib/health-checks/endpoints.ts +++ b/src/lib/health-checks/endpoints.ts @@ -1,4 +1,5 @@ import { REMOTE_SKILLS_BASE_URL } from '@lib/constants'; +import { logToFile } from '@utils/debug'; import { ServiceHealthStatus, type BaseHealthResult } from './types'; // --------------------------------------------------------------------------- @@ -13,7 +14,7 @@ import { ServiceHealthStatus, type BaseHealthResult } from './types'; // // MCP – Cloudflare Worker // Source: posthog/services/mcp/src/index.ts -// GET / → 200 (HTML landing page) +// GET / → 302 to posthog.com docs. The redirect proves the worker is up. // --------------------------------------------------------------------------- function downResult(error: string): BaseHealthResult { @@ -23,33 +24,52 @@ function downResult(error: string): BaseHealthResult { async function fetchEndpointHealth( url: string, timeoutMs = 5000, - expectedStatus = 200, + isExpectedStatus: (status: number) => boolean = (s) => s === 200, + redirect: 'follow' | 'manual' | 'error' = 'follow', ): Promise { - try { - const controller = new AbortController(); - const tid = setTimeout(() => controller.abort(), timeoutMs); - const res = await fetch(url, { signal: controller.signal }); - clearTimeout(tid); - - if (res.status === expectedStatus) { - return { - status: ServiceHealthStatus.Healthy, - rawIndicator: `HTTP ${res.status}`, - }; + const result = await (async (): Promise => { + try { + const controller = new AbortController(); + const tid = setTimeout(() => controller.abort(), timeoutMs); + const res = await fetch(url, { + signal: controller.signal, + redirect, + }); + clearTimeout(tid); + + if (isExpectedStatus(res.status)) { + return { + status: ServiceHealthStatus.Healthy, + rawIndicator: `HTTP ${res.status}`, + }; + } + return downResult(`HTTP ${res.status}`); + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') + return downResult(`Request timed out after ${timeoutMs}ms`); + return downResult(e instanceof Error ? e.message : 'Unknown error'); } - return downResult(`HTTP ${res.status}`); - } catch (e) { - if (e instanceof Error && e.name === 'AbortError') - return downResult('Request timed out'); - return downResult(e instanceof Error ? e.message : 'Unknown error'); - } + })(); + + logToFile( + `[health-checks] GET ${url} -> ${result.status}` + + `${result.rawIndicator ? ` (${result.rawIndicator})` : ''}` + + `${result.error ? ` (${result.error})` : ''}`, + ); + return result; } export const checkLlmGatewayHealth = (): Promise => fetchEndpointHealth('https://gateway.us.posthog.com/_liveness'); export const checkMcpHealth = (): Promise => - fetchEndpointHealth('https://mcp.posthog.com/'); + fetchEndpointHealth( + 'https://mcp.posthog.com/', + 5000, + // 2xx-3xx counts as up (redirect to docs) + (s) => s >= 200 && s < 400, + 'manual', + ); export const checkGithubReleasesHealth = (): Promise => fetchEndpointHealth(`${REMOTE_SKILLS_BASE_URL}/skill-menu.json`); diff --git a/src/lib/health-checks/readiness.ts b/src/lib/health-checks/readiness.ts index 1056bbca..27dbe7ca 100644 --- a/src/lib/health-checks/readiness.ts +++ b/src/lib/health-checks/readiness.ts @@ -194,7 +194,11 @@ export async function evaluateWizardReadiness( const blockingKeys = getBlockingServiceKeys(health, config); if (blockingKeys.length > 0) { - logToFile(`[health-checks] blocked by: ${blockingKeys.join(', ')}`); + const blockingDetails = blockingKeys.map((key) => { + const h = health[key]; + return `${key} (${h.status}${h.error ? ` — ${h.error}` : ''})`; + }); + logToFile(`[health-checks] blocked by: ${blockingDetails.join(', ')}`); return { decision: WizardReadiness.No, health, reasons }; } diff --git a/src/lib/programs/shared/health-check-step.ts b/src/lib/programs/shared/health-check-step.ts index 9134d03b..1c630a52 100644 --- a/src/lib/programs/shared/health-check-step.ts +++ b/src/lib/programs/shared/health-check-step.ts @@ -19,6 +19,7 @@ import { SIGNUP_WIZARD_READINESS_CONFIG, getBlockingServiceKeys, } from '@lib/health-checks/readiness'; +import { logToFile } from '@utils/debug'; export function healthCheckReady(session: WizardSession): boolean { if (!session.readinessResult) return false; @@ -49,9 +50,13 @@ export const HEALTH_CHECK_STEP: ProgramStep = { onInit: (ctx) => { evaluateWizardReadiness() .then((readiness) => { + logToFile( + `[health-checks] TUI pre-flight complete: decision=${readiness.decision}`, + ); ctx.setReadinessResult(readiness); }) - .catch(() => { + .catch((err) => { + logToFile('[health-checks] TUI pre-flight failed:', err); ctx.setReadinessResult({ decision: WizardReadiness.Yes, health: {} as never, diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index bb5fd39c..de8cf1db 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -1,5 +1,5 @@ import { VERSION } from '@lib/version'; -import { runtimeEnv } from '@env'; +import { logToFile, getLogFilePath } from '@utils/debug'; import type { ProgramConfig } from '@lib/programs/program-step'; import type { startTUI as StartTUIFn } from '@ui/tui/start-tui'; import type { TaskStreamPush as TaskStreamPushClass } from '@lib/task-stream/task-stream-push'; @@ -31,7 +31,6 @@ export function runWizard( const { PostHogDestination } = await import( '@lib/task-stream/destinations/posthog' ); - const { logToFile } = await import('@utils/debug'); // eslint-disable-next-line @typescript-eslint/no-explicit-any tui = startTUI(WIZARD_VERSION, config.id as any); @@ -81,17 +80,23 @@ export function runWizard( onSignal = (): void => { if (signalled || exitInProgress) return; signalled = true; + logToFile('[run-wizard] signal received, flushing task stream'); if (activeTui.store.session.runPhase === RunPhase.Running) { activeTui.store.setRunPhase(RunPhase.Error); } - void activeStream.shutdown(2000).finally(() => { - try { - activeTui.unmount(); - } catch { - // terminal may already be torn down - } - process.exit(130); - }); + void activeStream + .shutdown(2000) + .catch((e) => + logToFile('[run-wizard] task stream shutdown error on signal:', e), + ) + .finally(() => { + try { + activeTui.unmount(); + } catch { + // terminal may already be torn down + } + process.exit(130); + }); }; process.on('SIGINT', onSignal); process.on('SIGTERM', onSignal); @@ -148,10 +153,8 @@ export function runWizard( activeTui.unmount(); process.exit(0); } catch (err) { - if (runtimeEnv('DEBUG') || runtimeEnv('POSTHOG_WIZARD_DEBUG')) { - // eslint-disable-next-line no-console - console.error('TUI init failed:', err); - } + // File-log first — the cleanup below can throw or exit. + logToFile('[run-wizard] FATAL:', err); // The task-stream debounce timer keeps the event loop alive, so // we have to drain it before exiting on the error path. exitInProgress = true; @@ -173,6 +176,11 @@ export function runWizard( // ignore } } + // Print after unmount — anything printed into the alt screen is wiped. + // eslint-disable-next-line no-console + console.error('Wizard run failed:', err); + // eslint-disable-next-line no-console + console.error(`Full logs: ${getLogFilePath()}`); process.exit(1); } })(); diff --git a/src/ui/tui/primitives/ScreenErrorBoundary.tsx b/src/ui/tui/primitives/ScreenErrorBoundary.tsx index 4d56f0af..6320b7a3 100644 --- a/src/ui/tui/primitives/ScreenErrorBoundary.tsx +++ b/src/ui/tui/primitives/ScreenErrorBoundary.tsx @@ -9,6 +9,7 @@ import { Box, Text } from 'ink'; import { Component, type ReactNode } from 'react'; import type { WizardStore } from '@ui/tui/store'; import { OutroKind, RunPhase } from '@lib/wizard-session'; +import { logToFile } from '@utils/debug'; interface Props { store: WizardStore; @@ -29,6 +30,8 @@ export class ScreenErrorBoundary extends Component { componentDidCatch(error: Error): void { const { store } = this.props; + // The console.error below is wiped with the alt screen; this survives. + logToFile('[screen-error-boundary]', error); // eslint-disable-next-line no-console console.error('[ScreenErrorBoundary]', error.message, error.stack); diff --git a/src/ui/tui/start-tui.ts b/src/ui/tui/start-tui.ts index 18bd6d15..c5702aaa 100644 --- a/src/ui/tui/start-tui.ts +++ b/src/ui/tui/start-tui.ts @@ -13,6 +13,7 @@ import { InkUI } from './ink-ui.js'; import { setUI } from '@ui/index'; import { App } from './App.js'; import { OutroKind } from '@lib/wizard-session'; +import { logToFile } from '@utils/debug'; // ANSI escape sequences const RESET_ATTRS = '\x1b[0m'; @@ -72,6 +73,12 @@ export function startTUI( const cleanup = () => { if (cleaned) return; cleaned = true; + // Timestamp the teardown — everything printed into the alt screen dies here. + logToFile( + `[start-tui] unmounting TUI, leaving alt screen (exitCode=${ + process.exitCode ?? 'unset' + })`, + ); inkUnmount(); releaseTerminal(); process.stdout.write(getExitLine(store) + '\n'); diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index 910d47fb..97d46706 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -339,6 +339,7 @@ export class WizardStore { /** User dismissed the blocking outage screen. Gate resolves via _checkGates(). */ dismissOutage(): void { + logToFile('[health-checks] user dismissed outage screen, continuing'); this.$session.setKey('outageDismissed', true); this.emitChange(); } diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 44e8660e..b9558331 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -1,5 +1,6 @@ import { appendFileSync } from 'fs'; import path from 'path'; +import { inspect } from 'node:util'; import { getUI } from '@ui'; import { runtimeEnv } from '@env'; import { WIZARD_LOG_FILE } from './paths'; @@ -10,8 +11,14 @@ let consoleLoggingEnabled = false; function stringify(value: unknown): string { if (typeof value === 'string') return value; - if (value instanceof Error) return value.stack ?? ''; - return JSON.stringify(value, null, 2); + if (value instanceof Error) return value.stack ?? String(value); + try { + // JSON.stringify throws on cycles and skips some values — fall back to + // inspect so a crash log line is never dropped. + return JSON.stringify(value, null, 2) ?? inspect(value, { depth: 3 }); + } catch { + return inspect(value, { depth: 3 }); + } } function renderLine(args: readonly unknown[]): string { diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index 5a35d47e..74f84073 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -162,6 +162,8 @@ async function startCallbackServer( if (error) { const isAccessDenied = error === 'access_denied'; + const safeError = error.replace(/[^\x20-\x7e]/g, '').slice(0, 200); + logToFile(`[oauth] callback received with error: ${safeError}`); res.writeHead(isAccessDenied ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8', }); @@ -190,6 +192,7 @@ async function startCallbackServer( } if (code) { + logToFile('[oauth] callback received with authorization code'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(` @@ -273,24 +276,51 @@ async function exchangeCodeForToken( ): Promise { const clientId = IS_DEV ? POSTHOG_DEV_CLIENT_ID : POSTHOG_PROXY_CLIENT_ID; - const response = await axios.post( - `${POSTHOG_OAUTH_URL}/oauth/token`, - { - grant_type: 'authorization_code', - code, - redirect_uri: callbackUrl, - client_id: clientId, - code_verifier: codeVerifier, - }, - { - headers: { - 'Content-Type': 'application/json', - 'User-Agent': WIZARD_USER_AGENT, - }, - }, + logToFile( + `[oauth] exchanging code for token at ${POSTHOG_OAUTH_URL}/oauth/token`, ); + let response; + try { + response = await axios.post( + `${POSTHOG_OAUTH_URL}/oauth/token`, + { + grant_type: 'authorization_code', + code, + redirect_uri: callbackUrl, + client_id: clientId, + code_verifier: codeVerifier, + }, + { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': WIZARD_USER_AGENT, + }, + }, + ); + } catch (e) { + const status = axios.isAxiosError(e) ? e.response?.status : undefined; + logToFile( + `[oauth] token exchange failed${status ? ` (HTTP ${status})` : ''}:`, + e instanceof Error ? e.message : e, + ); + throw e; + } - return OAuthTokenResponseSchema.parse(response.data); + const token = OAuthTokenResponseSchema.parse(response.data); + logToFile( + `[oauth] token exchange succeeded, granted scopes: ${token.scope}` + + `${ + token.scoped_teams + ? `, scoped_teams: [${token.scoped_teams.join(', ')}]` + : '' + }` + + `${ + token.scoped_organizations + ? `, scoped_organizations: ${token.scoped_organizations.length}` + : '' + }`, + ); + return token; } export async function performOAuthFlow( @@ -301,6 +331,13 @@ export async function performOAuthFlow( const codeChallenge = generateCodeChallenge(codeVerifier); let shouldRetry = false; + logToFile( + `[oauth] starting flow against ${POSTHOG_OAUTH_URL} ` + + `(${ + IS_DEV ? 'dev' : 'prod' + } client), requested scopes: ${config.scopes.join(' ')}`, + ); + do { shouldRetry = false; let lastProcessInfo: { @@ -399,6 +436,7 @@ export async function performOAuthFlow( server.close(); const error = e instanceof Error ? e : new Error('Unknown error'); + logToFile('[oauth] flow failed:', error); if (error.message.includes('timeout')) { getUI().log.error('Authorization timed out. Please try again.'); diff --git a/src/utils/wizard-abort.ts b/src/utils/wizard-abort.ts index 5e7bb7fa..9b7013b0 100644 --- a/src/utils/wizard-abort.ts +++ b/src/utils/wizard-abort.ts @@ -7,6 +7,7 @@ * The legacy abort() in setup-utils.ts delegates here. */ import { analytics } from './analytics'; +import { logToFile } from './debug'; import { getUI } from '@ui'; import { OutroKind, type OutroData } from '@lib/wizard-session'; @@ -48,6 +49,11 @@ export async function wizardAbort( exitCode = 1, } = options ?? {}; + logToFile(`[wizard-abort] exitCode=${exitCode}, message: ${message}`); + if (error) { + logToFile('[wizard-abort] error:', error); + } + // 1. Run registered cleanup functions for (const fn of cleanupFns) { try {