diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index e3aa86bad..5a1c7e952 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -126,6 +126,7 @@ export const ErrorName = z.enum([ 'ServerError', 'TimeoutError', 'UnsupportedLanguageError', + 'UserCancellationError', 'ValidationError', 'UnknownError', ]); diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index 4602c1c14..3ab7256ec 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -1,5 +1,5 @@ -import { ConfigIO, SecureCredentials } from '../../../lib'; -import { AwsCredentialsError } from '../../../lib/errors/types'; +import { ConfigIO, SecureCredentials, toError } from '../../../lib'; +import { AwsCredentialsError, UserCancellationError } from '../../../lib/errors/types'; import type { DeployedState } from '../../../schema'; import { applyTargetRegionToEnv } from '../../aws'; import { validateAwsCredentials } from '../../aws/account'; @@ -66,6 +66,8 @@ export interface PreflightResult { hasTokenExpiredError: boolean; /** True if preflight failed due to missing AWS credentials (not configured) */ hasCredentialsError: boolean; + /** The error that caused preflight to fail, if any */ + lastError?: Error; /** Missing credentials that need to be provided */ missingCredentials: MissingCredential[]; /** KMS key ARN used for identity token vault encryption */ @@ -134,6 +136,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { Record >({}); const [teardownConfirmed, setTeardownConfirmed] = useState(false); + const lastErrorRef = useRef(undefined); // Guard against concurrent runs (React StrictMode, re-renders, etc.) const isRunningRef = useRef(false); @@ -157,6 +160,12 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { setSteps(BASE_PREFLIGHT_STEPS.map(s => ({ ...s, status: 'pending' as const }))); }; + const failPreflight = (err: unknown) => { + lastErrorRef.current = toError(err); + setPhase('error'); + isRunningRef.current = false; + }; + // Dispose wrapper and clear ref const disposeWrapper = useCallback(async () => { if (wrapperRef.current) { @@ -231,8 +240,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { }, []); const cancelTeardown = useCallback(() => { - setPhase('error'); - isRunningRef.current = false; + failPreflight(new UserCancellationError()); restoreRegionEnv(); }, [restoreRegionEnv]); @@ -269,6 +277,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { if (phase !== 'running') return; if (isRunningRef.current) return; isRunningRef.current = true; + lastErrorRef.current = undefined; const handleUnhandledRejection = (reason: unknown) => { const error = formatError(reason); @@ -287,8 +296,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } return prev; }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(reason); }; process.on('unhandledRejection', handleUnhandledRejection); @@ -329,8 +337,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { userMessage = getErrorMessage(err); } updateStep(STEP_VALIDATE, { status: 'error', error: userMessage }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } @@ -354,8 +361,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const userMessage = isInteractive && err instanceof AwsCredentialsError ? err.shortMessage : getErrorMessage(err); updateStep(STEP_VALIDATE, { status: 'error', error: userMessage }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } } @@ -369,8 +375,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const errorMsg = depsResult.errors.join('\n'); logger.endStep('error', errorMsg); updateStep(STEP_DEPS, { status: 'error', error: errorMsg }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error(errorMsg)); return; } // Log version info @@ -386,8 +391,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const errorMsg = formatError(err); logger.endStep('error', errorMsg); updateStep(STEP_DEPS, { status: 'error', error: logger.getFailureMessage('Check dependencies') }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } @@ -402,8 +406,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const errorMsg = formatError(err); logger.endStep('error', errorMsg); updateStep(STEP_BUILD, { status: 'error', error: logger.getFailureMessage('Build CDK project') }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } @@ -450,8 +453,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { status: 'error', error: logger.getFailureMessage('Synthesize CloudFormation'), }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } @@ -466,8 +468,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const errorMsg = stackStatus.message ?? `Stack ${stackStatus.blockingStack} is not in a deployable state`; logger.endStep('error', errorMsg); updateStepByLabel(LABEL_STACK_STATUS, { status: 'error', error: errorMsg }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error(errorMsg)); return; } logger.endStep('success'); @@ -482,8 +483,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { status: 'error', error: logger.getFailureMessage('Check stack status'), }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } } else { @@ -520,8 +520,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } return prev; }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); } }; @@ -566,8 +565,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { status: 'error', error: logger.getFailureMessage('Synthesize CloudFormation'), }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } @@ -582,8 +580,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const errorMsg = stackStatus.message ?? `Stack ${stackStatus.blockingStack} is not in a deployable state`; logger.endStep('error', errorMsg); updateStepByLabel(LABEL_STACK_STATUS, { status: 'error', error: errorMsg }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error(errorMsg)); return; } logger.endStep('success'); @@ -598,8 +595,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { status: 'error', error: logger.getFailureMessage('Check stack status'), }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } } else { @@ -647,8 +643,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } else if (hasOAuth) { updateStepByLabel(LABEL_OAUTH, { status: 'error', error: errorMsg }); } - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error(errorMsg)); return; } @@ -697,8 +692,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { if (identityResult.hasErrors) { logger.endStep('error', 'Some API key providers failed to set up'); updateStepByLabel(LABEL_API_KEY, { status: 'error', error: 'Some API key providers failed' }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error('Some API key providers failed to set up')); return; } @@ -741,8 +735,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { if (oauthResult.hasErrors) { logger.endStep('error', 'Some OAuth providers failed to set up'); updateStepByLabel(LABEL_OAUTH, { status: 'error', error: 'Some OAuth providers failed' }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error('Some OAuth providers failed to set up')); return; } @@ -807,8 +800,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { status: 'error', error: logger.getFailureMessage('Synthesize CloudFormation'), }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } @@ -822,8 +814,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const errorMsg = stackStatus.message ?? `Stack ${stackStatus.blockingStack} is not in a deployable state`; logger.endStep('error', errorMsg); updateStepByLabel(LABEL_STACK_STATUS, { status: 'error', error: errorMsg }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error(errorMsg)); return; } logger.endStep('success'); @@ -838,8 +829,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { status: 'error', error: logger.getFailureMessage('Check stack status'), }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } } else { @@ -872,8 +862,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { : s ) ); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); } }; @@ -908,8 +897,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { : s ) ); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); } }; @@ -925,6 +913,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { switchableIoHost, hasTokenExpiredError, hasCredentialsError, + lastError: lastErrorRef.current, missingCredentials, identityKmsKeyArn, allCredentials, diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 8b0295744..6a709438d 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -598,8 +598,20 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // Start deploy when preflight completes OR when shouldStartDeploy is set useEffect(() => { if (diffMode) return; // Diff mode uses its own effect - const shouldStart = skipPreflight ? shouldStartDeploy : preflight.phase === 'complete'; + const preflightDone = preflight.phase === 'complete' || preflight.phase === 'error'; + const shouldStart = skipPreflight ? shouldStartDeploy : preflightDone; if (!shouldStart) return; + + // Preflight failed — emit telemetry and bail + if (preflight.phase === 'error') { + const error = preflight.lastError ?? new Error('Preflight failed'); + const attrs = context ? computeDeployAttrs(context.projectSpec, 'deploy') : { ...DEFAULT_DEPLOY_ATTRS }; + withCommandRunTelemetry('deploy', attrs, () => ({ success: false as const, error })).catch(() => { + /* telemetry is best-effort */ + }); + return; + } + if (deployStep.status !== 'pending') return; if (!cdkToolkitWrapper) return; @@ -772,6 +784,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState }; void withCommandRunTelemetry('deploy', attrs, run); + // eslint-disable-next-line react-hooks/exhaustive-deps -- preflight.lastError and context are read only on error path }, [ preflight.phase, cdkToolkitWrapper, @@ -791,8 +804,22 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // Start diff when preflight completes (diff mode only) useEffect(() => { if (!diffMode) return; - const shouldStart = skipPreflight ? shouldStartDeploy : preflight.phase === 'complete'; + const preflightDone = preflight.phase === 'complete' || preflight.phase === 'error'; + const shouldStart = skipPreflight ? shouldStartDeploy : preflightDone; if (!shouldStart) return; + + // Preflight failed — emit telemetry and bail + if (preflight.phase === 'error') { + const error = preflight.lastError ?? new Error('Preflight failed'); + const attrs = context + ? computeDeployAttrs(context.projectSpec, 'diff') + : { ...DEFAULT_DEPLOY_ATTRS, deploy_mode: 'diff' as const }; + withCommandRunTelemetry('deploy', attrs, () => ({ success: false as const, error })).catch(() => { + /* telemetry is best-effort */ + }); + return; + } + if (diffStep.status !== 'pending') return; if (!cdkToolkitWrapper) return; @@ -845,6 +872,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState }; void withCommandRunTelemetry('deploy', attrs, run); + // eslint-disable-next-line react-hooks/exhaustive-deps -- preflight.lastError and context are read only on error path }, [ diffMode, preflight.phase, diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts index 16940a3d2..726312eba 100644 --- a/src/lib/errors/types.ts +++ b/src/lib/errors/types.ts @@ -290,3 +290,12 @@ export class PollExhaustedError extends BaseError { super(`Polling exhausted after ${maxAttempts} attempts`, { defaultSource: 'service', ...options }); } } + +/** + * Error indicating user cancellation interuption + */ +export class UserCancellationError extends BaseError { + constructor(options?: BaseErrorOptions) { + super(`User cancelled`, { defaultSource: 'user', ...options }); + } +}