From c80d9a2700edc31ee1f5577376e5deb218aca9db Mon Sep 17 00:00:00 2001 From: notgitika Date: Wed, 25 Feb 2026 18:57:06 -0500 Subject: [PATCH] feat: add --diff flag to deploy command (#75) --- src/cli/cdk/toolkit-lib/types.ts | 10 +- src/cli/commands/deploy/actions.ts | 34 +++ src/cli/commands/deploy/command.tsx | 23 +- src/cli/commands/deploy/types.ts | 1 + .../__tests__/exec-logger-diff.test.ts | 103 +++++++ src/cli/logging/exec-logger.ts | 41 +++ src/cli/tui/components/DiffSummaryView.tsx | 255 ++++++++++++++++++ .../__tests__/DiffSummaryView.test.ts | 223 +++++++++++++++ src/cli/tui/components/index.ts | 1 + src/cli/tui/screens/deploy/DeployScreen.tsx | 90 ++++++- src/cli/tui/screens/deploy/useDeployFlow.ts | 177 +++++++++++- 11 files changed, 925 insertions(+), 33 deletions(-) create mode 100644 src/cli/logging/__tests__/exec-logger-diff.test.ts create mode 100644 src/cli/tui/components/DiffSummaryView.tsx create mode 100644 src/cli/tui/components/__tests__/DiffSummaryView.test.ts diff --git a/src/cli/cdk/toolkit-lib/types.ts b/src/cli/cdk/toolkit-lib/types.ts index d6a4c961..61f2039b 100644 --- a/src/cli/cdk/toolkit-lib/types.ts +++ b/src/cli/cdk/toolkit-lib/types.ts @@ -51,7 +51,7 @@ export interface SwitchableIoHost { /** Set callback to receive filtered deploy messages for TUI */ setOnMessage: (callback: ((msg: DeployMessage) => void) | null) => void; /** Set callback to receive ALL raw CDK messages for logging */ - setOnRawMessage: (callback: ((code: string, level: string, message: string) => void) | null) => void; + setOnRawMessage: (callback: ((code: string, level: string, message: string, data?: unknown) => void) | null) => void; } /** @@ -94,7 +94,7 @@ function extractProgressFromMessage(message: string): ResourceProgress | undefin export function createSwitchableIoHost(): SwitchableIoHost { let verbose = false; let onMessage: ((msg: DeployMessage) => void) | null = null; - let onRawMessage: ((code: string, level: string, message: string) => void) | null = null; + let onRawMessage: ((code: string, level: string, message: string, data?: unknown) => void) | null = null; const ioHost: IIoHost = { notify(msg): Promise { @@ -104,8 +104,8 @@ export function createSwitchableIoHost(): SwitchableIoHost { const level = msg.level ?? 'info'; const text = typeof msg.message === 'string' ? msg.message : ''; - // Log ALL messages for debugging - onRawMessage?.(code, level, text); + // Log ALL messages for debugging (pass data for structured access) + onRawMessage?.(code, level, text, msg.data); // Only pass filtered messages to TUI if (onMessage && msg.code && DEPLOY_MESSAGE_CODES.has(msg.code)) { @@ -150,7 +150,7 @@ export function createSwitchableIoHost(): SwitchableIoHost { setOnMessage: (cb: ((msg: DeployMessage) => void) | null) => { onMessage = cb; }, - setOnRawMessage: (cb: ((code: string, level: string, message: string) => void) | null) => { + setOnRawMessage: (cb: ((code: string, level: string, message: string, data?: unknown) => void) | null) => { onRawMessage = cb; }, }; diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 537f55b9..a6209945 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -23,6 +23,7 @@ export interface ValidatedDeployOptions { autoConfirm?: boolean; verbose?: boolean; plan?: boolean; + diff?: boolean; onProgress?: (step: string, status: 'start' | 'success' | 'error') => void; onResourceEvent?: (message: string) => void; } @@ -155,6 +156,39 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { + if (!message) return; + // I4002: formatted diff per stack, I4001: overall diff summary + if (code === 'CDK_TOOLKIT_I4002' || code === 'CDK_TOOLKIT_I4001') { + hasDiffContent = true; + console.log(message); + } + }); + diffIoHost.setVerbose(true); + await toolkitWrapper.diff(); + if (!hasDiffContent) { + console.log('No stack differences detected.'); + } + diffIoHost.setVerbose(false); + diffIoHost.setOnRawMessage(null); + endStep('success'); + + logger.finalize(true); + await toolkitWrapper.dispose(); + toolkitWrapper = null; + return { + success: true, + targetName: target.name, + stackName, + logPath: logger.getRelativeLogPath(), + }; + } + // Set up identity providers if needed let identityKmsKeyArn: string | undefined; if (hasOwnedIdentityApiProviders(context.projectSpec)) { diff --git a/src/cli/commands/deploy/command.tsx b/src/cli/commands/deploy/command.tsx index 95da0a33..4ac9c21a 100644 --- a/src/cli/commands/deploy/command.tsx +++ b/src/cli/commands/deploy/command.tsx @@ -11,13 +11,14 @@ import React from 'react'; const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; -function handleDeployTUI(options: { autoConfirm?: boolean } = {}): void { +function handleDeployTUI(options: { autoConfirm?: boolean; diffMode?: boolean } = {}): void { requireProject(); const { unmount } = render( { unmount(); process.exit(0); @@ -71,8 +72,9 @@ async function handleDeployCLI(options: DeployOptions): Promise { const result = await handleDeploy({ target: options.target!, autoConfirm: options.yes, - verbose: options.verbose, + verbose: options.verbose ?? options.diff, plan: options.plan, + diff: options.diff, onProgress, onResourceEvent, }); @@ -85,7 +87,9 @@ async function handleDeployCLI(options: DeployOptions): Promise { if (options.json) { console.log(JSON.stringify(result)); } else if (result.success) { - if (options.plan) { + if (options.diff) { + console.log(`\n✓ Diff complete for '${result.targetName}' (stack: ${result.stackName})`); + } else if (options.plan) { console.log(`\n✓ Plan complete for '${result.targetName}' (stack: ${result.stackName})`); console.log('\nRun `agentcore deploy` to deploy.'); } else { @@ -127,8 +131,16 @@ export const registerDeploy = (program: Command) => { .option('-v, --verbose', 'Show resource-level deployment events [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') .option('--plan', 'Preview deployment without deploying (dry-run) [non-interactive]') + .option('--diff', 'Show CDK diff without deploying [non-interactive]') .action( - async (cliOptions: { target?: string; yes?: boolean; verbose?: boolean; json?: boolean; plan?: boolean }) => { + async (cliOptions: { + target?: string; + yes?: boolean; + verbose?: boolean; + json?: boolean; + plan?: boolean; + diff?: boolean; + }) => { try { requireProject(); if (cliOptions.json || cliOptions.target || cliOptions.plan || cliOptions.yes || cliOptions.verbose) { @@ -139,6 +151,9 @@ export const registerDeploy = (program: Command) => { progress: !cliOptions.json, }; await handleDeployCLI(options as DeployOptions); + } else if (cliOptions.diff) { + // Diff-only: use TUI with diff mode + handleDeployTUI({ diffMode: true }); } else { handleDeployTUI(); } diff --git a/src/cli/commands/deploy/types.ts b/src/cli/commands/deploy/types.ts index 28fe1c7a..ec5c0323 100644 --- a/src/cli/commands/deploy/types.ts +++ b/src/cli/commands/deploy/types.ts @@ -5,6 +5,7 @@ export interface DeployOptions { verbose?: boolean; json?: boolean; plan?: boolean; + diff?: boolean; } export interface DeployResult { diff --git a/src/cli/logging/__tests__/exec-logger-diff.test.ts b/src/cli/logging/__tests__/exec-logger-diff.test.ts new file mode 100644 index 00000000..332bad39 --- /dev/null +++ b/src/cli/logging/__tests__/exec-logger-diff.test.ts @@ -0,0 +1,103 @@ +import { ExecLogger } from '../exec-logger.js'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +describe('ExecLogger.logDiff', () => { + let tempDir: string; + let logger: ExecLogger; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'exec-logger-test-')); + // Create the agentcore/.cli/logs/deploy directory structure + const agentcoreDir = path.join(tempDir, 'agentcore'); + logger = new ExecLogger({ command: 'deploy', baseDir: agentcoreDir }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function getLogContent(): string { + return readFileSync(logger.logFilePath, 'utf-8'); + } + + it('writes I4002 messages as a section with dividers', () => { + logger.logDiff('CDK_TOOLKIT_I4002', 'Stack MyStack\nResources\n[+] AWS::S3::Bucket MyBucket'); + + const content = getLogContent(); + expect(content).toContain('─'.repeat(80)); + expect(content).toContain('Stack MyStack'); + expect(content).toContain('Resources'); + expect(content).toContain('[+] AWS::S3::Bucket MyBucket'); + }); + + it('strips ANSI escape codes from logged output', () => { + const ansiMessage = 'Stack \x1b[1mMyStack\x1b[22m\n\x1b[32m[+]\x1b[39m Resource'; + + logger.logDiff('CDK_TOOLKIT_I4002', ansiMessage); + + const content = getLogContent(); + expect(content).toContain('Stack MyStack'); + expect(content).toContain('[+] Resource'); + expect(content).not.toContain('\x1b['); + }); + + it('strips underline and other ANSI sequences (not just color)', () => { + const ansiMessage = '\x1b[4m\x1b[1mResources\x1b[22m\x1b[24m'; + + logger.logDiff('CDK_TOOLKIT_I4002', ansiMessage); + + const content = getLogContent(); + expect(content).toContain('Resources'); + expect(content).not.toContain('\x1b['); + }); + + it('writes I4001 messages as a plain summary line', () => { + logger.logDiff('CDK_TOOLKIT_I4001', '✨ Number of stacks with differences: 2'); + + const content = getLogContent(); + expect(content).toContain('✨ Number of stacks with differences: 2'); + // Should NOT have section dividers + expect(content.split('─'.repeat(80))).toHaveLength(1); + }); + + it('logs other multi-line messages line by line with timestamps', () => { + logger.logDiff('UNKNOWN', 'Line one\nLine two\nLine three'); + + const content = getLogContent(); + // Each non-empty line should have a timestamp + const lines = content.split('\n'); + const loggedLines = lines.filter(l => l.includes('Line')); + expect(loggedLines).toHaveLength(3); + for (const line of loggedLines) { + expect(line).toMatch(/\[\d{2}:\d{2}:\d{2}\]/); + } + }); + + it('logs single-line messages with a timestamp', () => { + logger.logDiff('CDK_SDK_I0100', 'STS.AssumeRole -> OK'); + + const content = getLogContent(); + expect(content).toContain('STS.AssumeRole -> OK'); + const line = content.split('\n').find(l => l.includes('STS.AssumeRole')); + expect(line).toMatch(/\[\d{2}:\d{2}:\d{2}\]/); + }); + + it('skips empty messages', () => { + const before = getLogContent(); + logger.logDiff('UNKNOWN', ''); + const after = getLogContent(); + + expect(after).toBe(before); + }); + + it('skips blank lines in multi-line other messages', () => { + logger.logDiff('UNKNOWN', 'Line one\n\n\nLine two'); + + const content = getLogContent(); + const loggedLines = content.split('\n').filter(l => l.includes('Line')); + expect(loggedLines).toHaveLength(2); + }); +}); diff --git a/src/cli/logging/exec-logger.ts b/src/cli/logging/exec-logger.ts index 1c032a16..cc708e86 100644 --- a/src/cli/logging/exec-logger.ts +++ b/src/cli/logging/exec-logger.ts @@ -2,6 +2,14 @@ import { CLI_LOGS_DIR, CLI_SYSTEM_DIR, CONFIG_DIR, findConfigRoot } from '../../ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; +// eslint-disable-next-line no-control-regex +const ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]/g; + +/** Strip ANSI escape codes from a string. */ +function stripAnsi(s: string): string { + return s.replace(ANSI_REGEX, ''); +} + export interface ExecLoggerOptions { /** Command name for the log file (e.g., 'deploy', 'destroy') */ command: string; @@ -160,6 +168,39 @@ ${separator} this.appendLine(`[${this.formatTime()}] ${levelPrefix}${message}`); } + /** + * Log a CDK diff block. Strips ANSI codes and writes each line cleanly. + * Multi-line messages (like I4002 per-stack diffs) are written with a section header. + */ + logDiff(code: string, message: string): void { + if (!message) return; + const clean = stripAnsi(message); + const lines = clean.split('\n'); + + if (code === 'CDK_TOOLKIT_I4002') { + // Per-stack diff — write as a clear section + this.appendLine(''); + this.appendLine(`${'─'.repeat(80)}`); + for (const line of lines) { + this.appendLine(line); + } + this.appendLine(`${'─'.repeat(80)}`); + } else if (code === 'CDK_TOOLKIT_I4001') { + // Overall diff summary + this.appendLine(''); + this.appendLine(clean); + } else if (lines.length > 1) { + // Other multi-line messages — log each line + for (const line of lines) { + if (line.trim()) { + this.appendLine(`[${this.formatTime()}] ${line}`); + } + } + } else { + this.appendLine(`[${this.formatTime()}] ${clean}`); + } + } + /** * Finalize the log file with a summary */ diff --git a/src/cli/tui/components/DiffSummaryView.tsx b/src/cli/tui/components/DiffSummaryView.tsx new file mode 100644 index 00000000..2e5147b9 --- /dev/null +++ b/src/cli/tui/components/DiffSummaryView.tsx @@ -0,0 +1,255 @@ +import { Box, Text, useInput, useStdout } from 'ink'; +import React, { useMemo, useState } from 'react'; + +/** A single resource or output change in the diff. */ +export interface DiffChange { + kind: 'add' | 'modify' | 'remove'; + resourceType: string; + logicalId: string; + /** Property-level changes for modifications */ + details?: string[]; +} + +/** A section of the diff (Resources, Outputs, etc.). */ +export interface DiffSection { + name: string; + added: number; + modified: number; + removed: number; + changes: DiffChange[]; +} + +/** Parsed summary of a stack diff. */ +export interface StackDiffSummary { + stackName: string; + sections: DiffSection[]; + hasSecurityChanges: boolean; + securitySummary?: string; + totalChanges: number; +} + +/** CDK I4002 StackDiff data shape (partial — only what we need). */ +interface CdkStackDiffData { + formattedDiff?: { + diff?: string; + security?: string; + }; + permissionChanges?: string; +} + +/** + * Parse CDK I4002 structured data into a StackDiffSummary. + * Falls back to text parsing if structured data is unavailable. + */ +export function parseStackDiff(data: unknown, messageText: string): StackDiffSummary { + const typed = data as CdkStackDiffData | undefined; + const diffText = typed?.formattedDiff?.diff ?? messageText; + const securityText = typed?.formattedDiff?.security; + const permissionChanges = typed?.permissionChanges ?? 'none'; + + // eslint-disable-next-line no-control-regex + const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); + + // Extract stack name from first line + const lines = diffText.split('\n'); + let stackName = 'Unknown Stack'; + const stackLine = lines.find(l => stripAnsi(l).trimStart().startsWith('Stack ')); + if (stackLine) { + stackName = stripAnsi(stackLine) + .trim() + .replace(/^Stack\s+/, ''); + } + + const sections: DiffSection[] = []; + let currentSection: DiffSection | null = null; + let currentChange: DiffChange | null = null; + + for (const rawLine of lines) { + const line = stripAnsi(rawLine).trimEnd(); + const trimmed = line.trimStart(); + + // Section headers: "Resources", "Outputs", "Parameters", "Conditions" + if (/^(Resources|Outputs|Parameters|Conditions|Mappings|Metadata)\s*$/.test(trimmed)) { + if (currentSection && currentSection.changes.length > 0) { + sections.push(currentSection); + } + currentSection = { name: trimmed.trim(), added: 0, modified: 0, removed: 0, changes: [] }; + currentChange = null; + continue; + } + + if (!currentSection) continue; + + // Resource/output change lines: "[+] AWS::Type LogicalId" or "[~] ..." or "[-] ..." + const changeMatch = /^\[([+~-])\]\s+(\S+)\s+(\S+)/.exec(trimmed); + if (changeMatch) { + const kind: DiffChange['kind'] = changeMatch[1] === '+' ? 'add' : changeMatch[1] === '~' ? 'modify' : 'remove'; + currentChange = { + kind, + resourceType: changeMatch[2]!, + logicalId: changeMatch[3]!, + details: [], + }; + currentSection.changes.push(currentChange); + if (kind === 'add') currentSection.added++; + else if (kind === 'modify') currentSection.modified++; + else currentSection.removed++; + continue; + } + + // Detail lines (indented under a change): "└─ [~] PropertyName" or "├─ [+] ..." + const detailMatch = /[└├]─\s*\[([+~-])\]\s+(.+)/.exec(trimmed); + if (detailMatch && currentChange) { + currentChange.details ??= []; + currentChange.details.push(detailMatch[2]!); + } + } + + // Push last section + if (currentSection && currentSection.changes.length > 0) { + sections.push(currentSection); + } + + const hasSecurityChanges = permissionChanges !== 'none' || !!securityText; + const totalChanges = sections.reduce((sum, s) => sum + s.changes.length, 0); + + let securitySummary: string | undefined; + if (hasSecurityChanges) { + if (permissionChanges === 'broadening') { + securitySummary = 'IAM policy broadening detected'; + } else if (securityText) { + securitySummary = 'IAM statement changes detected'; + } else { + securitySummary = 'IAM policy changes detected'; + } + } + + return { stackName, sections, hasSecurityChanges, securitySummary, totalChanges }; +} + +/** Parse CDK I4001 overall summary data. */ +export function parseDiffResult(data: unknown): { numStacksWithChanges: number } { + const typed = data as { numStacksWithChanges?: number } | undefined; + return { numStacksWithChanges: typed?.numStacksWithChanges ?? 0 }; +} + +// ─── Component ─────────────────────────────────────────────────────────── + +interface DiffSummaryViewProps { + summaries: StackDiffSummary[]; + numStacksWithChanges?: number; + isActive?: boolean; + maxHeight?: number; +} + +const CHANGE_ICON = { add: '+', modify: '~', remove: '-' } as const; +const CHANGE_COLOR = { add: 'green', modify: 'yellow', remove: 'red' } as const; + +/** Structured, scrollable CDK diff summary view. */ +export function DiffSummaryView({ summaries, numStacksWithChanges, isActive = true, maxHeight }: DiffSummaryViewProps) { + const { stdout } = useStdout(); + const [scrollOffset, setScrollOffset] = useState(0); + + // Build all display lines as structured data for rendering + const displayLines = useMemo(() => { + const lines: { text: string; color?: string; bold?: boolean; dim?: boolean }[] = []; + + for (const summary of summaries) { + if (summary.totalChanges === 0) { + lines.push({ text: `Stack ${summary.stackName}`, color: 'cyan', bold: true }); + lines.push({ text: ' No differences', dim: true }); + lines.push({ text: '' }); + continue; + } + + lines.push({ text: `Stack ${summary.stackName}`, color: 'cyan', bold: true }); + lines.push({ text: '' }); + + for (const section of summary.sections) { + // Section header with counts + const counts = [ + section.added > 0 ? `${section.added} added` : '', + section.modified > 0 ? `${section.modified} modified` : '', + section.removed > 0 ? `${section.removed} removed` : '', + ] + .filter(Boolean) + .join(', '); + lines.push({ text: ` ${section.name} (${counts})`, bold: true }); + lines.push({ text: '' }); + + for (const change of section.changes) { + const icon = CHANGE_ICON[change.kind]; + const color = CHANGE_COLOR[change.kind]; + // Pad resource type for alignment + const typeStr = change.resourceType.padEnd(30); + lines.push({ text: ` [${icon}] ${typeStr} ${change.logicalId}`, color }); + + // Show property-level details for modifications + if (change.details && change.details.length > 0) { + for (const detail of change.details) { + lines.push({ text: ` └─ ${detail}`, dim: true }); + } + } + } + lines.push({ text: '' }); + } + + // Security warning + if (summary.hasSecurityChanges && summary.securitySummary) { + lines.push({ text: ` ⚠ Security: ${summary.securitySummary}`, color: 'yellow', bold: true }); + lines.push({ text: '' }); + } + } + + // Overall summary + if (numStacksWithChanges !== undefined) { + lines.push({ text: `✨ ${numStacksWithChanges} stack(s) with differences`, dim: true }); + } + + return lines; + }, [summaries, numStacksWithChanges]); + + const terminalHeight = stdout?.rows ?? 24; + // Use caller-supplied maxHeight, or fall back to terminal height with generous chrome margin + const effectiveMax = maxHeight ?? Math.max(6, terminalHeight - 16); + const displayHeight = Math.min(effectiveMax, displayLines.length); + const totalLines = displayLines.length; + const maxScroll = Math.max(0, totalLines - displayHeight); + const needsScroll = totalLines > displayHeight; + + useInput( + (_input, key) => { + if (!needsScroll) return; + if (key.upArrow) setScrollOffset(prev => Math.max(0, prev - 1)); + if (key.downArrow) setScrollOffset(prev => Math.min(maxScroll, prev + 1)); + if (key.pageUp) setScrollOffset(prev => Math.max(0, prev - displayHeight)); + if (key.pageDown) setScrollOffset(prev => Math.min(maxScroll, prev + displayHeight)); + }, + { isActive: isActive && needsScroll } + ); + + const visibleLines = displayLines.slice(scrollOffset, scrollOffset + displayHeight); + + return ( + + + {visibleLines.map((line, idx) => ( + + {line.text || ' '} + + ))} + + {needsScroll && ( + + [{scrollOffset + 1}-{Math.min(scrollOffset + displayHeight, totalLines)} of {totalLines}] ↑↓ PgUp/PgDn + + )} + + ); +} diff --git a/src/cli/tui/components/__tests__/DiffSummaryView.test.ts b/src/cli/tui/components/__tests__/DiffSummaryView.test.ts new file mode 100644 index 00000000..b89d560d --- /dev/null +++ b/src/cli/tui/components/__tests__/DiffSummaryView.test.ts @@ -0,0 +1,223 @@ +import { parseDiffResult, parseStackDiff } from '../DiffSummaryView.js'; +import { describe, expect, it } from 'vitest'; + +// ─── Fixtures ─────────────────────────────────────────────────────────── + +/** Minimal CDK diff output for a single stack with one resource added. */ +const SINGLE_ADD_DIFF = `Stack MyStack + +Resources +[+] AWS::IAM::Role MyRole MyRoleLogicalId`; + +/** CDK diff output with ANSI escape codes (bold, color, underline). */ +const ANSI_DIFF = [ + 'Stack \x1b[1mMyStack\x1b[22m', + '', + '\x1b[4m\x1b[1mResources\x1b[22m\x1b[24m', + '\x1b[32m[+]\x1b[39m \x1b[36mAWS::Lambda::Function\x1b[39m MyFunc \x1b[90mMyFuncLogicalId\x1b[39m', +].join('\n'); + +/** CDK diff with multiple sections and change types. */ +const MULTI_SECTION_DIFF = `Stack ProdStack + +Resources +[+] AWS::IAM::Role NewRole NewRoleLogicalId +[~] AWS::Lambda::Function ExistingFunc ExistingFuncLogicalId + └─ [~] Runtime + ├─ [~] Timeout +[-] AWS::S3::Bucket OldBucket OldBucketLogicalId + +Outputs +[+] Output StackOutput StackOutputLogicalId`; + +/** CDK diff with no resource changes. */ +const EMPTY_DIFF = `Stack EmptyStack +There were no differences`; + +/** Structured CDK I4002 data with formattedDiff and permissionChanges. */ +const STRUCTURED_DATA = { + formattedDiff: { + diff: SINGLE_ADD_DIFF, + security: 'IAM Statement Changes\nSome security info', + }, + permissionChanges: 'broadening', +}; + +const STRUCTURED_DATA_NONE = { + formattedDiff: { + diff: SINGLE_ADD_DIFF, + }, + permissionChanges: 'none', +}; + +// ─── parseStackDiff ───────────────────────────────────────────────────── + +describe('parseStackDiff', () => { + it('parses a simple single-resource addition', () => { + const result = parseStackDiff(undefined, SINGLE_ADD_DIFF); + + expect(result.stackName).toBe('MyStack'); + expect(result.totalChanges).toBe(1); + expect(result.sections).toHaveLength(1); + expect(result.sections[0]!.name).toBe('Resources'); + expect(result.sections[0]!.added).toBe(1); + expect(result.sections[0]!.modified).toBe(0); + expect(result.sections[0]!.removed).toBe(0); + expect(result.sections[0]!.changes[0]).toEqual({ + kind: 'add', + resourceType: 'AWS::IAM::Role', + logicalId: 'MyRole', + details: [], + }); + }); + + it('strips ANSI escape codes from diff text', () => { + const result = parseStackDiff(undefined, ANSI_DIFF); + + expect(result.stackName).toBe('MyStack'); + expect(result.sections).toHaveLength(1); + expect(result.sections[0]!.changes[0]!.resourceType).toBe('AWS::Lambda::Function'); + expect(result.sections[0]!.changes[0]!.logicalId).toBe('MyFunc'); + }); + + it('parses multiple sections with add, modify, and remove', () => { + const result = parseStackDiff(undefined, MULTI_SECTION_DIFF); + + expect(result.stackName).toBe('ProdStack'); + expect(result.totalChanges).toBe(4); + expect(result.sections).toHaveLength(2); + + const resources = result.sections[0]!; + expect(resources.name).toBe('Resources'); + expect(resources.added).toBe(1); + expect(resources.modified).toBe(1); + expect(resources.removed).toBe(1); + expect(resources.changes).toHaveLength(3); + + // Verify change kinds + expect(resources.changes[0]!.kind).toBe('add'); + expect(resources.changes[1]!.kind).toBe('modify'); + expect(resources.changes[2]!.kind).toBe('remove'); + + const outputs = result.sections[1]!; + expect(outputs.name).toBe('Outputs'); + expect(outputs.added).toBe(1); + expect(outputs.changes).toHaveLength(1); + }); + + it('parses property-level detail lines for modifications', () => { + const result = parseStackDiff(undefined, MULTI_SECTION_DIFF); + + const modifiedResource = result.sections[0]!.changes[1]!; + expect(modifiedResource.kind).toBe('modify'); + expect(modifiedResource.details).toEqual(['Runtime', 'Timeout']); + }); + + it('returns zero totalChanges for a diff with no resource changes', () => { + const result = parseStackDiff(undefined, EMPTY_DIFF); + + expect(result.stackName).toBe('EmptyStack'); + expect(result.totalChanges).toBe(0); + expect(result.sections).toHaveLength(0); + }); + + it('uses structured data formattedDiff when available', () => { + const result = parseStackDiff(STRUCTURED_DATA, 'fallback message'); + + // Should use formattedDiff.diff, not the fallback message + expect(result.stackName).toBe('MyStack'); + expect(result.totalChanges).toBe(1); + }); + + it('falls back to message text when structured data is undefined', () => { + const result = parseStackDiff(undefined, SINGLE_ADD_DIFF); + + expect(result.stackName).toBe('MyStack'); + expect(result.totalChanges).toBe(1); + }); + + it('detects security changes from permissionChanges=broadening', () => { + const result = parseStackDiff(STRUCTURED_DATA, ''); + + expect(result.hasSecurityChanges).toBe(true); + expect(result.securitySummary).toBe('IAM policy broadening detected'); + }); + + it('detects security changes from security text', () => { + const data = { + formattedDiff: { + diff: SINGLE_ADD_DIFF, + security: 'Some security changes', + }, + permissionChanges: 'none', + }; + const result = parseStackDiff(data, ''); + + expect(result.hasSecurityChanges).toBe(true); + expect(result.securitySummary).toBe('IAM statement changes detected'); + }); + + it('reports no security changes when permissionChanges=none and no security text', () => { + const result = parseStackDiff(STRUCTURED_DATA_NONE, ''); + + expect(result.hasSecurityChanges).toBe(false); + expect(result.securitySummary).toBeUndefined(); + }); + + it('defaults stack name to Unknown Stack when no Stack line found', () => { + const result = parseStackDiff(undefined, 'Resources\n[+] AWS::S3::Bucket MyBucket MyBucketId'); + + expect(result.stackName).toBe('Unknown Stack'); + expect(result.totalChanges).toBe(1); + }); + + it('handles all CDK section types', () => { + const diff = `Stack TestStack + +Parameters +[+] Parameter BootstrapVersion BootstrapVersionParam + +Conditions +[+] Condition HasBucket HasBucketCondition + +Mappings +[+] Mapping RegionMap RegionMapId + +Resources +[+] AWS::S3::Bucket MyBucket MyBucketId`; + + const result = parseStackDiff(undefined, diff); + + expect(result.sections).toHaveLength(4); + expect(result.sections.map(s => s.name)).toEqual(['Parameters', 'Conditions', 'Mappings', 'Resources']); + expect(result.totalChanges).toBe(4); + }); +}); + +// ─── parseDiffResult ──────────────────────────────────────────────────── + +describe('parseDiffResult', () => { + it('extracts numStacksWithChanges from structured data', () => { + const result = parseDiffResult({ numStacksWithChanges: 3 }); + + expect(result.numStacksWithChanges).toBe(3); + }); + + it('defaults to 0 when numStacksWithChanges is missing', () => { + const result = parseDiffResult({}); + + expect(result.numStacksWithChanges).toBe(0); + }); + + it('defaults to 0 when data is undefined', () => { + const result = parseDiffResult(undefined); + + expect(result.numStacksWithChanges).toBe(0); + }); + + it('handles zero stacks with changes', () => { + const result = parseDiffResult({ numStacksWithChanges: 0 }); + + expect(result.numStacksWithChanges).toBe(0); + }); +}); diff --git a/src/cli/tui/components/index.ts b/src/cli/tui/components/index.ts index f125302f..c07946aa 100644 --- a/src/cli/tui/components/index.ts +++ b/src/cli/tui/components/index.ts @@ -27,3 +27,4 @@ export { AwsTargetConfigUI, getAwsConfigHelpText } from './AwsTargetConfigUI'; export { ResourceGraph, type AgentStatusInfo } from './ResourceGraph'; export { LogLink } from './LogLink'; export { ScrollableText } from './ScrollableText'; +export { DiffSummaryView, parseStackDiff, parseDiffResult, type StackDiffSummary } from './DiffSummaryView'; diff --git a/src/cli/tui/screens/deploy/DeployScreen.tsx b/src/cli/tui/screens/deploy/DeployScreen.tsx index 55e05e67..ee6b332e 100644 --- a/src/cli/tui/screens/deploy/DeployScreen.tsx +++ b/src/cli/tui/screens/deploy/DeployScreen.tsx @@ -5,6 +5,7 @@ import { ConfirmPrompt, CredentialSourcePrompt, DeployStatus, + DiffSummaryView, LogLink, type NextStep, NextSteps, @@ -17,7 +18,7 @@ import { BOOTSTRAP, HELP_TEXT } from '../../constants'; import { useAwsTargetConfig } from '../../hooks'; import { InvokeScreen } from '../invoke'; import { type PreSynthesized, useDeployFlow } from './useDeployFlow'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text, useInput, useStdout } from 'ink'; import React, { useEffect, useMemo, useState } from 'react'; interface DeployScreenProps { @@ -29,6 +30,8 @@ interface DeployScreenProps { onNavigate?: (command: string) => void; /** Skip preflight and use pre-synthesized context (from plan command) */ preSynthesized?: PreSynthesized; + /** Run CDK diff instead of deploying */ + diffMode?: boolean; } /** Next steps shown after successful deployment */ @@ -37,10 +40,19 @@ const DEPLOY_NEXT_STEPS: NextStep[] = [ { command: 'status', label: 'View deployment status' }, ]; -export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, preSynthesized }: DeployScreenProps) { +export function DeployScreen({ + isInteractive, + onExit, + autoConfirm, + onNavigate, + preSynthesized, + diffMode, +}: DeployScreenProps) { + const { stdout } = useStdout(); const awsConfig = useAwsTargetConfig(); const [showInvoke, setShowInvoke] = useState(false); const [showResourceGraph, setShowResourceGraph] = useState(false); + const [showDiff, setShowDiff] = useState(diffMode ?? false); const [mcpSpec, setMcpSpec] = useState(); // Load MCP spec for ResourceGraph @@ -52,6 +64,10 @@ export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, p context, deployOutput, deployMessages, + diffSummaries, + numStacksWithChanges, + isDiffLoading, + requestDiff, hasError, hasTokenExpiredError, hasCredentialsError, @@ -68,7 +84,7 @@ export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, p useEnvLocalCredentials, useManualCredentials, skipCredentials, - } = useDeployFlow({ preSynthesized, isInteractive }); + } = useDeployFlow({ preSynthesized, isInteractive, diffMode }); const allSuccess = !hasError && isComplete; const skipPreflight = !!preSynthesized; @@ -93,6 +109,21 @@ export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, p { isActive: isInteractive && !!context } ); + // Toggle CDK diff with Ctrl+D + useInput( + (input, key) => { + if (input === 'd' && key.ctrl && context) { + setShowDiff(prev => { + if (!prev) { + requestDiff(); // Lazy: runs diff on first show + } + return !prev; + }); + } + }, + { isActive: isInteractive && !diffMode && !!context } + ); + // Auto-start deploy when AWS target is configured (or immediately when preSynthesized) useEffect(() => { if (phase === 'idle' && (skipPreflight || awsConfig.isConfigured)) { @@ -228,7 +259,7 @@ export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, p const targetDisplay = context?.awsTargets.map(t => `${t.region}:${t.account}`).join(', '); // Show deploy status box once CloudFormation has started (after asset publishing) - const showDeployStatus = hasStartedCfn || isComplete; + const showDeployStatus = !diffMode && (hasStartedCfn || isComplete); // Filter out "Deploy to AWS" step when deploy status box is showing const displaySteps = showDeployStatus ? steps.filter(s => s.label !== 'Deploy to AWS') : steps; @@ -248,15 +279,27 @@ export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, p ); - // Build help text with Ctrl+G toggle hint when context is available + // Build help text with toggle hints when context is available const baseHelpText = allSuccess && isInteractive ? HELP_TEXT.NAVIGATE_SELECT : HELP_TEXT.EXIT; - const helpText = - context && isInteractive - ? `Ctrl+G ${showResourceGraph ? 'hide' : 'show'} resource graph · ${baseHelpText}` - : baseHelpText; + const toggleHints = [ + !diffMode && diffSummaries.length > 0 && `Ctrl+D ${showDiff ? 'hide' : 'show'} diff`, + `Ctrl+G ${showResourceGraph ? 'hide' : 'show'} resource graph`, + ] + .filter(Boolean) + .join(' · '); + const helpText = context && isInteractive ? `${toggleHints} · ${baseHelpText}` : baseHelpText; + + const screenTitle = diffMode ? 'AgentCore Diff' : 'AgentCore Deploy'; + + // Compute available height for diff view: terminal height minus chrome elements + // Chrome: ScreenLayout padding (2) + ScreenHeader (3) + Project/Target (2) + StepProgress (~3) + // + margins (2) + scroll indicator (1) + "Diff complete" (2) + LogLink (2) + help text (2) + const terminalRows = stdout?.rows ?? 24; + const chromeLines = context ? 17 : 10; // more chrome when project info is visible + const diffMaxHeight = Math.max(6, terminalRows - chromeLines); return ( - + {/* Toggleable ResourceGraph view */} @@ -273,19 +316,42 @@ export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, p )} - {allSuccess && deployOutput && ( + {/* Show diff output (diff mode: always; normal mode: Ctrl+D toggle) */} + {(diffMode === true || showDiff) && isDiffLoading && ( + + Loading diff... + + )} + {(diffMode === true || showDiff) && diffSummaries.length > 0 && ( + + + + )} + + {allSuccess && deployOutput && !diffMode && ( {deployOutput} )} + {allSuccess && diffMode && ( + + Diff complete + + )} + {logFilePath && ( )} - {allSuccess && ( + {allSuccess && !diffMode && ( void; startDeploy: () => void; confirmTeardown: () => void; cancelTeardown: () => void; @@ -73,7 +90,7 @@ interface DeployFlowState { } export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState { - const { preSynthesized, isInteractive = false } = options; + const { preSynthesized, isInteractive = false, diffMode = false } = options; const skipPreflight = !!preSynthesized; // Create logger once for the entire deploy flow @@ -91,6 +108,11 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const [publishAssetsStep, setPublishAssetsStep] = useState({ label: 'Publish assets', status: 'pending' }); const [deployStep, setDeployStep] = useState({ label: 'Deploy to AWS', status: 'pending' }); + const [diffStep, setDiffStep] = useState({ label: 'Run CDK diff', status: 'pending' }); + const [diffSummaries, setDiffSummaries] = useState([]); + const [numStacksWithChanges, setNumStacksWithChanges] = useState(); + const [isDiffLoading, setIsDiffLoading] = useState(false); + const isDiffRunningRef = useRef(false); const [deployOutput, setDeployOutput] = useState(null); const [deployMessages, setDeployMessages] = useState([]); const [stackOutputs, setStackOutputs] = useState>({}); @@ -117,6 +139,40 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } }, [preflight, skipPreflight]); + /** Run diff on-demand (lazy: runs once, caches result). Safe to call anytime after synth. */ + const requestDiff = useCallback(() => { + if (diffSummaries.length > 0 || isDiffRunningRef.current) return; + if (!cdkToolkitWrapper) return; + + isDiffRunningRef.current = true; + setIsDiffLoading(true); + + const run = async () => { + switchableIoHost?.setOnRawMessage((code, _level, message, data) => { + logger.logDiff(code, message); + if (code === 'CDK_TOOLKIT_I4002') { + setDiffSummaries(prev => [...prev, parseStackDiff(data, message)]); + } else if (code === 'CDK_TOOLKIT_I4001') { + setNumStacksWithChanges(parseDiffResult(data).numStacksWithChanges); + } + }); + switchableIoHost?.setVerbose(true); + + try { + await cdkToolkitWrapper.diff(); + } catch { + setDiffSummaries([{ stackName: 'Error', sections: [], hasSecurityChanges: false, totalChanges: 0 }]); + } finally { + switchableIoHost?.setVerbose(false); + switchableIoHost?.setOnRawMessage(null); + isDiffRunningRef.current = false; + setIsDiffLoading(false); + } + }; + + void run(); + }, [cdkToolkitWrapper, diffSummaries.length, switchableIoHost, logger]); + /** * Persist deployed state after successful deployment. * Uses outputs from CDK stream (I5900) if available, falls back to DescribeStacks API. @@ -173,12 +229,38 @@ 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'; if (!shouldStart) return; if (deployStep.status !== 'pending') return; if (!cdkToolkitWrapper) return; const run = async () => { + // Run diff before deploy to capture pre-deploy differences + if (!isDiffRunningRef.current) { + isDiffRunningRef.current = true; + setIsDiffLoading(true); + switchableIoHost?.setOnRawMessage((code, _level, message, data) => { + logger.logDiff(code, message); + if (code === 'CDK_TOOLKIT_I4002') { + setDiffSummaries(prev => [...prev, parseStackDiff(data, message)]); + } else if (code === 'CDK_TOOLKIT_I4001') { + setNumStacksWithChanges(parseDiffResult(data).numStacksWithChanges); + } + }); + switchableIoHost?.setVerbose(true); + try { + await cdkToolkitWrapper.diff(); + } catch { + // Diff failure is non-fatal — deploy will proceed + } finally { + switchableIoHost?.setVerbose(false); + switchableIoHost?.setOnRawMessage(null); + isDiffRunningRef.current = false; + setIsDiffLoading(false); + } + } + setPublishAssetsStep(prev => ({ ...prev, status: 'running' })); setShouldStartDeploy(false); setDeployMessages([]); // Clear previous messages @@ -294,6 +376,69 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState switchableIoHost, context?.isTeardownDeploy, context?.awsTargets, + diffMode, + ]); + + // Start diff when preflight completes (diff mode only) + useEffect(() => { + if (!diffMode) return; + const shouldStart = skipPreflight ? shouldStartDeploy : preflight.phase === 'complete'; + if (!shouldStart) return; + if (diffStep.status !== 'pending') return; + if (!cdkToolkitWrapper) return; + + const run = async () => { + setDiffStep(prev => ({ ...prev, status: 'running' })); + setShouldStartDeploy(false); + setDiffSummaries([]); + logger.startStep('Run CDK diff'); + + switchableIoHost?.setOnRawMessage((code, _level, message, data) => { + logger.logDiff(code, message); + if (code === 'CDK_TOOLKIT_I4002') { + setDiffSummaries(prev => [...prev, parseStackDiff(data, message)]); + } else if (code === 'CDK_TOOLKIT_I4001') { + setNumStacksWithChanges(parseDiffResult(data).numStacksWithChanges); + } + }); + switchableIoHost?.setVerbose(true); + + try { + await cdkToolkitWrapper.diff(); + logger.endStep('success'); + logger.finalize(true); + setDiffStep(prev => ({ ...prev, status: 'success' })); + } catch (err) { + const errorMsg = getErrorMessage(err); + logger.endStep('error', errorMsg); + logger.finalize(false); + + if (isExpiredTokenError(err)) { + setHasTokenExpiredError(true); + } + + setDiffStep(prev => ({ + ...prev, + status: 'error', + error: logger.getFailureMessage('Run CDK diff'), + })); + } finally { + switchableIoHost?.setVerbose(false); + switchableIoHost?.setOnRawMessage(null); + void cdkToolkitWrapper.dispose(); + } + }; + + void run(); + }, [ + diffMode, + preflight.phase, + cdkToolkitWrapper, + diffStep.status, + logger, + skipPreflight, + shouldStartDeploy, + switchableIoHost, ]); // Finalize logger and dispose toolkit when preflight fails @@ -305,20 +450,24 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } }, [preflight.phase, preflight.cdkToolkitWrapper, logger, skipPreflight]); - const steps = useMemo( - () => (skipPreflight ? [publishAssetsStep, deployStep] : [...preflight.steps, publishAssetsStep, deployStep]), - [preflight.steps, publishAssetsStep, deployStep, skipPreflight] - ); + const steps = useMemo(() => { + if (diffMode) { + return skipPreflight ? [diffStep] : [...preflight.steps, diffStep]; + } + return skipPreflight ? [publishAssetsStep, deployStep] : [...preflight.steps, publishAssetsStep, deployStep]; + }, [preflight.steps, publishAssetsStep, deployStep, diffStep, skipPreflight, diffMode]); const phase: DeployPhase = useMemo(() => { + const activeStep = diffMode ? diffStep : deployStep; + if (skipPreflight) { - if (!shouldStartDeploy && deployStep.status === 'pending') { + if (!shouldStartDeploy && activeStep.status === 'pending') { return 'idle'; } - if (deployStep.status === 'error') { + if (activeStep.status === 'error') { return 'error'; } - if (deployStep.status === 'success') { + if (activeStep.status === 'success') { return 'complete'; } return 'deploying'; @@ -342,14 +491,14 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (preflight.phase === 'running' || preflight.phase === 'bootstrapping' || preflight.phase === 'identity-setup') { return 'running'; } - if (deployStep.status === 'error') { + if (activeStep.status === 'error') { return 'error'; } - if (deployStep.status === 'success') { + if (activeStep.status === 'success') { return 'complete'; } return 'deploying'; - }, [preflight.phase, deployStep.status, skipPreflight, shouldStartDeploy]); + }, [preflight.phase, deployStep, diffStep, skipPreflight, shouldStartDeploy, diffMode]); const hasError = hasStepError(steps); const isComplete = areStepsComplete(steps); @@ -372,6 +521,10 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState context, deployOutput, deployMessages, + diffSummaries, + numStacksWithChanges, + isDiffLoading, + requestDiff, stackOutputs, hasError, hasTokenExpiredError: combinedTokenExpiredError,