Repository: aws/aws-cdk-cli
Pull request #306: feat(toolkit-lib): default to using a non interactive IoHost
Author: login: mrgrain
Labels: name: pr/exempt-integ-test
description: Skips the integ test steps if set.
name: p1
Created: 2025-04-02T10:40:01Z | Merged: 2025-04-03T11:48:14Z

Replaces the current default `IoHost` which is the same as the one used by the CLI with a strictly non-interactive version. This is more in accordance with the goals of a library providing actions.

Fixes #157
Closes #289 

BREAKING CHANGE: This change updates the default `IoHost` implementation used by `Toolkit` to a version that is strictly non-interactive, i.e. there is no expectation anymore that users will respond to command-line prompts. To restore previous behavior, you will can provide a custom `IoHost` implementation to your `Toolkit` instance. You may consider extending the new `NonInteractiveIoHost` class with desired interactive prompts.

---
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license


Linked issue #157: @aws-cdk/toolkit-lib: unable to destroy stack without custom ioHost
### Describe the bug

As a programmatic user of `@aws-cdk/toolkit-lib` I'd like to be able to destroy stacks without explicit operator approval. At the moment this requires me implementing a custom `ioHost`, which can be a lot of work.

### Regression Issue

- [ ] Select this option if this issue appears to be a regression.

### Last Known Working CDK Version

_No response_

### Expected Behavior

While I understand and appreciate the current `requestResponse` system for operations that require some kind of intervention, I'd like an easy way to tell the toolkit that I want to destroy stacks without any approval.

It's already possible to turn off approvals for stack creation, and the legacy `@aws-cdk/cli-lib-alpha` also allowed to destroy stacks without approvals.

### Current Behavior

Currently the only way to destroy stacks in a completely unattended way is to implement a passthrough `ioHost`, like:

```js
ioHost: {
  async notify(msg) {
    console.log(msg.message);
  },
  async requestResponse(msg) {
    console.log(msg.message);
    return msg.defaultResponse;
  },
},
```

This works, but requires me to now also take care of messaging and handle different levels - something that is done automatically by the default `ioHost`.

### Reproduction Steps

N/A

### Possible Solution

_No response_

### Additional Information/Context

_No response_

### CDK CLI Version

2.1000.2

### Framework Version

_No response_

### Node.js Version

v22.x

### OS

N/A

### Language

TypeScript

### Language Version

_No response_

### Other information

_No response_

Linked issue comments: user: login: mrgrain
created_at: 2025-03-28T13:10:46Z
body: Will be solved by https://github.com/aws/aws-cdk-cli/issues/289
author_association: CONTRIBUTOR

Diff:
diff --git a/.projenrc.ts b/.projenrc.ts
index ee4f3bd38..b154d9750 100644
--- a/.projenrc.ts
+++ b/.projenrc.ts
@@ -681,11 +681,14 @@ const tmpToolkitHelpers = configureProject(
     deps: [
       cloudAssemblySchema.name,
       cloudFormationDiff,
+      cxApi,
+      `@aws-sdk/client-cloudformation@${CLI_SDK_V3_RANGE}`,
       'archiver',
       'chalk@4',
       'glob',
       'semver',
       'uuid',
+      'wrap-ansi@^7', // Last non-ESM version
       'yaml@^1',
     ],
     tsconfig: {
diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json
index d638a847a..4af6f4ece 100644
--- a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json
+++ b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json
@@ -109,6 +109,15 @@
       "name": "@aws-cdk/cloudformation-diff",
       "type": "runtime"
     },
+    {
+      "name": "@aws-cdk/cx-api",
+      "type": "runtime"
+    },
+    {
+      "name": "@aws-sdk/client-cloudformation",
+      "version": "^3",
+      "type": "runtime"
+    },
     {
       "name": "archiver",
       "type": "runtime"
@@ -130,6 +139,11 @@
       "name": "uuid",
       "type": "runtime"
     },
+    {
+      "name": "wrap-ansi",
+      "version": "^7",
+      "type": "runtime"
+    },
     {
       "name": "yaml",
       "version": "^1",
diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json
index 4ef457ac1..328be7de6 100644
--- a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json
+++ b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json
@@ -37,7 +37,7 @@
       },
       "steps": [
         {
-          "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@cdklabs/eslint-plugin,@types/archiver,@types/jest,@types/semver,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,fast-check,jest,projen,ts-jest,@aws-cdk/cloud-assembly-schema,archiver,glob,semver,uuid"
+          "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@cdklabs/eslint-plugin,@types/archiver,@types/jest,@types/semver,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,fast-check,jest,projen,ts-jest,@aws-cdk/cloud-assembly-schema,@aws-cdk/cx-api,archiver,glob,semver,uuid"
         }
       ]
     },
diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/package.json b/packages/@aws-cdk/tmp-toolkit-helpers/package.json
index 5a3325f14..50c08a696 100644
--- a/packages/@aws-cdk/tmp-toolkit-helpers/package.json
+++ b/packages/@aws-cdk/tmp-toolkit-helpers/package.json
@@ -57,11 +57,14 @@
   "dependencies": {
     "@aws-cdk/cloud-assembly-schema": "^0.0.0",
     "@aws-cdk/cloudformation-diff": "^0.0.0",
+    "@aws-cdk/cx-api": "^2.186.0",
+    "@aws-sdk/client-cloudformation": "^3",
     "archiver": "^7.0.1",
     "chalk": "4",
     "glob": "^11.0.1",
     "semver": "^7.7.1",
     "uuid": "^11.1.0",
+    "wrap-ansi": "^7",
     "yaml": "^1"
   },
   "keywords": [
diff --git a/packages/aws-cdk/lib/cli/activity-printer/base.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/base.ts
similarity index 94%
rename from packages/aws-cdk/lib/cli/activity-printer/base.ts
rename to packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/base.ts
index ce9e9b432..9d89e82e1 100644
--- a/packages/aws-cdk/lib/cli/activity-printer/base.ts
+++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/base.ts
@@ -1,8 +1,8 @@
 import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api';
-import { type StackActivity, type StackProgress } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api';
-import { IO } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private';
+import type { IoMessage } from '../../api/io';
+import { type StackActivity, type StackProgress } from '../../api/io/payloads';
+import { IO } from '../../api/io/private';
 import { maxResourceTypeLength, stackEventHasErrorMessage } from '../../util';
-import type { IoMessage } from '../io-host/cli-io-host';
 
 export interface IActivityPrinter {
   notify(msg: IoMessage<unknown>): void;
diff --git a/packages/aws-cdk/lib/cli/activity-printer/current.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/current.ts
similarity index 98%
rename from packages/aws-cdk/lib/cli/activity-printer/current.ts
rename to packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/current.ts
index b2f1e38e4..119ebbd6f 100644
--- a/packages/aws-cdk/lib/cli/activity-printer/current.ts
+++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/current.ts
@@ -1,9 +1,9 @@
 import * as util from 'util';
-import type { StackActivity } from '@aws-cdk/tmp-toolkit-helpers';
 import * as chalk from 'chalk';
 import type { ActivityPrinterProps } from './base';
 import { ActivityPrinterBase } from './base';
 import { RewritableBlock } from './display';
+import type { StackActivity } from '../../api/io/payloads';
 import { padLeft, padRight, stackEventHasErrorMessage } from '../../util';
 
 /**
diff --git a/packages/aws-cdk/lib/cli/activity-printer/display.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/display.ts
similarity index 100%
rename from packages/aws-cdk/lib/cli/activity-printer/display.ts
rename to packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/display.ts
diff --git a/packages/aws-cdk/lib/cli/activity-printer/history.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/history.ts
similarity index 98%
rename from packages/aws-cdk/lib/cli/activity-printer/history.ts
rename to packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/history.ts
index 4a082d7ce..8a2ff7495 100644
--- a/packages/aws-cdk/lib/cli/activity-printer/history.ts
+++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/history.ts
@@ -1,8 +1,8 @@
 import * as util from 'util';
-import type { StackActivity } from '@aws-cdk/tmp-toolkit-helpers';
 import * as chalk from 'chalk';
 import type { ActivityPrinterProps } from './base';
 import { ActivityPrinterBase } from './base';
+import type { StackActivity } from '../../api/io/payloads';
 import { padRight } from '../../util';
 
 /**
diff --git a/packages/aws-cdk/lib/cli/activity-printer/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/index.ts
similarity index 100%
rename from packages/aws-cdk/lib/cli/activity-printer/index.ts
rename to packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/index.ts
diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/private/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/index.ts
new file mode 100644
index 000000000..ab21a80b6
--- /dev/null
+++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/index.ts
@@ -0,0 +1 @@
+export * from './activity-printer';
diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/test/_helpers/assembly.ts b/packages/@aws-cdk/tmp-toolkit-helpers/test/_helpers/assembly.ts
new file mode 100644
index 000000000..b6d807db9
--- /dev/null
+++ b/packages/@aws-cdk/tmp-toolkit-helpers/test/_helpers/assembly.ts
@@ -0,0 +1,165 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { ArtifactMetadataEntryType, ArtifactType, type AssetManifest, type AssetMetadataEntry, type AwsCloudFormationStackProperties, type MetadataEntry, type MissingContext } from '@aws-cdk/cloud-assembly-schema';
+import { CloudAssembly, CloudAssemblyBuilder, type CloudFormationStackArtifact, type StackMetadata } from '@aws-cdk/cx-api';
+
+export const DEFAULT_FAKE_TEMPLATE = { No: 'Resources' };
+
+const SOME_RECENT_SCHEMA_VERSION = '30.0.0';
+
+export interface TestStackArtifact {
+  stackName: string;
+  template?: any;
+  env?: string;
+  depends?: string[];
+  metadata?: StackMetadata;
+  notificationArns?: string[];
+
+  /** Old-style assets */
+  assets?: AssetMetadataEntry[];
+  properties?: Partial<AwsCloudFormationStackProperties>;
+  terminationProtection?: boolean;
+  displayName?: string;
+
+  /** New-style assets */
+  assetManifest?: AssetManifest;
+}
+
+export interface TestAssembly {
+  stacks: TestStackArtifact[];
+  missing?: MissingContext[];
+  nestedAssemblies?: TestAssembly[];
+  schemaVersion?: string;
+}
+
+function clone(obj: any) {
+  return JSON.parse(JSON.stringify(obj));
+}
+
+function addAttributes(assembly: TestAssembly, builder: CloudAssemblyBuilder) {
+  for (const stack of assembly.stacks) {
+    const templateFile = `${stack.stackName}.template.json`;
+    const template = stack.template ?? DEFAULT_FAKE_TEMPLATE;
+    fs.writeFileSync(path.join(builder.outdir, templateFile), JSON.stringify(template, undefined, 2));
+    addNestedStacks(templateFile, builder.outdir, template);
+
+    // we call patchStackTags here to simulate the tags formatter
+    // that is used when building real manifest files.
+    const metadata: { [path: string]: MetadataEntry[] } = patchStackTags({ ...stack.metadata });
+    for (const asset of stack.assets || []) {
+      metadata[asset.id] = [{ type: ArtifactMetadataEntryType.ASSET, data: asset }];
+    }
+
+    for (const missing of assembly.missing || []) {
+      builder.addMissing(missing);
+    }
+
+    const dependencies = [...(stack.depends ?? [])];
+
+    if (stack.assetManifest) {
+      const manifestFile = `${stack.stackName}.assets.json`;
+      fs.writeFileSync(path.join(builder.outdir, manifestFile), JSON.stringify(stack.assetManifest, undefined, 2));
+      dependencies.push(`${stack.stackName}.assets`);
+      builder.addArtifact(`${stack.stackName}.assets`, {
+        type: ArtifactType.ASSET_MANIFEST,
+        environment: stack.env || 'aws://123456789012/here',
+        properties: {
+          file: manifestFile,
+        },
+      });
+    }
+
+    builder.addArtifact(stack.stackName, {
+      type: ArtifactType.AWS_CLOUDFORMATION_STACK,
+      environment: stack.env || 'aws://123456789012/here',
+
+      dependencies,
+      metadata,
+      properties: {
+        ...stack.properties,
+        templateFile,
+        terminationProtection: stack.terminationProtection,
+        notificationArns: stack.notificationArns,
+      },
+      displayName: stack.displayName,
+    });
+  }
+}
+
+function addNestedStacks(templatePath: string, outdir: string, rootStackTemplate?: any) {
+  let template = rootStackTemplate;
+
+  if (!template) {
+    const templatePathWithDir = path.join('nested-stack-templates', templatePath);
+    template = JSON.parse(fs.readFileSync(path.join(__dirname, '..', templatePathWithDir)).toString());
+    fs.writeFileSync(path.join(outdir, templatePath), JSON.stringify(template, undefined, 2));
+  }
+
+  for (const logicalId of Object.keys(template.Resources ?? {})) {
+    if (template.Resources[logicalId].Type === 'AWS::CloudFormation::Stack') {
+      if (template.Resources[logicalId].Metadata && template.Resources[logicalId].Metadata['aws:asset:path']) {
+        const nestedTemplatePath = template.Resources[logicalId].Metadata['aws:asset:path'];
+        addNestedStacks(nestedTemplatePath, outdir);
+      }
+    }
+  }
+}
+
+function rewriteManifestVersion(directory: string, version: string) {
+  const manifestFile = `${directory}/manifest.json`;
+  const contents = JSON.parse(fs.readFileSync(`${directory}/manifest.json`, 'utf-8'));
+  contents.version = version;
+  fs.writeFileSync(manifestFile, JSON.stringify(contents, undefined, 2));
+}
+
+function cxapiAssemblyWithForcedVersion(asm: CloudAssembly, version: string) {
+  rewriteManifestVersion(asm.directory, version);
+  return new CloudAssembly(asm.directory, { skipVersionCheck: true });
+}
+
+export function testAssembly(assembly: TestAssembly): CloudAssembly {
+  const builder = new CloudAssemblyBuilder();
+  addAttributes(assembly, builder);
+
+  if (assembly.nestedAssemblies != null && assembly.nestedAssemblies.length > 0) {
+    assembly.nestedAssemblies?.forEach((nestedAssembly: TestAssembly, i: number) => {
+      const nestedAssemblyBuilder = builder.createNestedAssembly(`nested${i}`, `nested${i}`);
+      addAttributes(nestedAssembly, nestedAssemblyBuilder);
+      nestedAssemblyBuilder.buildAssembly();
+    });
+  }
+
+  const asm = builder.buildAssembly();
+  return cxapiAssemblyWithForcedVersion(asm, assembly.schemaVersion ?? SOME_RECENT_SCHEMA_VERSION);
+}
+
+/**
+ * Transform stack tags from how they are decalred in source code (lower cased)
+ * to how they are stored on disk (upper cased). In real synthesis this is done
+ * by a special tags formatter.
+ *
+ * @see aws-cdk-lib/lib/stack.ts
+ */
+function patchStackTags(metadata: { [path: string]: MetadataEntry[] }): {
+  [path: string]: MetadataEntry[];
+} {
+  const cloned = clone(metadata) as { [path: string]: MetadataEntry[] };
+
+  for (const metadataEntries of Object.values(cloned)) {
+    for (const metadataEntry of metadataEntries) {
+      if (metadataEntry.type === ArtifactMetadataEntryType.STACK_TAGS && metadataEntry.data) {
+        const metadataAny = metadataEntry as any;
+
+        metadataAny.data = metadataAny.data.map((t: any) => {
+          return { Key: t.key, Value: t.value };
+        });
+      }
+    }
+  }
+  return cloned;
+}
+
+export function testStack(stack: TestStackArtifact): CloudFormationStackArtifact {
+  const assembly = testAssembly({ stacks: [stack] });
+  return assembly.getStackByName(stack.stackName);
+}
diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/test/_helpers/console-listener.ts b/packages/@aws-cdk/tmp-toolkit-helpers/test/_helpers/console-listener.ts
new file mode 100644
index 000000000..52d63355a
--- /dev/null
+++ b/packages/@aws-cdk/tmp-toolkit-helpers/test/_helpers/console-listener.ts
@@ -0,0 +1,65 @@
+import { EventEmitter } from 'events';
+
+export type Output = ReadonlyArray<string>;
+
+export interface Options {
+  isTTY?: boolean;
+}
+
+export interface Inspector {
+  output: Output;
+  restore: () => void;
+}
+
+class ConsoleListener {
+  private _stream: NodeJS.WriteStream;
+  private _options?: Options;
+
+  constructor(stream: NodeJS.WriteStream, options?: Options) {
+    this._stream = stream;
+    this._options = options;
+  }
+
+  inspect(): Inspector {
+    let isTTY;
+    if (this._options && this._options.isTTY !== undefined) {
+      isTTY = this._options.isTTY;
+    }
+
+    const output: string[] = [];
+    const stream = this._stream;
+    const res: EventEmitter & Partial<Inspector> = new EventEmitter();
+
+    // eslint-disable-next-line @typescript-eslint/unbound-method
+    const originalWrite = stream.write;
+    stream.write = (string: string) => {
+      output.push(string);
+      return res.emit('data', string);
+    };
+
+    const originalIsTTY = stream.isTTY;
+    if (isTTY === true) {
+      stream.isTTY = isTTY;
+    }
+
+    res.output = output;
+    res.restore = () => {
+      stream.write = originalWrite;
+      stream.isTTY = originalIsTTY;
+    };
+    return (res as Inspector);
+  }
+
+  inspectSync(fn: (output: Output) => void): Output {
+    const inspect = this.inspect();
+    try {
+      fn(inspect.output);
+    } finally {
+      inspect.restore();
+    }
+    return inspect.output;
+  }
+}
+
+export const stdout = new ConsoleListener(process.stdout);
+export const stderr = new ConsoleListener(process.stderr);
diff --git a/packages/aws-cdk/test/cli/activity-monitor/display.test.ts b/packages/@aws-cdk/tmp-toolkit-helpers/test/activity-monitor/display.test.ts
similarity index 94%
rename from packages/aws-cdk/test/cli/activity-monitor/display.test.ts
rename to packages/@aws-cdk/tmp-toolkit-helpers/test/activity-monitor/display.test.ts
index 7dd67f49a..a3d64dc64 100644
--- a/packages/aws-cdk/test/cli/activity-monitor/display.test.ts
+++ b/packages/@aws-cdk/tmp-toolkit-helpers/test/activity-monitor/display.test.ts
@@ -1,5 +1,4 @@
-/* eslint-disable import/order */
-import { RewritableBlock } from '../../../lib/cli/activity-printer/display';
+import { RewritableBlock } from '../../src/private/activity-printer/display';
 import { stderr } from '../_helpers/console-listener';
 
 describe('Rewritable Block Tests', () => {
diff --git a/packages/aws-cdk/test/cli/activity-monitor/history.test.ts b/packages/@aws-cdk/tmp-toolkit-helpers/test/activity-monitor/history.test.ts
similarity index 89%
rename from packages/aws-cdk/test/cli/activity-monitor/history.test.ts
rename to packages/@aws-cdk/tmp-toolkit-helpers/test/activity-monitor/history.test.ts
index 4bc85f0c7..ad818e92f 100644
--- a/packages/aws-cdk/test/cli/activity-monitor/history.test.ts
+++ b/packages/@aws-cdk/tmp-toolkit-helpers/test/activity-monitor/history.test.ts
@@ -1,9 +1,8 @@
-import { ResourceStatus } from "@aws-sdk/client-cloudformation";
-import { HistoryActivityPrinter } from "../../../lib/cli/activity-printer";
-import { CliIoHost } from "../../../lib/cli/io-host";
-import { testStack } from "../../_helpers";
-import { stderr } from "../_helpers/console-listener";
+import { ResourceStatus } from '@aws-sdk/client-cloudformation';
 import * as chalk from 'chalk';
+import { HistoryActivityPrinter } from '../../src/private/activity-printer';
+import { testStack } from '../_helpers/assembly';
+import { stderr } from '../_helpers/console-listener';
 
 let TIMESTAMP: number;
 let HUMAN_TIME: string;
@@ -11,7 +10,6 @@ let HUMAN_TIME: string;
 beforeAll(() => {
   TIMESTAMP = new Date().getTime();
   HUMAN_TIME = new Date(TIMESTAMP).toLocaleTimeString();
-  CliIoHost.instance().isCI = false;
 });
 
 test('prints "IN_PROGRESS" ResourceStatus', () => {
@@ -31,12 +29,12 @@ test('prints "IN_PROGRESS" ResourceStatus', () => {
         EventId: '',
         StackName: 'stack-name',
       },
-      deployment: "test",
+      deployment: 'test',
       progress: {
         completed: 0,
         total: 2,
-        formatted: "0/4"
-      }
+        formatted: '0/4',
+      },
     });
     historyActivityPrinter.stop();
   });
@@ -46,7 +44,6 @@ test('prints "IN_PROGRESS" ResourceStatus', () => {
   );
 });
 
-
 test('prints "Failed Resources:" list, when at least one deployment fails', () => {
   const historyActivityPrinter = new HistoryActivityPrinter({
     stream: process.stderr,
@@ -64,12 +61,12 @@ test('prints "Failed Resources:" list, when at least one deployment fails', () =
         EventId: '',
         StackName: 'stack-name',
       },
-      deployment: "test",
+      deployment: 'test',
       progress: {
         completed: 0,
         total: 2,
-        formatted: "0/2"
-      }
+        formatted: '0/2',
+      },
     });
     historyActivityPrinter.activity({
       event: {
@@ -81,12 +78,12 @@ test('prints "Failed Resources:" list, when at least one deployment fails', () =
         EventId: '',
         StackName: 'stack-name',
       },
-      deployment: "test",
+      deployment: 'test',
       progress: {
         completed: 0,
         total: 2,
-        formatted: "0/2"
-      }
+        formatted: '0/2',
+      },
     });
     historyActivityPrinter.stop();
   });
@@ -124,12 +121,12 @@ test('print failed resources because of hook failures', () => {
         HookType: 'hook1',
         HookStatusReason: 'stack1 must obey certain rules',
       },
-      deployment: "test",
+      deployment: 'test',
       progress: {
         completed: 0,
         total: 2,
-        formatted: "0/2"
-      }
+        formatted: '0/2',
+      },
     });
     historyActivityPrinter.activity({
       event: {
@@ -142,12 +139,12 @@ test('print failed resources because of hook failures', () => {
         StackName: 'stack-name',
         ResourceStatusReason: 'The following hook(s) failed: hook1',
       },
-      deployment: "test",
+      deployment: 'test',
       progress: {
         completed: 0,
         total: 2,
-        formatted: "0/2"
-      }
+        formatted: '0/2',
+      },
     });
     historyActivityPrinter.stop();
   });
diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts b/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts
index f40a3df12..204d8544d 100644
--- a/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts
+++ b/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts
@@ -17,8 +17,8 @@ export { loadTree, some } from '../../../../aws-cdk/lib/api/tree';
 export * as contextproviders from '../../../../aws-cdk/lib/context-providers';
 
 // @todo APIs not clean import
-export { HotswapMode } from '../../../../aws-cdk/lib/api/hotswap/common';
-export { HotswapPropertyOverrides, EcsHotswapProperties } from '../../../../aws-cdk/lib/api/hotswap/common';
+export { HotswapMode } from '../../../../aws-cdk/lib/api/hotswap';
+export { HotswapPropertyOverrides, EcsHotswapProperties } from '../../../../aws-cdk/lib/api/hotswap';
 export { RWLock, type ILock } from '../../../../aws-cdk/lib/api/util/rwlock';
 
 // @todo Cloud Assembly and Executable - this is a messy API right now
@@ -28,4 +28,3 @@ export { guessExecutable } from '../../../../aws-cdk/lib/api/cxapp/exec';
 
 // @todo Should not use! investigate how to replace
 export { versionNumber } from '../../../../aws-cdk/lib/cli/version';
-export { CliIoHost } from '../../../../aws-cdk/lib/cli/io-host';
diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/shared-private.ts b/packages/@aws-cdk/toolkit-lib/lib/api/shared-private.ts
index c82b3c567..fec6dc06c 100644
--- a/packages/@aws-cdk/toolkit-lib/lib/api/shared-private.ts
+++ b/packages/@aws-cdk/toolkit-lib/lib/api/shared-private.ts
@@ -1,3 +1,4 @@
 /* eslint-disable import/no-restricted-paths */
 
 export * from '../../../tmp-toolkit-helpers/src/api/io/private';
+export * from '../../../tmp-toolkit-helpers/src/private';
diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/index.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/index.ts
index c28e88c5d..a69665dca 100644
--- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/index.ts
+++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/index.ts
@@ -1,2 +1,3 @@
 export * from './toolkit';
+export * from './non-interactive-io-host';
 export * from '../api/shared-public';
diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts
new file mode 100644
index 000000000..849e31876
--- /dev/null
+++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts
@@ -0,0 +1,174 @@
+import * as chalk from 'chalk';
+import type { IActivityPrinter } from '../api/shared-private';
+import { HistoryActivityPrinter, isMessageRelevantForLevel } from '../api/shared-private';
+import type { IIoHost, IoMessage, IoMessageLevel, IoRequest } from '../api/shared-public';
+import { isCI, isTTY } from '../util/shell-env';
+
+export interface NonInteractiveIoHostProps {
+  /**
+   * Determines the verbosity of the output.
+   *
+   * The IoHost will still receive all messages and requests,
+   * but only the messages included in this level will be printed.
+   *
+   * @default 'info'
+   */
+  readonly logLevel?: IoMessageLevel;
+
+  /**
+   * Overrides the automatic TTY detection.
+   *
+   * When TTY is disabled, the CLI will have no interactions or color.
+   *
+   * @default - determined from the current process
+   */
+  readonly isTTY?: boolean;
+
+  /**
+   * Whether the IoHost is running in CI mode.
+   *
+   * In CI mode, all non-error output goes to stdout instead of stderr.
+   * Set to false in the IoHost constructor it will be overwritten if the CLI CI argument is passed
+   *
+   * @default - determined from the environment, specifically based on `process.env.CI`
+   */
+  readonly isCI?: boolean;
+}
+
+/**
+ * A simple IO host for a non interactive CLI that writes messages to the console and returns the default answer to all requests.
+ */
+export class NonInteractiveIoHost implements IIoHost {
+  /**
+   * Whether the IoHost is running in CI mode.
+   *
+   * In CI mode, all non-error output goes to stdout instead of stderr.
+   */
+  public readonly isCI: boolean;
+
+  /**
+   * Whether the host can use interactions and message styling.
+   */
+  public readonly isTTY: boolean;
+
+  /**
+   * The current threshold.
+   *
+   * Messages with a lower priority level will be ignored.
+   */
+  public readonly logLevel: IoMessageLevel;
+
+  // Stack Activity Printer
+  private readonly activityPrinter: IActivityPrinter;
+
+  public constructor(props: NonInteractiveIoHostProps = {}) {
+    this.logLevel = props.logLevel ?? 'info';
+    this.isTTY = props.isTTY ?? isTTY();
+    this.isCI = props.isCI ?? isCI();
+
+    this.activityPrinter = new HistoryActivityPrinter({
+      stream: this.selectStreamFromLevel('info'),
+    });
+  }
+
+  /**
+   * Notifies the host of a message.
+   * The caller waits until the notification completes.
+   */
+  public async notify(msg: IoMessage<unknown>): Promise<void> {
+    if (isStackActivity(msg)) {
+      return this.activityPrinter.notify(msg);
+    }
+
+    if (!isMessageRelevantForLevel(msg, this.logLevel)) {
+      return;
+    }
+
+    const output = this.formatMessage(msg);
+    const stream = this.selectStream(msg);
+    stream?.write(output);
+  }
+
+  /**
+   * Determines the output stream, based on message and configuration.
+   */
+  private selectStream(msg: IoMessage<any>): NodeJS.WriteStream | undefined {
+    return this.selectStreamFromLevel(msg.level);
+  }
+
+  /**
+   * Determines the output stream, based on message level and configuration.
+   */
+  private selectStreamFromLevel(level: IoMessageLevel): NodeJS.WriteStream {
+    // The stream selection policy for the CLI is the following:
+    //
+    //   (1) Messages of level `result` always go to `stdout`
+    //   (2) Messages of level `error` always go to `stderr`.
+    //   (3a) All remaining messages go to `stderr`.
+    //   (3b) If we are in CI mode, all remaining messages go to `stdout`.
+    //
+    switch (level) {
+      case 'error':
+        return process.stderr;
+      case 'result':
+        return process.stdout;
+      default:
+        return this.isCI ? process.stdout : process.stderr;
+    }
+  }
+
+  /**
+   * Notifies the host of a message that requires a response.
+   *
+   * If the host does not return a response the suggested
+   * default response from the input message will be used.
+   */
+  public async requestResponse<DataType, ResponseType>(msg: IoRequest<DataType, ResponseType>): Promise<ResponseType> {
+    // in the non-interactive IoHost, no requests are promptable
+    await this.notify(msg);
+    return msg.defaultResponse;
+  }
+
+  /**
+   * Formats a message for console output with optional color support
+   */
+  private formatMessage(msg: IoMessage<unknown>): string {
+    // apply provided style or a default style if we're in TTY mode
+    let message_text = this.isTTY
+      ? styleMap[msg.level](msg.message)
+      : msg.message;
+
+    // prepend timestamp if IoMessageLevel is DEBUG or TRACE. Postpend a newline.
+    return ((msg.level === 'debug' || msg.level === 'trace')
+      ? `[${this.formatTime(msg.time)}] ${message_text}`
+      : message_text) + '\n';
+  }
+
+  /**
+   * Formats date to HH:MM:SS
+   */
+  private formatTime(d: Date): string {
+    const pad = (n: number): string => n.toString().padStart(2, '0');
+    return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+  }
+}
+
+const styleMap: Record<IoMessageLevel, (str: string) => string> = {
+  error: chalk.red,
+  warn: chalk.yellow,
+  result: chalk.white,
+  info: chalk.white,
+  debug: chalk.gray,
+  trace: chalk.gray,
+};
+
+/**
+ * Detect stack activity messages so they can be send to the printer.
+ */
+function isStackActivity(msg: IoMessage<unknown>) {
+  return [
+    'CDK_TOOLKIT_I5501',
+    'CDK_TOOLKIT_I5502',
+    'CDK_TOOLKIT_I5503',
+  ].includes(msg.code);
+}
diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
index b66c84710..fb904e566 100644
--- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
+++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
@@ -4,6 +4,7 @@ import * as chalk from 'chalk';
 import * as chokidar from 'chokidar';
 import * as fs from 'fs-extra';
 import * as uuid from 'uuid';
+import { NonInteractiveIoHost } from './non-interactive-io-host';
 import type { ToolkitServices } from './private';
 import { assemblyFromSource } from './private';
 import type { BootstrapEnvironments, BootstrapOptions, BootstrapResult, EnvironmentBootstrapResult } from '../actions/bootstrap';
@@ -21,7 +22,7 @@ import type { WatchOptions } from '../actions/watch';
 import { patternsArrayForWatch } from '../actions/watch/private';
 import { type SdkConfig } from '../api/aws-auth';
 import type { SuccessfulDeployStackResult, StackCollection, Concurrency, AssetBuildNode, AssetPublishNode, StackNode } from '../api/aws-cdk';
-import { DEFAULT_TOOLKIT_STACK_NAME, Bootstrapper, SdkProvider, Deployments, HotswapMode, ResourceMigrator, tagsForStack, CliIoHost, WorkGraphBuilder, CloudWatchLogEventMonitor, findCloudWatchLogGroups, createDiffChangeSet } from '../api/aws-cdk';
+import { DEFAULT_TOOLKIT_STACK_NAME, Bootstrapper, SdkProvider, Deployments, HotswapMode, ResourceMigrator, tagsForStack, WorkGraphBuilder, CloudWatchLogEventMonitor, findCloudWatchLogGroups, createDiffChangeSet } from '../api/aws-cdk';
 import type { ICloudAssemblySource } from '../api/cloud-assembly';
 import { StackSelectionStrategy } from '../api/cloud-assembly';
 import type { StackAssembly } from '../api/cloud-assembly/private';
@@ -39,14 +40,14 @@ export interface ToolkitOptions {
   /**
    * The IoHost implementation, handling the inline interactions between the Toolkit and an integration.
    */
-  ioHost?: IIoHost;
+  readonly ioHost?: IIoHost;
 
   /**
    * Allow emojis in messages sent to the IoHost.
    *
    * @default true
    */
-  emojis?: boolean;
+  readonly emojis?: boolean;
 
   /**
    * Whether to allow ANSI colors and formatting in IoHost messages.
@@ -56,26 +57,26 @@ export interface ToolkitOptions {
    *
    * @default - detects color from the TTY status of the IoHost
    */
-  color?: boolean;
+  readonly color?: boolean;
 
   /**
    * Configuration options for the SDK.
    */
-  sdkConfig?: SdkConfig;
+  readonly sdkConfig?: SdkConfig;
 
   /**
    * Name of the toolkit stack to be used.
    *
    * @default "CDKToolkit"
    */
-  toolkitStackName?: string;
+  readonly toolkitStackName?: string;
 
   /**
    * Fail Cloud Assemblies
    *
    * @default "error"
    */
-  assemblyFailureAt?: 'error' | 'warn' | 'none';
+  readonly assemblyFailureAt?: 'error' | 'warn' | 'none';
 }
 
 /**
@@ -91,18 +92,17 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
    * The IoHost of this Toolkit
    */
   public readonly ioHost: IIoHost;
+
+  /**
+   * Cache of the internal SDK Provider instance
+   */
   private _sdkProvider?: SdkProvider;
 
   public constructor(private readonly props: ToolkitOptions = {}) {
     super();
     this.toolkitStackName = props.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME;
 
-    // Hacky way to re-use the global IoHost until we have fully removed the need for it
-    const globalIoHost = CliIoHost.instance();
-    if (props.ioHost) {
-      globalIoHost.registerIoHost(props.ioHost as any);
-    }
-    let ioHost = globalIoHost as IIoHost;
+    let ioHost = props.ioHost ?? new NonInteractiveIoHost();
     if (props.emojis === false) {
       ioHost = withoutEmojis(ioHost);
     }
diff --git a/packages/@aws-cdk/toolkit-lib/lib/util/shell-env.ts b/packages/@aws-cdk/toolkit-lib/lib/util/shell-env.ts
new file mode 100644
index 000000000..9d71fd7d8
--- /dev/null
+++ b/packages/@aws-cdk/toolkit-lib/lib/util/shell-env.ts
@@ -0,0 +1,15 @@
+/**
+ * Returns true if the current process is running in a CI environment
+ * @returns true if the current process is running in a CI environment
+ */
+export function isCI(): boolean {
+  return process.env.CI !== undefined && process.env.CI !== 'false' && process.env.CI !== '0';
+}
+
+/**
+ * Returns true if the current process is running in a TTY environment
+ * @returns true if the current process is running in a TTY environment
+ */
+export function isTTY(): boolean {
+  return process.stdout.isTTY ?? false;
+}
diff --git a/packages/aws-cdk/lib/api/hotswap/index.ts b/packages/aws-cdk/lib/api/hotswap/index.ts
new file mode 100644
index 000000000..d0b932366
--- /dev/null
+++ b/packages/aws-cdk/lib/api/hotswap/index.ts
@@ -0,0 +1 @@
+export * from './common';
diff --git a/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts b/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts
index 6708f9463..9526a73db 100644
--- a/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts
+++ b/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts
@@ -6,9 +6,9 @@ import { ToolkitError } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api';
 import type { IIoHost, IoMessage, IoMessageCode, IoMessageLevel, IoRequest, ToolkitAction } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api';
 import type { IoHelper } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private';
 import { asIoHelper, IO, IoDefaultMessages, isMessageRelevantForLevel } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private';
+import { CurrentActivityPrinter, HistoryActivityPrinter } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer';
+import type { ActivityPrinterProps, IActivityPrinter } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer';
 import { StackActivityProgress } from '../../commands/deploy';
-import { CurrentActivityPrinter, HistoryActivityPrinter } from '../activity-printer';
-import type { ActivityPrinterProps, IActivityPrinter } from '../activity-printer';
 
 export type { IIoHost, IoMessage, IoMessageCode, IoMessageLevel, IoRequest };
 


Reviews:
user: login: rix0rrr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T10:47:34Z
user: login: rix0rrr
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T10:49:33Z
user: login: mrgrain
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T10:51:47Z
user: login: mrgrain
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T10:53:12Z

Review comments:
diff_hunk: @@ -0,0 +1,174 @@
+import * as chalk from 'chalk';
+import type { IActivityPrinter } from '../api/shared-private';
+import { HistoryActivityPrinter, isMessageRelevantForLevel } from '../api/shared-private';
+import type { IIoHost, IoMessage, IoMessageLevel, IoRequest } from '../api/shared-public';
+import { isCI, isTTY } from '../util/shell-env';
+
+export interface NonInteractiveIoHostProps {
+  /**
+   * Determines the verbosity of the output.
+   *
+   * The IoHost will still receive all messages and requests,
+   * but only the messages included in this level will be printed.
+   *
+   * @default 'info'
+   */
+  readonly logLevel?: IoMessageLevel;
+
+  /**
+   * Overrides the automatic TTY detection.
+   *
+   * When TTY is disabled, the CLI will have no interactions or color.
+   *
+   * @default - determined from the current process
+   */
+  readonly isTTY?: boolean;
+
+  /**
+   * Whether the IoHost is running in CI mode.
+   *
+   * In CI mode, all non-error output goes to stdout instead of stderr.
+   * Set to false in the IoHost constructor it will be overwritten if the CLI CI argument is passed
+   *
+   * @default - determined from the environment, specifically based on `process.env.CI`
+   */
+  readonly isCI?: boolean;
+}
+
+/**
+ * A simple IO host for a non interactive CLI that writes messages to the console.
path: packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts
user: login: rix0rrr
body: ...and returns the default answer to all queries?

Or fails all queries?
created_at: 2025-04-03T10:47:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,174 @@
+import * as chalk from 'chalk';
+import type { IActivityPrinter } from '../api/shared-private';
+import { HistoryActivityPrinter, isMessageRelevantForLevel } from '../api/shared-private';
+import type { IIoHost, IoMessage, IoMessageLevel, IoRequest } from '../api/shared-public';
+import { isCI, isTTY } from '../util/shell-env';
+
+export interface NonInteractiveIoHostProps {
+  /**
+   * Determines the verbosity of the output.
+   *
+   * The IoHost will still receive all messages and requests,
+   * but only the messages included in this level will be printed.
+   *
+   * @default 'info'
+   */
+  readonly logLevel?: IoMessageLevel;
+
+  /**
+   * Overrides the automatic TTY detection.
+   *
+   * When TTY is disabled, the CLI will have no interactions or color.
+   *
+   * @default - determined from the current process
+   */
+  readonly isTTY?: boolean;
+
+  /**
+   * Whether the IoHost is running in CI mode.
+   *
+   * In CI mode, all non-error output goes to stdout instead of stderr.
+   * Set to false in the IoHost constructor it will be overwritten if the CLI CI argument is passed
+   *
+   * @default - determined from the environment, specifically based on `process.env.CI`
+   */
+  readonly isCI?: boolean;
+}
+
+/**
+ * A simple IO host for a non interactive CLI that writes messages to the console.
path: packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts
user: login: mrgrain
body: ```suggestion
 * A simple IO host for a non interactive CLI that writes messages to the console and returns the default answer to all requests.
```
created_at: 2025-04-03T10:51:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,174 @@
+import * as chalk from 'chalk';
+import type { IActivityPrinter } from '../api/shared-private';
+import { HistoryActivityPrinter, isMessageRelevantForLevel } from '../api/shared-private';
+import type { IIoHost, IoMessage, IoMessageLevel, IoRequest } from '../api/shared-public';
+import { isCI, isTTY } from '../util/shell-env';
+
+export interface NonInteractiveIoHostProps {
+  /**
+   * Determines the verbosity of the output.
+   *
+   * The IoHost will still receive all messages and requests,
+   * but only the messages included in this level will be printed.
+   *
+   * @default 'info'
+   */
+  readonly logLevel?: IoMessageLevel;
+
+  /**
+   * Overrides the automatic TTY detection.
+   *
+   * When TTY is disabled, the CLI will have no interactions or color.
+   *
+   * @default - determined from the current process
+   */
+  readonly isTTY?: boolean;
+
+  /**
+   * Whether the IoHost is running in CI mode.
+   *
+   * In CI mode, all non-error output goes to stdout instead of stderr.
+   * Set to false in the IoHost constructor it will be overwritten if the CLI CI argument is passed
+   *
+   * @default - determined from the environment, specifically based on `process.env.CI`
+   */
+  readonly isCI?: boolean;
+}
+
+/**
+ * A simple IO host for a non interactive CLI that writes messages to the console.
path: packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts
user: login: mrgrain
body: >...and returns the default answer to all queries?

This. There's not really a concept of failing. For booleans you might consider the inverse a failure or it could just throw.
created_at: 2025-04-03T10:53:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov-commenter
created_at: 2025-04-03T09:49:19Z
body: ## [Codecov](https://app.codecov.io/gh/aws/aws-cdk-cli/pull/306?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aws) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 85.49%. Comparing base [(`3e8359c`)](https://app.codecov.io/gh/aws/aws-cdk-cli/commit/3e8359caca6c88e6299a6fbdcfeaddec1964a177?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aws) to head [(`62c3dd3`)](https://app.codecov.io/gh/aws/aws-cdk-cli/commit/62c3dd3e401b6330d07a2fd8219d237598f38f81?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aws).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #306      +/-   ##
==========================================
- Coverage   85.58%   85.49%   -0.09%     
==========================================
  Files         223      223              
  Lines       37002    37002              
  Branches     4493     4500       +7     
==========================================
- Hits        31668    31636      -32     
- Misses       5233     5269      +36     
+ Partials      101       97       -4     
```

| [Flag](https://app.codecov.io/gh/aws/aws-cdk-cli/pull/306/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aws) | Coverage Δ | |
|---|---|---|
| [suite.unit](https://app.codecov.io/gh/aws/aws-cdk-cli/pull/306/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aws) | `85.49% <100.00%> (-0.09%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aws#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aws/aws-cdk-cli/pull/306?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aws).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aws).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: mrgrain
created_at: 2025-04-03T11:28:30Z
body: exempted integ test because a previous run passed and the latest change just fixed a comment
author_association: CONTRIBUTOR