From 73197bc7d1fea3c7f74f92d9d08435b5147d9b65 Mon Sep 17 00:00:00 2001 From: Axon Stone Date: Wed, 1 Apr 2026 09:20:36 +0200 Subject: [PATCH 1/2] feat(cli): add interactive progress bar --- .changeset/silent-progress-bar.md | 5 + docs/architecture/cli.md | 1 + packages/cloudburn/src/commands/discover.ts | 84 ++++++----- packages/cloudburn/src/commands/scan.ts | 10 ++ packages/cloudburn/src/progress.ts | 67 +++++++++ packages/cloudburn/test/discover.e2e.test.ts | 63 ++++++++ .../cloudburn/test/scan-static.e2e.test.ts | 134 ++++++++++++++++++ 7 files changed, 330 insertions(+), 34 deletions(-) create mode 100644 .changeset/silent-progress-bar.md create mode 100644 packages/cloudburn/src/progress.ts diff --git a/.changeset/silent-progress-bar.md b/.changeset/silent-progress-bar.md new file mode 100644 index 0000000..0b5f2c9 --- /dev/null +++ b/.changeset/silent-progress-bar.md @@ -0,0 +1,5 @@ +--- +"cloudburn": patch +--- + +Add a simple interactive ASCII progress bar for `scan` and `discover` without changing structured stdout output. diff --git a/docs/architecture/cli.md b/docs/architecture/cli.md index 4141aba..f2a0c31 100644 --- a/docs/architecture/cli.md +++ b/docs/architecture/cli.md @@ -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`. diff --git a/packages/cloudburn/src/commands/discover.ts b/packages/cloudburn/src/commands/discover.ts index f3a79dc..f4d394d 100644 --- a/packages/cloudburn/src/commands/discover.ts +++ b/packages/cloudburn/src/commands/discover.ts @@ -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 = { @@ -20,6 +21,8 @@ type DiscoverOptions = { const parseDiscoveryServiceList = (value: string): string[] => validateServiceList('discovery', parseServiceList(value)) ?? []; +const discoverProgressSteps = ['Load config', 'Discover resources', 'Evaluate rules', 'Render output'] as const; + type DiscoverListOptions = Record; type DiscoverInitOptions = { @@ -171,10 +174,14 @@ const toDiscoveryConfigOverride = (options: DiscoverOptions) => { }; }; -const runCommand = async (action: () => Promise): Promise => { +const runCommand = async ( + action: () => Promise, + options?: { onError?: () => void }, +): Promise => { try { process.exitCode = (await action()) ?? EXIT_CODE_OK; } catch (err) { + options?.onError?.(); process.stderr.write(`${formatError(err)}\n`); process.exitCode = EXIT_CODE_RUNTIME_ERROR; } @@ -214,39 +221,48 @@ 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; - 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 = resolveCliDebugLogger(command); + const scanner = new CloudBurnClient({ debugLogger }); + const configOverride = toDiscoveryConfigOverride(options); + const loadedConfig = await scanner.loadConfig(options.config); + progress.advance(); + const discoveryOptions: { + target: AwsDiscoveryTarget; + config?: ReturnType; + 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); + progress.advance(); + const format = resolveOutputFormat(command, undefined, loadedConfig.discovery.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) { + return EXIT_CODE_POLICY_VIOLATION; + } + + return EXIT_CODE_OK; + }, + { onError: () => progress.finishError() }, + ); }), [ 'cloudburn discover', diff --git a/packages/cloudburn/src/commands/scan.ts b/packages/cloudburn/src/commands/scan.ts index 10d5e45..4e8c4c1 100644 --- a/packages/cloudburn/src/commands/scan.ts +++ b/packages/cloudburn/src/commands/scan.ts @@ -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 = { @@ -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; @@ -54,11 +57,14 @@ export const registerScanCommand = (program: Command): void => { .option('--service ', '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 @@ -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) { @@ -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; } diff --git a/packages/cloudburn/src/progress.ts b/packages/cloudburn/src/progress.ts new file mode 100644 index 0000000..2ec37af --- /dev/null +++ b/packages/cloudburn/src/progress.ts @@ -0,0 +1,67 @@ +type AsciiProgressTracker = { + advance: () => void; + finishError: () => void; + finishSuccess: () => 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 isEnabled = stream.isTTY === true; + let currentStepIndex = 0; + let hasRendered = false; + + const render = (): void => { + if (!isEnabled) { + return; + } + + const completedSteps = Math.min(currentStepIndex + 1, steps.length); + const label = steps[Math.min(currentStepIndex, steps.length - 1)]; + stream.write(`\r[${renderBar(completedSteps, steps.length)}] ${label}`); + hasRendered = true; + }; + + const finish = (): void => { + if (!isEnabled || !hasRendered) { + return; + } + + stream.write('\n'); + hasRendered = false; + }; + + render(); + + return { + advance: () => { + if (!isEnabled) { + return; + } + + currentStepIndex = Math.min(currentStepIndex + 1, steps.length - 1); + render(); + }, + finishError: finish, + finishSuccess: finish, + }; +}; diff --git a/packages/cloudburn/test/discover.e2e.test.ts b/packages/cloudburn/test/discover.e2e.test.ts index 49d9b68..4848cd8 100644 --- a/packages/cloudburn/test/discover.e2e.test.ts +++ b/packages/cloudburn/test/discover.e2e.test.ts @@ -102,6 +102,24 @@ const observedLocalStatus = { 'Discovery coverage is limited. 16 of 17 regions could not be inspected, which may be intentional if SCPs restrict regional Resource Explorer access.', }; +const setStderrIsTTY = (value: boolean): (() => void) => { + const descriptor = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY'); + + Object.defineProperty(process.stderr, 'isTTY', { + configurable: true, + value, + }); + + return () => { + if (descriptor) { + Object.defineProperty(process.stderr, 'isTTY', descriptor); + return; + } + + delete (process.stderr as NodeJS.WriteStream & { isTTY?: boolean }).isTTY; + }; +}; + describe('discover command e2e', () => { afterEach(() => { vi.restoreAllMocks(); @@ -142,6 +160,51 @@ describe('discover command e2e', () => { expect(process.exitCode).toBe(0); }); + it('writes discover progress to stderr during interactive runs without changing stdout output', async () => { + const restoreTTY = setStderrIsTTY(true); + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const discover = vi.spyOn(CloudBurnClient.prototype, 'discover').mockResolvedValue(liveScanResult); + + try { + await createProgram().parseAsync(['discover', '--format', 'json'], { from: 'user' }); + } finally { + restoreTTY(); + } + + const progressOutput = stderr.mock.calls.map(([chunk]) => String(chunk)).join(''); + + expect(discover).toHaveBeenCalledWith({ target: { mode: 'current' } }); + expect(progressOutput).toContain('Load config'); + expect(progressOutput).toContain('Discover resources'); + expect(progressOutput).toContain('Evaluate rules'); + expect(progressOutput).toContain('Render output'); + expect(progressOutput).toContain('\r'); + expect(progressOutput.endsWith('\n')).toBe(true); + expect(stdout).toHaveBeenCalledWith(`{ + "providers": [ + { + "provider": "aws", + "rules": [ + { + "ruleId": "CLDBRN-AWS-EBS-1", + "service": "ebs", + "source": "discovery", + "message": "EBS volumes should use current-generation storage.", + "findings": [ + { + "resourceId": "vol-123", + "region": "us-east-1" + } + ] + } + ] + } + ] +}\n`); + expect(process.exitCode).toBe(0); + }); + it('accepts the global root format flag for discovery scans', async () => { const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); const discover = vi.spyOn(CloudBurnClient.prototype, 'discover').mockResolvedValue(liveScanResult); diff --git a/packages/cloudburn/test/scan-static.e2e.test.ts b/packages/cloudburn/test/scan-static.e2e.test.ts index 9b5c4d1..585f036 100644 --- a/packages/cloudburn/test/scan-static.e2e.test.ts +++ b/packages/cloudburn/test/scan-static.e2e.test.ts @@ -29,6 +29,24 @@ const staticScanResult = { ], }; +const setStderrIsTTY = (value: boolean): (() => void) => { + const descriptor = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY'); + + Object.defineProperty(process.stderr, 'isTTY', { + configurable: true, + value, + }); + + return () => { + if (descriptor) { + Object.defineProperty(process.stderr, 'isTTY', descriptor); + return; + } + + delete (process.stderr as NodeJS.WriteStream & { isTTY?: boolean }).isTTY; + }; +}; + describe('scan command e2e', () => { afterEach(() => { vi.restoreAllMocks(); @@ -71,6 +89,99 @@ describe('scan command e2e', () => { expect(process.exitCode).toBe(0); }); + it('writes scan progress to stderr during interactive runs without changing stdout output', async () => { + const restoreTTY = setStderrIsTTY(true); + const fixturePath = fileURLToPath(new URL('../../sdk/test/fixtures/terraform/scan-dir', import.meta.url)); + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const scanStatic = vi.spyOn(CloudBurnClient.prototype, 'scanStatic').mockResolvedValue(staticScanResult); + + try { + await createProgram().parseAsync(['scan', fixturePath, '--format', 'json'], { from: 'user' }); + } finally { + restoreTTY(); + } + + const progressOutput = stderr.mock.calls.map(([chunk]) => String(chunk)).join(''); + + expect(scanStatic).toHaveBeenCalledWith(fixturePath); + expect(progressOutput).toContain('Load config'); + expect(progressOutput).toContain('Scan IaC'); + expect(progressOutput).toContain('Evaluate rules'); + expect(progressOutput).toContain('Render output'); + expect(progressOutput).toContain('\r'); + expect(progressOutput.endsWith('\n')).toBe(true); + expect(stdout).toHaveBeenCalledWith(`{ + "providers": [ + { + "provider": "aws", + "rules": [ + { + "ruleId": "CLDBRN-AWS-EBS-1", + "service": "ebs", + "source": "iac", + "message": "EBS volumes should use current-generation storage.", + "findings": [ + { + "resourceId": "aws_ebs_volume.gp2_logs", + "location": { + "path": "main.tf", + "line": 4, + "column": 3 + } + } + ] + } + ] + } + ] +}\n`); + expect(process.exitCode).toBe(0); + }); + + it('does not write scan progress when stderr is not a tty', async () => { + const restoreTTY = setStderrIsTTY(false); + const fixturePath = fileURLToPath(new URL('../../sdk/test/fixtures/terraform/scan-dir', import.meta.url)); + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const scanStatic = vi.spyOn(CloudBurnClient.prototype, 'scanStatic').mockResolvedValue(staticScanResult); + + try { + await createProgram().parseAsync(['scan', fixturePath, '--format', 'json'], { from: 'user' }); + } finally { + restoreTTY(); + } + + expect(scanStatic).toHaveBeenCalledWith(fixturePath); + expect(stderr).not.toHaveBeenCalled(); + expect(stdout).toHaveBeenCalledWith(`{ + "providers": [ + { + "provider": "aws", + "rules": [ + { + "ruleId": "CLDBRN-AWS-EBS-1", + "service": "ebs", + "source": "iac", + "message": "EBS volumes should use current-generation storage.", + "findings": [ + { + "resourceId": "aws_ebs_volume.gp2_logs", + "location": { + "path": "main.tf", + "line": 4, + "column": 3 + } + } + ] + } + ] + } + ] +}\n`); + expect(process.exitCode).toBe(0); + }); + it('accepts the global root format flag for static scans', async () => { const fixturePath = fileURLToPath(new URL('../../sdk/test/fixtures/terraform/scan-dir', import.meta.url)); const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -287,6 +398,29 @@ describe('scan command e2e', () => { expect(parsed.error.message).toBe('YAML parse error in template.yaml at line 12, column 4'); }); + it('cleans up the progress line before writing runtime errors to stderr', async () => { + const restoreTTY = setStderrIsTTY(true); + const fixturePath = fileURLToPath(new URL('../../sdk/test/fixtures/terraform/scan-dir', import.meta.url)); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + vi.spyOn(CloudBurnClient.prototype, 'scanStatic').mockRejectedValue( + new Error('YAML parse error in template.yaml at line 12, column 4'), + ); + + try { + await createProgram().parseAsync(['scan', fixturePath], { from: 'user' }); + } finally { + restoreTTY(); + } + + const output = stderr.mock.calls.map(([chunk]) => String(chunk)).join(''); + const finalChunk = String(stderr.mock.calls.at(-1)?.[0] ?? ''); + + expect(process.exitCode).toBe(2); + expect(output).toContain('Scan IaC'); + expect(output).toContain('\n{'); + expect(finalChunk).toContain('"code": "RUNTIME_ERROR"'); + }); + it('describes static autodetection in scan help output', () => { const program = createProgram(); const scanCommand = program.commands.find((command) => command.name() === 'scan'); From ad4422d96392831e1f672aa8c590ef26dbfea5df Mon Sep 17 00:00:00 2001 From: Axon Stone Date: Wed, 1 Apr 2026 09:44:43 +0200 Subject: [PATCH 2/2] feat(cli): surface discovery progress details --- packages/cloudburn/src/commands/discover.ts | 56 ++++++++++++++++++-- packages/cloudburn/src/progress.ts | 26 +++++++-- packages/cloudburn/test/discover.e2e.test.ts | 21 +++++++- packages/cloudburn/test/progress.test.ts | 31 +++++++++++ packages/sdk/src/engine/run-live.ts | 1 + 5 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 packages/cloudburn/test/progress.test.ts diff --git a/packages/cloudburn/src/commands/discover.ts b/packages/cloudburn/src/commands/discover.ts index f4d394d..f70c29a 100644 --- a/packages/cloudburn/src/commands/discover.ts +++ b/packages/cloudburn/src/commands/discover.ts @@ -21,7 +21,7 @@ type DiscoverOptions = { const parseDiscoveryServiceList = (value: string): string[] => validateServiceList('discovery', parseServiceList(value)) ?? []; -const discoverProgressSteps = ['Load config', 'Discover resources', 'Evaluate rules', 'Render output'] as const; +const discoverProgressSteps = ['Load config', 'Resource Explorer', 'Evaluate rules', 'Render output'] as const; type DiscoverListOptions = Record; @@ -187,6 +187,53 @@ const runCommand = async ( } }; +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, + 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. * @@ -225,11 +272,11 @@ export const registerDiscoverCommand = (program: Command): void => { await runCommand( async () => { - const debugLogger = resolveCliDebugLogger(command); + const debugLogger = resolveDiscoverProgressLogger(progress, command); const scanner = new CloudBurnClient({ debugLogger }); const configOverride = toDiscoveryConfigOverride(options); const loadedConfig = await scanner.loadConfig(options.config); - progress.advance(); + progress.advance('Resource Explorer'); const discoveryOptions: { target: AwsDiscoveryTarget; config?: ReturnType; @@ -247,11 +294,10 @@ export const registerDiscoverCommand = (program: Command): void => { } const result = await scanner.discover(discoveryOptions); - progress.advance(); const format = resolveOutputFormat(command, undefined, loadedConfig.discovery.format ?? 'table'); const output = renderResponse({ kind: 'scan-result', result }, format); - progress.advance(); + progress.advance('Render output'); progress.finishSuccess(); process.stdout.write(`${output}\n`); diff --git a/packages/cloudburn/src/progress.ts b/packages/cloudburn/src/progress.ts index 2ec37af..4b4b930 100644 --- a/packages/cloudburn/src/progress.ts +++ b/packages/cloudburn/src/progress.ts @@ -1,7 +1,8 @@ type AsciiProgressTracker = { - advance: () => void; + advance: (nextLabel?: string) => void; finishError: () => void; finishSuccess: () => void; + setLabel: (label: string) => void; }; const PROGRESS_BAR_WIDTH = 10; @@ -26,9 +27,13 @@ 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) { @@ -36,9 +41,12 @@ export const createAsciiProgressTracker = ( } const completedSteps = Math.min(currentStepIndex + 1, steps.length); - const label = steps[Math.min(currentStepIndex, steps.length - 1)]; - stream.write(`\r[${renderBar(completedSteps, steps.length)}] ${label}`); + 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 => { @@ -48,20 +56,30 @@ export const createAsciiProgressTracker = ( stream.write('\n'); hasRendered = false; + previousLineLength = 0; }; render(); return { - advance: () => { + 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(); + }, }; }; diff --git a/packages/cloudburn/test/discover.e2e.test.ts b/packages/cloudburn/test/discover.e2e.test.ts index 4848cd8..f8fdd75 100644 --- a/packages/cloudburn/test/discover.e2e.test.ts +++ b/packages/cloudburn/test/discover.e2e.test.ts @@ -164,7 +164,22 @@ describe('discover command e2e', () => { const restoreTTY = setStderrIsTTY(true); const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - const discover = vi.spyOn(CloudBurnClient.prototype, 'discover').mockResolvedValue(liveScanResult); + const discover = vi.spyOn(CloudBurnClient.prototype, 'discover').mockImplementation(async function () { + (this as { options?: { debugLogger?: (message: string) => void } }).options?.debugLogger?.( + 'aws: Resource Explorer using aggregator control plane us-east-1', + ); + (this as { options?: { debugLogger?: (message: string) => void } }).options?.debugLogger?.( + 'aws: Resource Explorer query 1/2 page 1 filter="service:ebs"', + ); + (this as { options?: { debugLogger?: (message: string) => void } }).options?.debugLogger?.( + 'aws: loading dataset aws-ebs-volumes', + ); + (this as { options?: { debugLogger?: (message: string) => void } }).options?.debugLogger?.( + 'sdk: evaluating discovery rules', + ); + + return liveScanResult; + }); try { await createProgram().parseAsync(['discover', '--format', 'json'], { from: 'user' }); @@ -176,7 +191,9 @@ describe('discover command e2e', () => { expect(discover).toHaveBeenCalledWith({ target: { mode: 'current' } }); expect(progressOutput).toContain('Load config'); - expect(progressOutput).toContain('Discover resources'); + expect(progressOutput).toContain('Resource Explorer'); + expect(progressOutput).toContain('Resource Explorer 1/2'); + expect(progressOutput).toContain('Load aws-ebs-volumes'); expect(progressOutput).toContain('Evaluate rules'); expect(progressOutput).toContain('Render output'); expect(progressOutput).toContain('\r'); diff --git a/packages/cloudburn/test/progress.test.ts b/packages/cloudburn/test/progress.test.ts new file mode 100644 index 0000000..32c44a4 --- /dev/null +++ b/packages/cloudburn/test/progress.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { createAsciiProgressTracker } from '../src/progress.js'; + +const createMockTtyStream = () => { + const writes: string[] = []; + + return { + stream: { + isTTY: true, + write: (chunk: string) => { + writes.push(chunk); + return true; + }, + } as unknown as NodeJS.WriteStream, + writes, + }; +}; + +describe('ascii progress tracker', () => { + it('pads shorter labels so previous content is cleared from the terminal line', () => { + const { stream, writes } = createMockTtyStream(); + const progress = createAsciiProgressTracker(['Load config', 'Discover resources', 'Render output'], stream); + + progress.advance('Discover resources'); + progress.advance('Render output'); + progress.finishSuccess(); + + expect(writes.at(-1)).toBe('\n'); + expect(writes.at(-2)).toMatch(/\r\[##########\] Render output +$/); + }); +}); diff --git a/packages/sdk/src/engine/run-live.ts b/packages/sdk/src/engine/run-live.ts index a8f6207..1ac2829 100644 --- a/packages/sdk/src/engine/run-live.ts +++ b/packages/sdk/src/engine/run-live.ts @@ -15,6 +15,7 @@ export const runLiveScan = async ( options?.debugLogger === undefined ? await discoverAwsResources(registry.activeRules, target) : await discoverAwsResources(registry.activeRules, target, { debugLogger: options.debugLogger }); + emitDebugLog(options?.debugLogger, 'sdk: evaluating discovery rules'); const findings = groupFindingsByProvider( registry.activeRules.map((rule) => { if (!rule.supports.includes('discovery') || !rule.evaluateLive) {