Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli/telemetry/schemas/common-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export const ErrorName = z.enum([
'ServerError',
'TimeoutError',
'UnsupportedLanguageError',
'UserCancellationError',
'ValidationError',
'UnknownError',
]);
Expand Down
81 changes: 35 additions & 46 deletions src/cli/tui/hooks/useCdkPreflight.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -134,6 +136,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
Record<string, { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string }>
>({});
const [teardownConfirmed, setTeardownConfirmed] = useState(false);
const lastErrorRef = useRef<Error | undefined>(undefined);

// Guard against concurrent runs (React StrictMode, re-renders, etc.)
const isRunningRef = useRef(false);
Expand All @@ -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) {
Expand Down Expand Up @@ -231,8 +240,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
}, []);

const cancelTeardown = useCallback(() => {
setPhase('error');
isRunningRef.current = false;
failPreflight(new UserCancellationError());
restoreRegionEnv();
}, [restoreRegionEnv]);

Expand Down Expand Up @@ -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);
Expand All @@ -287,8 +296,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
}
return prev;
});
setPhase('error');
isRunningRef.current = false;
failPreflight(reason);
};

process.on('unhandledRejection', handleUnhandledRejection);
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
}
Expand All @@ -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
Expand All @@ -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;
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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');
Expand All @@ -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 {
Expand Down Expand Up @@ -520,8 +520,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
}
return prev;
});
setPhase('error');
isRunningRef.current = false;
failPreflight(err);
}
};

Expand Down Expand Up @@ -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;
}

Expand All @@ -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');
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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');
Expand All @@ -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 {
Expand Down Expand Up @@ -872,8 +862,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
: s
)
);
setPhase('error');
isRunningRef.current = false;
failPreflight(err);
}
};

Expand Down Expand Up @@ -908,8 +897,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
: s
)
);
setPhase('error');
isRunningRef.current = false;
failPreflight(err);
}
};

Expand All @@ -925,6 +913,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult {
switchableIoHost,
hasTokenExpiredError,
hasCredentialsError,
lastError: lastErrorRef.current,
missingCredentials,
identityKmsKeyArn,
allCredentials,
Expand Down
32 changes: 30 additions & 2 deletions src/cli/tui/screens/deploy/useDeployFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/lib/errors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
Loading