Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/silent-progress-bar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"cloudburn": patch
---

Add a simple interactive ASCII progress bar for `scan` and `discover` without changing structured stdout output.
1 change: 1 addition & 0 deletions docs/architecture/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ All stdout-producing commands return a typed `CliResponse` and share the same fo
- `--debug` is a global flag that relays SDK and provider execution tracing to `stderr` without changing normal command output on `stdout`.
- `--format` is documented as a global option and defaults to `table`, except `config --print` and `config --print-template`, which preserve raw YAML by default for redirection workflows.
- `scan` and `discover` can also source their default format from `.cloudburn.yml`; explicit `--format` still wins.
- Interactive `scan` and `discover` runs emit a transient ASCII progress bar to `stderr` when `stderr` is a TTY. `stdout` output remains unchanged for table and JSON modes.
- The hidden `__complete` command exists only as the runtime hook for generated shell scripts.
- `--exit-code` counts nested matches across all provider and rule groups.
- Runtime errors still write a structured JSON envelope to `stderr`.
Expand Down
130 changes: 96 additions & 34 deletions packages/cloudburn/src/commands/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { formatError } from '../formatters/error.js';
import { type CliResponse, type OutputFormat, renderResponse, resolveOutputFormat } from '../formatters/output.js';
import { countScanResultFindings } from '../formatters/shared.js';
import { setCommandExamples } from '../help.js';
import { createAsciiProgressTracker } from '../progress.js';
import { parseRuleIdList, parseServiceList, validateServiceList } from './config-options.js';

type DiscoverOptions = {
Expand All @@ -20,6 +21,8 @@ type DiscoverOptions = {
const parseDiscoveryServiceList = (value: string): string[] =>
validateServiceList('discovery', parseServiceList(value)) ?? [];

const discoverProgressSteps = ['Load config', 'Resource Explorer', 'Evaluate rules', 'Render output'] as const;

type DiscoverListOptions = Record<string, never>;

type DiscoverInitOptions = {
Expand Down Expand Up @@ -171,15 +174,66 @@ const toDiscoveryConfigOverride = (options: DiscoverOptions) => {
};
};

const runCommand = async (action: () => Promise<number | undefined>): Promise<void> => {
const runCommand = async (
action: () => Promise<number | undefined>,
options?: { onError?: () => void },
): Promise<void> => {
try {
process.exitCode = (await action()) ?? EXIT_CODE_OK;
} catch (err) {
options?.onError?.();
process.stderr.write(`${formatError(err)}\n`);
process.exitCode = EXIT_CODE_RUNTIME_ERROR;
}
};

const mapDiscoverProgressLabel = (message: string): string | undefined => {
if (
message.startsWith('aws: Resource Explorer using ') ||
message.startsWith('aws: planned ') ||
message.startsWith('aws: Resource Explorer catalog collected ')
) {
return 'Resource Explorer';
}

const queryMatch = /^aws: Resource Explorer query (\d+\/\d+) page \d+ /.exec(message);

if (queryMatch) {
return `Resource Explorer ${queryMatch[1]}`;
}

const datasetMatch = /^aws: loading dataset ([^ ]+)(?: in ([^ ]+))?/.exec(message);

if (datasetMatch) {
const [, datasetKey, region] = datasetMatch;

return region ? `Load ${datasetKey} (${region})` : `Load ${datasetKey}`;
}

return undefined;
};

const resolveDiscoverProgressLogger = (
progress: ReturnType<typeof createAsciiProgressTracker>,
command: Command,
): ((message: string) => void) => {
const debugLogger = resolveCliDebugLogger(command);
let hasEnteredEvaluation = false;

return (message: string) => {
const progressLabel = mapDiscoverProgressLabel(message);

if (progressLabel) {
progress.setLabel(progressLabel);
} else if (message === 'sdk: evaluating discovery rules' && !hasEnteredEvaluation) {
progress.advance('Evaluate rules');
hasEnteredEvaluation = true;
}

debugLogger?.(message);
};
};

/**
* Registers the live AWS discovery command tree.
*
Expand Down Expand Up @@ -214,39 +268,47 @@ export const registerDiscoverCommand = (program: Command): void => {
)
.option('--exit-code', 'Exit with code 1 when findings exist')
.action(async (options: DiscoverOptions, command: Command) => {
await runCommand(async () => {
const debugLogger = resolveCliDebugLogger(command);
const scanner = new CloudBurnClient({ debugLogger });
const configOverride = toDiscoveryConfigOverride(options);
const loadedConfig = await scanner.loadConfig(options.config);
const discoveryOptions: {
target: AwsDiscoveryTarget;
config?: ReturnType<typeof toDiscoveryConfigOverride>;
configPath?: string;
} = {
target: resolveDiscoveryTarget(options.region),
};

if (configOverride !== undefined) {
discoveryOptions.config = configOverride;
}

if (options.config !== undefined) {
discoveryOptions.configPath = options.config;
}

const result = await scanner.discover(discoveryOptions);
const format = resolveOutputFormat(command, undefined, loadedConfig.discovery.format ?? 'table');
const output = renderResponse({ kind: 'scan-result', result }, format);

process.stdout.write(`${output}\n`);

if (options.exitCode && countScanResultFindings(result) > 0) {
return EXIT_CODE_POLICY_VIOLATION;
}

return EXIT_CODE_OK;
});
const progress = createAsciiProgressTracker(discoverProgressSteps);

await runCommand(
async () => {
const debugLogger = resolveDiscoverProgressLogger(progress, command);
const scanner = new CloudBurnClient({ debugLogger });
const configOverride = toDiscoveryConfigOverride(options);
const loadedConfig = await scanner.loadConfig(options.config);
progress.advance('Resource Explorer');
const discoveryOptions: {
target: AwsDiscoveryTarget;
config?: ReturnType<typeof toDiscoveryConfigOverride>;
configPath?: string;
} = {
target: resolveDiscoveryTarget(options.region),
};

if (configOverride !== undefined) {
discoveryOptions.config = configOverride;
}

if (options.config !== undefined) {
discoveryOptions.configPath = options.config;
}

const result = await scanner.discover(discoveryOptions);
const format = resolveOutputFormat(command, undefined, loadedConfig.discovery.format ?? 'table');
const output = renderResponse({ kind: 'scan-result', result }, format);

progress.advance('Render output');
progress.finishSuccess();
process.stdout.write(`${output}\n`);

if (options.exitCode && countScanResultFindings(result) > 0) {
return EXIT_CODE_POLICY_VIOLATION;
}

return EXIT_CODE_OK;
},
{ onError: () => progress.finishError() },
);
}),
[
'cloudburn discover',
Expand Down
10 changes: 10 additions & 0 deletions packages/cloudburn/src/commands/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { formatError } from '../formatters/error.js';
import { renderResponse, resolveOutputFormat } from '../formatters/output.js';
import { countScanResultFindings } from '../formatters/shared.js';
import { setCommandExamples } from '../help.js';
import { createAsciiProgressTracker } from '../progress.js';
import { parseRuleIdList, parseServiceList, validateServiceList } from './config-options.js';

type ScanOptions = {
Expand All @@ -18,6 +19,8 @@ type ScanOptions = {

const parseIaCServiceList = (value: string): string[] => validateServiceList('iac', parseServiceList(value)) ?? [];

const scanProgressSteps = ['Load config', 'Scan IaC', 'Evaluate rules', 'Render output'] as const;

const toScanConfigOverride = (options: ScanOptions) => {
if (options.enabledRules === undefined && options.disabledRules === undefined && options.service === undefined) {
return undefined;
Expand Down Expand Up @@ -54,11 +57,14 @@ export const registerScanCommand = (program: Command): void => {
.option('--service <services>', 'Comma-separated services to include in the scan rule set.', parseIaCServiceList)
.option('--exit-code', 'Exit with code 1 when findings exist')
.action(async (path: string | undefined, options: ScanOptions, command: Command) => {
const progress = createAsciiProgressTracker(scanProgressSteps);

try {
const debugLogger = resolveCliDebugLogger(command);
const scanner = new CloudBurnClient({ debugLogger });
const configOverride = toScanConfigOverride(options);
const loadedConfig = await scanner.loadConfig(options.config);
progress.advance();
const scanPath = path ?? process.cwd();
const result =
configOverride === undefined && options.config === undefined
Expand All @@ -67,9 +73,12 @@ export const registerScanCommand = (program: Command): void => {
? await scanner.scanStatic(scanPath, configOverride)
: await scanner.scanStatic(scanPath, configOverride, { configPath: options.config });

progress.advance();
const format = resolveOutputFormat(command, undefined, loadedConfig.iac.format ?? 'table');
const output = renderResponse({ kind: 'scan-result', result }, format);

progress.advance();
progress.finishSuccess();
process.stdout.write(`${output}\n`);

if (options.exitCode && countScanResultFindings(result) > 0) {
Expand All @@ -79,6 +88,7 @@ export const registerScanCommand = (program: Command): void => {

process.exitCode = EXIT_CODE_OK;
} catch (err) {
progress.finishError();
process.stderr.write(`${formatError(err)}\n`);
process.exitCode = EXIT_CODE_RUNTIME_ERROR;
}
Expand Down
85 changes: 85 additions & 0 deletions packages/cloudburn/src/progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
type AsciiProgressTracker = {
advance: (nextLabel?: string) => void;
finishError: () => void;
finishSuccess: () => void;
setLabel: (label: string) => void;
};

const PROGRESS_BAR_WIDTH = 10;

const renderBar = (completedSteps: number, totalSteps: number): string => {
const filledWidth = Math.max(1, Math.round((completedSteps / totalSteps) * PROGRESS_BAR_WIDTH));

return `${'#'.repeat(filledWidth)}${'-'.repeat(PROGRESS_BAR_WIDTH - filledWidth)}`;
};

/**
* Creates a lightweight ASCII progress tracker for interactive CLI commands.
*
* The tracker writes transient updates to `stderr` only when the target stream
* is attached to a TTY, keeping structured `stdout` output unchanged.
*
* @param steps - Ordered progress labels to render.
* @param stream - Writable TTY stream used for progress output.
* @returns Progress controls for advancing and finalizing the display.
*/
export const createAsciiProgressTracker = (
steps: readonly [string, ...string[]],
stream: NodeJS.WriteStream = process.stderr,
): AsciiProgressTracker => {
const [firstStep, ...remainingSteps] = steps;
const isEnabled = stream.isTTY === true;
const lastStep = remainingSteps.at(-1) ?? firstStep;
let currentStepIndex = 0;
let currentLabel = firstStep;
let hasRendered = false;
let previousLineLength = 0;

const render = (): void => {
if (!isEnabled) {
return;
}

const completedSteps = Math.min(currentStepIndex + 1, steps.length);
const line = `[${renderBar(completedSteps, steps.length)}] ${currentLabel}`;
const padding = previousLineLength > line.length ? ' '.repeat(previousLineLength - line.length) : '';

stream.write(`\r${line}${padding}`);
hasRendered = true;
previousLineLength = line.length;
};

const finish = (): void => {
if (!isEnabled || !hasRendered) {
return;
}

stream.write('\n');
hasRendered = false;
previousLineLength = 0;
};

render();

return {
advance: (nextLabel) => {
if (!isEnabled) {
return;
}

currentStepIndex = Math.min(currentStepIndex + 1, steps.length - 1);
currentLabel = nextLabel ?? steps[currentStepIndex] ?? lastStep;
render();
},
finishError: finish,
finishSuccess: finish,
setLabel: (label) => {
if (!isEnabled) {
return;
}

currentLabel = label;
render();
},
};
};
Loading
Loading