diff --git a/cli.php b/cli.php
index c7f7a9f..2db2c82 100644
--- a/cli.php
+++ b/cli.php
@@ -5,6 +5,7 @@
require __DIR__.'/vendor/autoload.php';
use Keboola\Console\Command\AddFeature;
+use Keboola\Console\Command\CleanupLeakedTestFeatures;
use Keboola\Console\Command\AllStacksIterator;
use Keboola\Console\Command\DeleteProjects;
use Keboola\Console\Command\DeleteStorageBackend;
@@ -46,6 +47,7 @@
$application->add(new MassProjectEnableDynamicBackends());
$application->add(new MigrateDataAppsOrchestratorTasks());
$application->add(new AddFeature());
+$application->add(new CleanupLeakedTestFeatures());
$application->add(new AllStacksIterator());
$application->add(new LineageEventsExport());
$application->add(new QueueMassTerminateJobs());
diff --git a/src/Keboola/Console/Command/CleanupLeakedTestFeatures.php b/src/Keboola/Console/Command/CleanupLeakedTestFeatures.php
new file mode 100644
index 0000000..2a57902
--- /dev/null
+++ b/src/Keboola/Console/Command/CleanupLeakedTestFeatures.php
@@ -0,0 +1,123 @@
+setName('manage:cleanup-leaked-test-features')
+ ->setDescription('Delete features leaked by connection E2E tests (matched by test name prefixes)')
+ ->addArgument('token', InputArgument::REQUIRED, 'manage token (super-admin)')
+ ->addArgument('url', InputArgument::REQUIRED, 'Stack URL')
+ ->addOption('force', 'f', InputOption::VALUE_NONE, 'Will actually do the work, otherwise it\'s dry run')
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $apiToken = $input->getArgument('token');
+ assert(is_string($apiToken));
+ $apiUrl = $input->getArgument('url');
+ assert(is_string($apiUrl));
+ $force = (bool) $input->getOption('force');
+
+ if (in_array(parse_url($apiUrl, PHP_URL_HOST), self::PRODUCTION_HOSTS, true)) {
+ $output->writeln(sprintf('Refusing to run against production host %s.', $apiUrl));
+ return 1;
+ }
+
+ $client = new Client([
+ 'url' => $apiUrl,
+ 'token' => $apiToken,
+ ]);
+
+ $matched = [];
+ foreach (['admin', 'project', 'global'] as $type) {
+ $output->write(sprintf('Listing "%s" features... ', $type));
+ $features = $client->listFeatures(['type' => $type]);
+ $matchedForType = 0;
+ foreach ($features as $feature) {
+ foreach (self::LEAKED_NAME_PREFIXES as $prefix) {
+ if (strpos((string) $feature['name'], $prefix) === 0) {
+ $matched[] = $feature;
+ $matchedForType++;
+ break;
+ }
+ }
+ }
+ $output->writeln(sprintf('%d total, %d matched', count($features), $matchedForType));
+ }
+
+ if ($matched === []) {
+ $output->writeln('No leaked test features found.');
+ return 0;
+ }
+
+ $output->writeln(sprintf('Matched %d leaked test features on %s:', count($matched), $apiUrl));
+ $shown = $output->isVerbose() ? $matched : array_slice($matched, 0, self::LIST_SAMPLE_SIZE);
+ foreach ($shown as $feature) {
+ $output->writeln(sprintf(' [%s] #%d %s', $feature['type'], $feature['id'], $feature['name']));
+ }
+ if (count($matched) > count($shown)) {
+ $output->writeln(sprintf(' ... and %d more (use -v to list all)', count($matched) - count($shown)));
+ }
+
+ if (!$force) {
+ $output->writeln('');
+ $output->writeln('Command was run in dry-run mode. To actually apply changes run it with --force flag.');
+ return 0;
+ }
+
+ $deleted = 0;
+ $failed = 0;
+ foreach ($matched as $i => $feature) {
+ try {
+ $output->writeln('Dropping feature');
+
+ $client->removeFeature($feature['id']);
+ $deleted++;
+ } catch (ClientException $e) {
+ $failed++;
+ $output->writeln(sprintf(
+ ' FAILED to delete #%d %s: %s',
+ $feature['id'],
+ $feature['name'],
+ $e->getMessage()
+ ));
+ }
+ if (($i + 1) % self::PROGRESS_EVERY === 0) {
+ $output->writeln(sprintf(' %d/%d processed (%d deleted, %d failed)', $i + 1, count($matched), $deleted, $failed));
+ }
+ }
+
+ $output->writeln('');
+ $output->writeln(sprintf('Deleted %d features, %d failed.', $deleted, $failed));
+ return $failed > 0 ? 1 : 0;
+ }
+}