Skip to content
Open
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
10 changes: 5 additions & 5 deletions src/cli/cdk/toolkit-lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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<void> {
Expand All @@ -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)) {
Expand Down Expand Up @@ -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;
},
};
Expand Down
34 changes: 34 additions & 0 deletions src/cli/commands/deploy/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface ValidatedDeployOptions {
autoConfirm?: boolean;
verbose?: boolean;
plan?: boolean;
diff?: boolean;
onProgress?: (step: string, status: 'start' | 'success' | 'error') => void;
onResourceEvent?: (message: string) => void;
}
Expand Down Expand Up @@ -266,6 +267,39 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
};
}

// Diff mode: run cdk diff and exit without deploying
if (options.diff) {
startStep('Run CDK diff');
const diffIoHost = switchableIoHost ?? createSwitchableIoHost();
let hasDiffContent = false;
diffIoHost.setOnRawMessage((code, _level, message) => {
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(),
};
}

// Deploy
const hasGateways = mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0;
const deployStepName = hasGateways ? 'Deploying gateways...' : 'Deploy to AWS';
Expand Down
23 changes: 19 additions & 4 deletions src/cli/commands/deploy/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DeployScreen
isInteractive={false}
autoConfirm={options.autoConfirm}
diffMode={options.diffMode}
onExit={() => {
unmount();
process.exit(0);
Expand Down Expand Up @@ -71,8 +72,9 @@ async function handleDeployCLI(options: DeployOptions): Promise<void> {
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,
});
Expand All @@ -85,7 +87,9 @@ async function handleDeployCLI(options: DeployOptions): Promise<void> {
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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
}
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/deploy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface DeployOptions {
verbose?: boolean;
json?: boolean;
plan?: boolean;
diff?: boolean;
}

export interface DeployResult {
Expand Down
103 changes: 103 additions & 0 deletions src/cli/logging/__tests__/exec-logger-diff.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
41 changes: 41 additions & 0 deletions src/cli/logging/exec-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down
Loading
Loading