From 054916950c7917b3ae3e9e5af61b053afd5367e9 Mon Sep 17 00:00:00 2001
From: Nikola Irinchev <irinchev@me.com>
Date: Wed, 6 Nov 2024 10:39:23 +0100
Subject: [PATCH 1/8] feat: add support for fetching a greeting CTA as part of
 the update flow

---
 packages/cli-repl/src/cli-repl.ts             | 12 +++-
 packages/cli-repl/src/mongosh-repl.ts         | 29 ++++++---
 .../src/update-notification-manager.ts        | 60 ++++++++++++++++++-
 packages/types/src/index.ts                   |  2 +
 4 files changed, 90 insertions(+), 13 deletions(-)

diff --git a/packages/cli-repl/src/cli-repl.ts b/packages/cli-repl/src/cli-repl.ts
index e69350f6dd..a1ec1202cd 100644
--- a/packages/cli-repl/src/cli-repl.ts
+++ b/packages/cli-repl/src/cli-repl.ts
@@ -414,7 +414,11 @@ export class CliRepl implements MongoshIOProvider {
     markTime(TimingCategories.DriverSetup, 'completed SP setup');
     const initialized = await this.mongoshRepl.initialize(
       initialServiceProvider,
-      await this.getMoreRecentMongoshVersion()
+      {
+        moreRecentMongoshVersion: await this.getMoreRecentMongoshVersion(),
+        currentVersionCTA:
+          await this.updateNotificationManager.getGreetingCTAForCurrentVersion(),
+      }
     );
     markTime(TimingCategories.REPLInstantiation, 'initialized mongosh repl');
     this.injectReplFunctions();
@@ -1264,6 +1268,7 @@ export class CliRepl implements MongoshIOProvider {
       const updateURL = (await this.getConfig('updateURL')).trim();
       if (!updateURL) return;
 
+      const { version: currentVersion } = require('../package.json');
       const localFilePath = this.shellHomeDirectory.localPath(
         'update-metadata.json'
       );
@@ -1271,14 +1276,17 @@ export class CliRepl implements MongoshIOProvider {
       this.bus.emit('mongosh:fetching-update-metadata', {
         updateURL,
         localFilePath,
+        currentVersion,
       });
       await this.updateNotificationManager.fetchUpdateMetadata(
         updateURL,
-        localFilePath
+        localFilePath,
+        currentVersion
       );
       this.bus.emit('mongosh:fetching-update-metadata-complete', {
         latest:
           await this.updateNotificationManager.getLatestVersionIfMoreRecent(''),
+        currentVersion,
       });
     } catch (err: any) {
       this.bus.emit('mongosh:error', err, 'startup');
diff --git a/packages/cli-repl/src/mongosh-repl.ts b/packages/cli-repl/src/mongosh-repl.ts
index 8903db2ab4..223e60edaf 100644
--- a/packages/cli-repl/src/mongosh-repl.ts
+++ b/packages/cli-repl/src/mongosh-repl.ts
@@ -114,6 +114,11 @@ type MongoshRuntimeState = {
   console: Console;
 };
 
+type GreetingDetails = {
+  moreRecentMongoshVersion?: string | null;
+  currentVersionCTA?: { text: string; style: StyleDefinition }[];
+};
+
 /* Utility, inverse of Readonly<T> */
 type Mutable<T> = {
   -readonly [P in keyof T]: T[P];
@@ -177,7 +182,7 @@ class MongoshNodeRepl implements EvaluationListener {
    */
   async initialize(
     serviceProvider: ServiceProvider,
-    moreRecentMongoshVersion?: string | null
+    greeting: GreetingDetails
   ): Promise<InitializationToken> {
     const usePlainVMContext = this.shellCliOptions.jsContext === 'plain-vm';
 
@@ -221,7 +226,7 @@ class MongoshNodeRepl implements EvaluationListener {
             (mongodVersion ? mongodVersion + ' ' : '') +
             `(API Version ${apiVersion})`;
         }
-        await this.greet(mongodVersion, moreRecentMongoshVersion);
+        await this.greet(mongodVersion, greeting);
       }
     }
 
@@ -577,10 +582,10 @@ class MongoshNodeRepl implements EvaluationListener {
   /**
    * The greeting for the shell, showing server and shell version.
    */
-  async greet(
-    mongodVersion: string,
-    moreRecentMongoshVersion?: string | null
-  ): Promise<void> {
+  async greet(mongodVersion: string, greeting: GreetingDetails): Promise<void> {
+    this.output.write('sadfasdfasdfassafsa');
+    this.output.write(JSON.stringify({ mongodVersion, greeting }) + '\n');
+
     if (this.shellCliOptions.quiet) {
       return;
     }
@@ -593,15 +598,23 @@ class MongoshNodeRepl implements EvaluationListener {
       'Using Mongosh',
       'mongosh:section-header'
     )}:\t\t${version}\n`;
-    if (moreRecentMongoshVersion) {
+    if (greeting.moreRecentMongoshVersion) {
       text += `mongosh ${this.clr(
-        moreRecentMongoshVersion,
+        greeting.moreRecentMongoshVersion,
         'bold'
       )} is available for download: ${this.clr(
         'https://www.mongodb.com/try/download/shell',
         'mongosh:uri'
       )}\n`;
     }
+
+    if (greeting.currentVersionCTA) {
+      for (const run of greeting.currentVersionCTA) {
+        text += this.clr(run.text, run.style);
+      }
+      text += '\n';
+    }
+
     text += `${MONGOSH_WIKI}\n`;
     if (!(await this.getConfig('disableGreetingMessage'))) {
       text += `${TELEMETRY_GREETING_MESSAGE}\n`;
diff --git a/packages/cli-repl/src/update-notification-manager.ts b/packages/cli-repl/src/update-notification-manager.ts
index 12a7604d3c..87ff1a16f7 100644
--- a/packages/cli-repl/src/update-notification-manager.ts
+++ b/packages/cli-repl/src/update-notification-manager.ts
@@ -7,18 +7,37 @@ import type {
   Response,
 } from '@mongodb-js/devtools-proxy-support';
 import { createFetch } from '@mongodb-js/devtools-proxy-support';
+import { StyleDefinition } from './clr';
+
+interface GreetingCTADetails {
+  chunks: {
+    text: string;
+    style: StyleDefinition;
+  }[];
+}
+
+interface MongoshVersionsContents {
+  versions: {
+    version: string;
+    cta?: GreetingCTADetails;
+  }[];
+}
 
 interface MongoshUpdateLocalFileContents {
   lastChecked?: number;
   latestKnownMongoshVersion?: string;
   etag?: string;
   updateURL?: string;
+  cta?: {
+    [version: string]: GreetingCTADetails | undefined;
+  };
 }
 
 // Utility for fetching metadata about potentially available newer versions
 // and returning that latest version if available.
 export class UpdateNotificationManager {
   private latestKnownMongoshVersion: string | undefined = undefined;
+  private currentVersionGreetingCTA: GreetingCTADetails | undefined = undefined;
   private localFilesystemFetchInProgress: Promise<unknown> | undefined =
     undefined;
   private fetch: (url: string, init: RequestInit) => Promise<Response>;
@@ -49,12 +68,29 @@ export class UpdateNotificationManager {
     return this.latestKnownMongoshVersion;
   }
 
+  async getGreetingCTAForCurrentVersion(): Promise<
+    | {
+        text: string;
+        style: StyleDefinition;
+      }[]
+    | undefined
+  > {
+    try {
+      await this.localFilesystemFetchInProgress;
+    } catch {
+      /* already handled in fetchUpdateMetadata() */
+    }
+
+    return this.currentVersionGreetingCTA?.chunks;
+  }
+
   // Fetch update metadata, taking into account a local cache and an external
   // JSON feed. This function will throw in case it failed to load information
   // about latest versions.
   async fetchUpdateMetadata(
     updateURL: string,
-    localFilePath: string
+    localFilePath: string,
+    currentVersion: string
   ): Promise<void> {
     let localFileContents: MongoshUpdateLocalFileContents | undefined;
     await (this.localFilesystemFetchInProgress = (async () => {
@@ -90,6 +126,10 @@ export class UpdateNotificationManager {
           localFileContents.latestKnownMongoshVersion;
       }
 
+      if (localFileContents?.cta && currentVersion in localFileContents.cta) {
+        this.currentVersionGreetingCTA = localFileContents.cta[currentVersion];
+      }
+
       this.localFilesystemFetchInProgress = undefined;
     })());
 
@@ -123,17 +163,31 @@ export class UpdateNotificationManager {
       );
     }
 
-    const jsonContents = (await response.json()) as { versions?: any[] };
+    const jsonContents = (await response.json()) as MongoshVersionsContents;
     this.latestKnownMongoshVersion = jsonContents?.versions
-      ?.map((v: any) => v.version as string)
+      ?.map((v) => v.version)
       ?.filter((v) => !semver.prerelease(v))
       ?.sort(semver.rcompare)?.[0];
 
+    this.currentVersionGreetingCTA = jsonContents?.versions?.filter(
+      (v) => v.version === currentVersion
+    )?.[0]?.cta;
+
+    const latestKnownVersionCTA = jsonContents?.versions?.filter(
+      (v) => v.version === this.latestKnownMongoshVersion
+    )?.[0]?.cta;
+
     localFileContents = {
       updateURL,
       lastChecked: Date.now(),
       etag: response.headers.get('etag') ?? undefined,
       latestKnownMongoshVersion: this.latestKnownMongoshVersion,
+      cta: {
+        [currentVersion]: this.currentVersionGreetingCTA,
+        ...(this.latestKnownMongoshVersion && {
+          [this.latestKnownMongoshVersion]: latestKnownVersionCTA,
+        }),
+      },
     };
     await fs.writeFile(localFilePath, JSON.stringify(localFileContents));
   }
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 06fa242c0d..39f8190f46 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -173,10 +173,12 @@ export interface EditorReadVscodeExtensionsFailedEvent {
 export interface FetchingUpdateMetadataEvent {
   updateURL: string;
   localFilePath: string;
+  currentVersion: string;
 }
 
 export interface FetchingUpdateMetadataCompleteEvent {
   latest: string | null;
+  currentVersion: string;
 }
 
 export interface SessionStartedEvent {

From 43efe22b5cf4fabb6f01b831caa307c77047e233 Mon Sep 17 00:00:00 2001
From: Nikola Irinchev <irinchev@me.com>
Date: Wed, 6 Nov 2024 10:55:58 +0100
Subject: [PATCH 2/8] Add a comment

---
 packages/cli-repl/src/cli-repl.ts                    |  2 ++
 packages/cli-repl/src/update-notification-manager.ts | 10 +++++++---
 packages/types/src/index.ts                          |  1 +
 3 files changed, 10 insertions(+), 3 deletions(-)

diff --git a/packages/cli-repl/src/cli-repl.ts b/packages/cli-repl/src/cli-repl.ts
index a1ec1202cd..c9756ec1ad 100644
--- a/packages/cli-repl/src/cli-repl.ts
+++ b/packages/cli-repl/src/cli-repl.ts
@@ -1287,6 +1287,8 @@ export class CliRepl implements MongoshIOProvider {
         latest:
           await this.updateNotificationManager.getLatestVersionIfMoreRecent(''),
         currentVersion,
+        hasGreetingCTA:
+          !!(await this.updateNotificationManager.getGreetingCTAForCurrentVersion()),
       });
     } catch (err: any) {
       this.bus.emit('mongosh:error', err, 'startup');
diff --git a/packages/cli-repl/src/update-notification-manager.ts b/packages/cli-repl/src/update-notification-manager.ts
index 87ff1a16f7..b459fbc2a6 100644
--- a/packages/cli-repl/src/update-notification-manager.ts
+++ b/packages/cli-repl/src/update-notification-manager.ts
@@ -184,9 +184,13 @@ export class UpdateNotificationManager {
       latestKnownMongoshVersion: this.latestKnownMongoshVersion,
       cta: {
         [currentVersion]: this.currentVersionGreetingCTA,
-        ...(this.latestKnownMongoshVersion && {
-          [this.latestKnownMongoshVersion]: latestKnownVersionCTA,
-        }),
+
+        // Add the latest known version's CTA if we're not already on latest. This could be used
+        // next time we start mongosh if the user has updated to latest.
+        ...(this.latestKnownMongoshVersion &&
+          this.latestKnownMongoshVersion !== currentVersion && {
+            [this.latestKnownMongoshVersion]: latestKnownVersionCTA,
+          }),
       },
     };
     await fs.writeFile(localFilePath, JSON.stringify(localFileContents));
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 39f8190f46..22da72a78a 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -179,6 +179,7 @@ export interface FetchingUpdateMetadataEvent {
 export interface FetchingUpdateMetadataCompleteEvent {
   latest: string | null;
   currentVersion: string;
+  hasGreetingCTA: boolean;
 }
 
 export interface SessionStartedEvent {

From 2a0955f6d3a9407b65eb07ceb278d7057f29885f Mon Sep 17 00:00:00 2001
From: Nikola Irinchev <irinchev@me.com>
Date: Fri, 8 Nov 2024 08:45:23 +0100
Subject: [PATCH 3/8] Wire up some basic cli commands for updating the cta

---
 config/cta.conf.js                            | 21 +++++
 packages/build/src/download-center/config.ts  | 87 +++++++++++++++----
 .../build/src/download-center/constants.ts    | 15 ++--
 packages/build/src/index.ts                   | 63 +++++++++-----
 packages/cli-repl/src/mongosh-repl.ts         | 11 ++-
 .../src/update-notification-manager.spec.ts   | 21 +++--
 .../src/update-notification-manager.ts        | 14 +--
 .../src/snippet-manager.spec.ts               |  2 +-
 8 files changed, 173 insertions(+), 61 deletions(-)
 create mode 100644 config/cta.conf.js

diff --git a/config/cta.conf.js b/config/cta.conf.js
new file mode 100644
index 0000000000..2f65194b89
--- /dev/null
+++ b/config/cta.conf.js
@@ -0,0 +1,21 @@
+'use strict';
+
+module.exports = {
+    awsAccessKeyId: process.env.DOWNLOAD_CENTER_AWS_KEY,
+    awsSecretAccessKey: process.env.DOWNLOAD_CENTER_AWS_SECRET,
+    ctas: {
+        // Define the ctas per version here. '*' is the default cta which will be shown if there's no specific cta
+        // for the current version.
+        // '*': {
+        //     runs: [
+        //         { text: 'Example', style: 'bold' },
+        //     ]
+        // },
+        // '1.2.3': {
+        //     runs: [
+        //         { text: 'Example', style: 'mongosh:uri' },
+        //     ]
+        // }
+    },
+    isDryRun: false,
+}
\ No newline at end of file
diff --git a/packages/build/src/download-center/config.ts b/packages/build/src/download-center/config.ts
index b88890037d..d8f3230c71 100644
--- a/packages/build/src/download-center/config.ts
+++ b/packages/build/src/download-center/config.ts
@@ -9,7 +9,7 @@ import type {
 } from '@mongodb-js/dl-center/dist/download-center-config';
 import {
   ARTIFACTS_BUCKET,
-  ARTIFACTS_FOLDER,
+  JSON_FEED_ARTIFACT_KEY,
   ARTIFACTS_URL_PUBLIC_BASE,
   CONFIGURATION_KEY,
   CONFIGURATIONS_BUCKET,
@@ -32,6 +32,24 @@ import path from 'path';
 import semver from 'semver';
 import { hashListFiles } from '../run-download-and-list-artifacts';
 
+async function getCurrentJsonFeed(
+  dlcenterArtifacts: DownloadCenterCls
+): Promise<JsonFeed | undefined> {
+  let existingJsonFeedText;
+  try {
+    existingJsonFeedText = await dlcenterArtifacts.downloadAsset(
+      JSON_FEED_ARTIFACT_KEY
+    );
+  } catch (err: any) {
+    console.warn('Failed to get existing JSON feed text', err);
+    if (err?.code !== 'NoSuchKey') throw err;
+  }
+
+  return existingJsonFeedText
+    ? JSON.parse(existingJsonFeedText.toString())
+    : undefined;
+}
+
 export async function createAndPublishDownloadCenterConfig(
   outputDir: string,
   packageInformation: PackageInformationProvider,
@@ -80,20 +98,8 @@ export async function createAndPublishDownloadCenterConfig(
     accessKeyId: awsAccessKeyId,
     secretAccessKey: awsSecretAccessKey,
   });
-  const jsonFeedArtifactkey = `${ARTIFACTS_FOLDER}/mongosh.json`;
-  let existingJsonFeedText;
-  try {
-    existingJsonFeedText = await dlcenterArtifacts.downloadAsset(
-      jsonFeedArtifactkey
-    );
-  } catch (err: any) {
-    console.warn('Failed to get existing JSON feed text', err);
-    if (err?.code !== 'NoSuchKey') throw err;
-  }
 
-  const existingJsonFeed: JsonFeed | undefined = existingJsonFeedText
-    ? JSON.parse(existingJsonFeedText.toString())
-    : undefined;
+  const existingJsonFeed = await getCurrentJsonFeed(dlcenterArtifacts);
   const injectedJsonFeed: JsonFeed | undefined = injectedJsonFeedFile
     ? JSON.parse(await fs.readFile(injectedJsonFeedFile, 'utf8'))
     : undefined;
@@ -122,12 +128,42 @@ export async function createAndPublishDownloadCenterConfig(
   await Promise.all([
     dlcenter.uploadConfig(CONFIGURATION_KEY, config),
     dlcenterArtifacts.uploadAsset(
-      jsonFeedArtifactkey,
+      JSON_FEED_ARTIFACT_KEY,
       JSON.stringify(newJsonFeed, null, 2)
     ),
   ]);
 }
 
+export async function updateJsonFeedCTA(
+  config: UpdateCTAConfig,
+  DownloadCenter: typeof DownloadCenterCls = DownloadCenterCls
+) {
+  const dlcenterArtifacts = new DownloadCenter({
+    bucket: ARTIFACTS_BUCKET,
+    accessKeyId: config.awsAccessKeyId,
+    secretAccessKey: config.awsSecretAccessKey,
+  });
+
+  const jsonFeed = await getCurrentJsonFeed(dlcenterArtifacts);
+  if (!jsonFeed) {
+    throw new Error('No existing JSON feed found');
+  }
+
+  jsonFeed.cta = config.ctas['*'];
+  for (const version of jsonFeed.versions) {
+    version.cta = config.ctas[version.version];
+  }
+
+  const patchedJsonFeed = JSON.stringify(jsonFeed, null, 2);
+  if (config.isDryRun) {
+    console.warn('Not uploading JSON feed in dry-run mode');
+    console.warn(`Patched JSON feed: ${patchedJsonFeed}`);
+    return;
+  }
+
+  await dlcenterArtifacts.uploadAsset(JSON_FEED_ARTIFACT_KEY, patchedJsonFeed);
+}
+
 export function getUpdatedDownloadCenterConfig(
   downloadedConfig: DownloadCenterConfig,
   getVersionConfig: () => ReturnType<typeof createVersionConfig>
@@ -201,13 +237,32 @@ export function createVersionConfig(
   };
 }
 
+// TODO: this is duplicated in update-notification-manager.ts
+interface GreetingCTADetails {
+  chunks: {
+    text: string;
+    style: string; // TODO: this is actually clr.ts/StyleDefinition
+  }[];
+}
+
+export interface UpdateCTAConfig {
+  ctas: {
+    [version: string]: GreetingCTADetails;
+  };
+  awsAccessKeyId: string;
+  awsSecretAccessKey: string;
+  isDryRun: boolean;
+}
+
 interface JsonFeed {
   versions: JsonFeedVersionEntry[];
+  cta?: GreetingCTADetails;
 }
 
 interface JsonFeedVersionEntry {
   version: string;
   downloads: JsonFeedDownloadEntry[];
+  cta?: GreetingCTADetails;
 }
 
 interface JsonFeedDownloadEntry {
@@ -275,6 +330,8 @@ function mergeFeeds(...args: (JsonFeed | undefined)[]): JsonFeed {
       if (index === -1) newFeed.versions.unshift(version);
       else newFeed.versions.splice(index, 1, version);
     }
+
+    newFeed.cta = feed?.cta ?? newFeed.cta;
   }
   newFeed.versions.sort((a, b) => semver.rcompare(a.version, b.version));
   return newFeed;
diff --git a/packages/build/src/download-center/constants.ts b/packages/build/src/download-center/constants.ts
index b3d953fd33..5ba6a4d773 100644
--- a/packages/build/src/download-center/constants.ts
+++ b/packages/build/src/download-center/constants.ts
@@ -3,25 +3,30 @@ const fallback = require('./fallback.json');
 /**
  * The S3 bucket for download center configurations.
  */
-export const CONFIGURATIONS_BUCKET = 'info-mongodb-com' as const;
+export const CONFIGURATIONS_BUCKET = 'info-mongodb-com';
 
 /**
  * The S3 object key for the download center configuration.
  */
 export const CONFIGURATION_KEY =
-  'com-download-center/mongosh.multiversion.json' as const;
+  'com-download-center/mongosh.multiversion.json';
 
 /**
  * The S3 bucket for download center artifacts.
  */
-export const ARTIFACTS_BUCKET = 'downloads.10gen.com' as const;
+export const ARTIFACTS_BUCKET = 'downloads.10gen.com';
 
 /**
  * The S3 "folder" for uploaded artifacts.
  */
-export const ARTIFACTS_FOLDER = 'compass' as const;
+export const ARTIFACTS_FOLDER = 'compass';
+
+/**
+ * The S3 artifact key for the versions JSON feed.
+ */
+export const JSON_FEED_ARTIFACT_KEY = `${ARTIFACTS_FOLDER}/mongosh.json`;
 
 export const ARTIFACTS_URL_PUBLIC_BASE =
-  'https://downloads.mongodb.com/compass/' as const;
+  'https://downloads.mongodb.com/compass/';
 
 export const ARTIFACTS_FALLBACK = Object.freeze(fallback);
diff --git a/packages/build/src/index.ts b/packages/build/src/index.ts
index 064557fa63..1a2e237829 100644
--- a/packages/build/src/index.ts
+++ b/packages/build/src/index.ts
@@ -6,10 +6,11 @@ import { triggerRelease } from './local';
 import type { ReleaseCommand } from './release';
 import { release } from './release';
 import type { Config, PackageVariant } from './config';
+import { updateJsonFeedCTA, UpdateCTAConfig } from './download-center';
 
 export { getArtifactUrl, downloadMongoDb };
 
-const validCommands: (ReleaseCommand | 'trigger-release')[] = [
+const validCommands: (ReleaseCommand | 'trigger-release' | 'update-cta')[] = [
   'bump',
   'compile',
   'package',
@@ -20,11 +21,12 @@ const validCommands: (ReleaseCommand | 'trigger-release')[] = [
   'download-crypt-shared-library',
   'download-and-list-artifacts',
   'trigger-release',
+  'update-cta',
 ] as const;
 
 const isValidCommand = (
   cmd: string
-): cmd is ReleaseCommand | 'trigger-release' =>
+): cmd is ReleaseCommand | 'trigger-release' | 'update-cta' =>
   (validCommands as string[]).includes(cmd);
 
 if (require.main === module) {
@@ -38,29 +40,46 @@ if (require.main === module) {
       );
     }
 
-    if (command === 'trigger-release') {
-      await triggerRelease(process.argv.slice(3));
-    } else {
-      const config: Config = require(path.join(
-        __dirname,
-        '..',
-        '..',
-        '..',
-        'config',
-        'build.conf.js'
-      ));
+    switch (command) {
+      case 'trigger-release':
+        await triggerRelease(process.argv.slice(3));
+        break;
+      case 'update-cta':
+        const ctaConfig: UpdateCTAConfig = require(path.join(
+          __dirname,
+          '..',
+          '..',
+          '..',
+          'config',
+          'cta.conf.js'
+        ));
 
-      const cliBuildVariant = process.argv
-        .map((arg) => /^--build-variant=(.+)$/.exec(arg))
-        .filter(Boolean)[0];
-      if (cliBuildVariant) {
-        config.packageVariant = cliBuildVariant[1] as PackageVariant;
-        validatePackageVariant(config.packageVariant);
-      }
+        ctaConfig.isDryRun ||= process.argv.includes('--dry-run');
 
-      config.isDryRun ||= process.argv.includes('--dry-run');
+        await updateJsonFeedCTA(ctaConfig);
+        break;
+      default:
+        const config: Config = require(path.join(
+          __dirname,
+          '..',
+          '..',
+          '..',
+          'config',
+          'build.conf.js'
+        ));
 
-      await release(command, config);
+        const cliBuildVariant = process.argv
+          .map((arg) => /^--build-variant=(.+)$/.exec(arg))
+          .filter(Boolean)[0];
+        if (cliBuildVariant) {
+          config.packageVariant = cliBuildVariant[1] as PackageVariant;
+          validatePackageVariant(config.packageVariant);
+        }
+
+        config.isDryRun ||= process.argv.includes('--dry-run');
+
+        await release(command, config);
+        break;
     }
   })().then(
     () => process.exit(0),
diff --git a/packages/cli-repl/src/mongosh-repl.ts b/packages/cli-repl/src/mongosh-repl.ts
index 223e60edaf..3182100e81 100644
--- a/packages/cli-repl/src/mongosh-repl.ts
+++ b/packages/cli-repl/src/mongosh-repl.ts
@@ -182,7 +182,7 @@ class MongoshNodeRepl implements EvaluationListener {
    */
   async initialize(
     serviceProvider: ServiceProvider,
-    greeting: GreetingDetails
+    greeting?: GreetingDetails
   ): Promise<InitializationToken> {
     const usePlainVMContext = this.shellCliOptions.jsContext === 'plain-vm';
 
@@ -582,7 +582,10 @@ class MongoshNodeRepl implements EvaluationListener {
   /**
    * The greeting for the shell, showing server and shell version.
    */
-  async greet(mongodVersion: string, greeting: GreetingDetails): Promise<void> {
+  async greet(
+    mongodVersion: string,
+    greeting?: GreetingDetails
+  ): Promise<void> {
     this.output.write('sadfasdfasdfassafsa');
     this.output.write(JSON.stringify({ mongodVersion, greeting }) + '\n');
 
@@ -598,7 +601,7 @@ class MongoshNodeRepl implements EvaluationListener {
       'Using Mongosh',
       'mongosh:section-header'
     )}:\t\t${version}\n`;
-    if (greeting.moreRecentMongoshVersion) {
+    if (greeting?.moreRecentMongoshVersion) {
       text += `mongosh ${this.clr(
         greeting.moreRecentMongoshVersion,
         'bold'
@@ -608,7 +611,7 @@ class MongoshNodeRepl implements EvaluationListener {
       )}\n`;
     }
 
-    if (greeting.currentVersionCTA) {
+    if (greeting?.currentVersionCTA) {
       for (const run of greeting.currentVersionCTA) {
         text += this.clr(run.text, run.style);
       }
diff --git a/packages/cli-repl/src/update-notification-manager.spec.ts b/packages/cli-repl/src/update-notification-manager.spec.ts
index 85faa8f681..2fbffcc5bb 100644
--- a/packages/cli-repl/src/update-notification-manager.spec.ts
+++ b/packages/cli-repl/src/update-notification-manager.spec.ts
@@ -41,28 +41,33 @@ describe('UpdateNotificationManager', function () {
 
   it('fetches and stores information about the current release', async function () {
     const manager = new UpdateNotificationManager();
-    await manager.fetchUpdateMetadata(httpServerUrl, filename);
+    await manager.fetchUpdateMetadata(httpServerUrl, filename, '1.2.3');
     expect(await manager.getLatestVersionIfMoreRecent('')).to.equal(null);
     expect(reqHandler).to.have.been.calledOnce;
     const fileContents = JSON.parse(await fs.readFile(filename, 'utf-8'));
     expect(Object.keys(fileContents)).to.deep.equal([
       'updateURL',
       'lastChecked',
+      'cta',
     ]);
     expect(fileContents.lastChecked).to.be.a('number');
   });
 
   it('uses existing data if some has been fetched recently', async function () {
     const manager = new UpdateNotificationManager();
-    await manager.fetchUpdateMetadata(httpServerUrl, filename);
-    await manager.fetchUpdateMetadata(httpServerUrl, filename);
+    await manager.fetchUpdateMetadata(httpServerUrl, filename, '1.2.3');
+    await manager.fetchUpdateMetadata(httpServerUrl, filename, '1.2.3');
     expect(reqHandler).to.have.been.calledOnce;
   });
 
   it('does not re-use existing data if the updateURL value has changed', async function () {
     const manager = new UpdateNotificationManager();
-    await manager.fetchUpdateMetadata(httpServerUrl, filename);
-    await manager.fetchUpdateMetadata(httpServerUrl + '/?foo=bar', filename);
+    await manager.fetchUpdateMetadata(httpServerUrl, filename, '1.2.3');
+    await manager.fetchUpdateMetadata(
+      httpServerUrl + '/?foo=bar',
+      filename,
+      '1.2.3'
+    );
     expect(reqHandler).to.have.been.calledTwice;
   });
 
@@ -80,7 +85,7 @@ describe('UpdateNotificationManager', function () {
       res.end('{}');
     });
     const manager = new UpdateNotificationManager();
-    await manager.fetchUpdateMetadata(httpServerUrl, filename);
+    await manager.fetchUpdateMetadata(httpServerUrl, filename, '1.2.3');
     await fs.writeFile(
       filename,
       JSON.stringify({
@@ -88,7 +93,7 @@ describe('UpdateNotificationManager', function () {
         lastChecked: 0,
       })
     );
-    await manager.fetchUpdateMetadata(httpServerUrl, filename);
+    await manager.fetchUpdateMetadata(httpServerUrl, filename, '1.2.3');
     expect(reqHandler).to.have.been.calledTwice;
     expect(cacheHits).to.equal(1);
   });
@@ -106,7 +111,7 @@ describe('UpdateNotificationManager', function () {
       );
     });
     const manager = new UpdateNotificationManager();
-    await manager.fetchUpdateMetadata(httpServerUrl, filename);
+    await manager.fetchUpdateMetadata(httpServerUrl, filename, '1.2.3');
     expect(await manager.getLatestVersionIfMoreRecent('')).to.equal('1.1.0');
     expect(await manager.getLatestVersionIfMoreRecent('1.0.0')).to.equal(
       '1.1.0'
diff --git a/packages/cli-repl/src/update-notification-manager.ts b/packages/cli-repl/src/update-notification-manager.ts
index b459fbc2a6..bfb7137b6d 100644
--- a/packages/cli-repl/src/update-notification-manager.ts
+++ b/packages/cli-repl/src/update-notification-manager.ts
@@ -21,6 +21,7 @@ interface MongoshVersionsContents {
     version: string;
     cta?: GreetingCTADetails;
   }[];
+  cta?: GreetingCTADetails;
 }
 
 interface MongoshUpdateLocalFileContents {
@@ -169,13 +170,14 @@ export class UpdateNotificationManager {
       ?.filter((v) => !semver.prerelease(v))
       ?.sort(semver.rcompare)?.[0];
 
-    this.currentVersionGreetingCTA = jsonContents?.versions?.filter(
-      (v) => v.version === currentVersion
-    )?.[0]?.cta;
+    this.currentVersionGreetingCTA =
+      jsonContents?.versions?.filter((v) => v.version === currentVersion)?.[0]
+        ?.cta ?? jsonContents?.cta;
 
-    const latestKnownVersionCTA = jsonContents?.versions?.filter(
-      (v) => v.version === this.latestKnownMongoshVersion
-    )?.[0]?.cta;
+    const latestKnownVersionCTA =
+      jsonContents?.versions?.filter(
+        (v) => v.version === this.latestKnownMongoshVersion
+      )?.[0]?.cta ?? jsonContents?.cta;
 
     localFileContents = {
       updateURL,
diff --git a/packages/snippet-manager/src/snippet-manager.spec.ts b/packages/snippet-manager/src/snippet-manager.spec.ts
index df1ae238e1..83ce7665f6 100644
--- a/packages/snippet-manager/src/snippet-manager.spec.ts
+++ b/packages/snippet-manager/src/snippet-manager.spec.ts
@@ -341,7 +341,7 @@ describe('SnippetManager', function () {
     await eventually(async () => {
       // This can fail when an index fetch is being written while we are removing
       // the directory; hence, try again.
-      await fs.rmdir(tmpdir, { recursive: true });
+      await fs.rm(tmpdir, { recursive: true });
     });
     httpServer.close();
   });

From 0c607968356ad551d12d003f7eaf27f962ee5a09 Mon Sep 17 00:00:00 2001
From: Nikola Irinchev <irinchev@me.com>
Date: Wed, 13 Nov 2024 14:09:15 +0100
Subject: [PATCH 4/8] Add a test

---
 configs/eslint-config-mongosh/index.js        |  1 +
 packages/cli-repl/src/clr.ts                  |  2 +-
 packages/cli-repl/src/mongosh-repl.ts         |  2 +-
 .../src/update-notification-manager.spec.ts   | 70 +++++++++++++++++++
 .../src/update-notification-manager.ts        |  8 +--
 5 files changed, 77 insertions(+), 6 deletions(-)

diff --git a/configs/eslint-config-mongosh/index.js b/configs/eslint-config-mongosh/index.js
index 71301c52f4..edffe282dc 100644
--- a/configs/eslint-config-mongosh/index.js
+++ b/configs/eslint-config-mongosh/index.js
@@ -93,6 +93,7 @@ module.exports = {
         ...common.testRules,
         ...extraJSRules,
         ...extraTypescriptRules,
+        '@typescript-eslint/no-non-null-assertion': 'off',
       },
     },
   ],
diff --git a/packages/cli-repl/src/clr.ts b/packages/cli-repl/src/clr.ts
index 1a97c5e35d..4b383e63ea 100644
--- a/packages/cli-repl/src/clr.ts
+++ b/packages/cli-repl/src/clr.ts
@@ -14,7 +14,7 @@ export type StyleDefinition =
 /** Optionally colorize a string, given a set of style definition(s). */
 export default function colorize(
   text: string,
-  style: StyleDefinition,
+  style: StyleDefinition | undefined,
   options: { colors: boolean }
 ): string {
   if (options.colors) {
diff --git a/packages/cli-repl/src/mongosh-repl.ts b/packages/cli-repl/src/mongosh-repl.ts
index 3182100e81..d37b966a63 100644
--- a/packages/cli-repl/src/mongosh-repl.ts
+++ b/packages/cli-repl/src/mongosh-repl.ts
@@ -116,7 +116,7 @@ type MongoshRuntimeState = {
 
 type GreetingDetails = {
   moreRecentMongoshVersion?: string | null;
-  currentVersionCTA?: { text: string; style: StyleDefinition }[];
+  currentVersionCTA?: { text: string; style?: StyleDefinition }[];
 };
 
 /* Utility, inverse of Readonly<T> */
diff --git a/packages/cli-repl/src/update-notification-manager.spec.ts b/packages/cli-repl/src/update-notification-manager.spec.ts
index 2fbffcc5bb..5a154171c8 100644
--- a/packages/cli-repl/src/update-notification-manager.spec.ts
+++ b/packages/cli-repl/src/update-notification-manager.spec.ts
@@ -7,6 +7,7 @@ import type { AddressInfo } from 'net';
 import os from 'os';
 import path from 'path';
 import { UpdateNotificationManager } from './update-notification-manager';
+import type { MongoshVersionsContents } from './update-notification-manager';
 import sinon from 'sinon';
 
 describe('UpdateNotificationManager', function () {
@@ -122,4 +123,73 @@ describe('UpdateNotificationManager', function () {
       await manager.getLatestVersionIfMoreRecent('1.0.0-alpha.0')
     ).to.equal(null);
   });
+
+  it('figures out the greeting CTA when set on a global level', async function () {
+    const response: MongoshVersionsContents = {
+      versions: [
+        { version: '1.0.0' },
+        {
+          version: '1.1.0',
+          cta: { chunks: [{ text: "Don't use 1.1.0, downgrade!!" }] },
+        },
+      ],
+      cta: {
+        chunks: [{ text: 'Vote for your favorite feature!', style: 'bold' }],
+      },
+    };
+    reqHandler.callsFake((req, res) => {
+      res.end(JSON.stringify(response));
+    });
+
+    const manager = new UpdateNotificationManager();
+    await manager.fetchUpdateMetadata(httpServerUrl, filename, '1.0.0');
+
+    const cta = await manager.getGreetingCTAForCurrentVersion();
+    expect(cta).to.not.be.undefined;
+    expect(cta?.length).to.equal(1);
+    expect(cta![0]?.text).to.equal('Vote for your favorite feature!');
+    expect(cta![0]?.style).to.equal('bold');
+  });
+
+  it('figures out the greeting CTA when set on a per-version basis', async function () {
+    const response: MongoshVersionsContents = {
+      versions: [
+        {
+          version: '1.0.0',
+          cta: {
+            chunks: [
+              { text: "Don't use 1.0.0, upgrade!! " },
+              {
+                text: 'https://downloads.mongodb.com/mongosh/1.1.0/',
+                style: 'mongosh:uri',
+              },
+            ],
+          },
+        },
+        {
+          version: '1.1.0',
+          cta: { chunks: [{ text: 'This version is very safe!' }] },
+        },
+      ],
+      cta: {
+        chunks: [{ text: 'Vote for your favorite feature!', style: 'bold' }],
+      },
+    };
+    reqHandler.callsFake((req, res) => {
+      res.end(JSON.stringify(response));
+    });
+
+    const manager = new UpdateNotificationManager();
+    await manager.fetchUpdateMetadata(httpServerUrl, filename, '1.0.0');
+
+    const cta = await manager.getGreetingCTAForCurrentVersion();
+    expect(cta).to.not.be.undefined;
+    expect(cta?.length).to.equal(2);
+    expect(cta![0]?.text).to.equal("Don't use 1.0.0, upgrade!! ");
+    expect(cta![0]?.style).to.be.undefined;
+    expect(cta![1]?.text).to.equal(
+      'https://downloads.mongodb.com/mongosh/1.1.0/'
+    );
+    expect(cta![1]?.style).to.equal('mongosh:uri');
+  });
 });
diff --git a/packages/cli-repl/src/update-notification-manager.ts b/packages/cli-repl/src/update-notification-manager.ts
index bfb7137b6d..374fdce408 100644
--- a/packages/cli-repl/src/update-notification-manager.ts
+++ b/packages/cli-repl/src/update-notification-manager.ts
@@ -7,16 +7,16 @@ import type {
   Response,
 } from '@mongodb-js/devtools-proxy-support';
 import { createFetch } from '@mongodb-js/devtools-proxy-support';
-import { StyleDefinition } from './clr';
+import type { StyleDefinition } from './clr';
 
 interface GreetingCTADetails {
   chunks: {
     text: string;
-    style: StyleDefinition;
+    style?: StyleDefinition;
   }[];
 }
 
-interface MongoshVersionsContents {
+export interface MongoshVersionsContents {
   versions: {
     version: string;
     cta?: GreetingCTADetails;
@@ -72,7 +72,7 @@ export class UpdateNotificationManager {
   async getGreetingCTAForCurrentVersion(): Promise<
     | {
         text: string;
-        style: StyleDefinition;
+        style?: StyleDefinition;
       }[]
     | undefined
   > {

From 156a2cf835d2018e9a984494eb83c1cbe89f7232 Mon Sep 17 00:00:00 2001
From: Nikola Irinchev <irinchev@me.com>
Date: Mon, 2 Dec 2024 11:28:42 +0100
Subject: [PATCH 5/8] Add more tests, github workflow

---
 .github/workflows/update-cta.yml              |  46 ++++
 config/cta.conf.js                            |   6 +-
 package.json                                  |   1 +
 packages/build/package.json                   |   3 +-
 .../build/src/download-center/config.spec.ts  | 224 ++++++++++++++++++
 packages/build/src/download-center/config.ts  |   6 +-
 .../build/test/fixtures/cta-versions.json     |  33 +++
 packages/cli-repl/src/mongosh-repl.ts         |   3 -
 8 files changed, 312 insertions(+), 10 deletions(-)
 create mode 100644 .github/workflows/update-cta.yml
 create mode 100644 packages/build/test/fixtures/cta-versions.json

diff --git a/.github/workflows/update-cta.yml b/.github/workflows/update-cta.yml
new file mode 100644
index 0000000000..859d40de4f
--- /dev/null
+++ b/.github/workflows/update-cta.yml
@@ -0,0 +1,46 @@
+name: Update greeting CTA
+on:
+  push:
+    branches:
+      - main
+    paths:
+      - config/cta.conf.js
+  workflow_dispatch:
+    inputs:
+      dry-run:
+        description: Run the script without updating the CTA
+        type: boolean
+        required: false
+        default: false
+      environment:
+        description: The environment to run the script in - must have the DOWNLOAD_CENTER_AWS_KEY and DOWNLOAD_CENTER_AWS_SECRET secrets configured
+        type: environment
+        required: true
+        default: CTA-Production
+
+jobs:
+  dry-run:
+    name: Update greeting CTA
+    runs-on: ubuntu-latest
+    environment: ${{ github.event.inputs.environment || 'CTA-Production'}}
+    env:
+      npm_config_loglevel: verbose
+      npm_config_foreground_scripts: "true"
+      PUPPETEER_SKIP_DOWNLOAD: "true"
+      DOWNLOAD_CENTER_AWS_KEY: ${{ secrets.DOWNLOAD_CENTER_AWS_KEY }}
+      DOWNLOAD_CENTER_AWS_SECRET: ${{ secrets.DOWNLOAD_CENTER_AWS_SECRET }}
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version: ^20.x
+          cache: "npm"
+
+      - name: Install Dependencies and Compile
+        run: |
+          npm ci
+          npm run compile
+
+      - name: Update greeting CTA
+        run: |
+          npm run update-cta ${{ github.event.inputs.dry-run && '-- --dry-run' || '' }}
diff --git a/config/cta.conf.js b/config/cta.conf.js
index 2f65194b89..f1dc72b761 100644
--- a/config/cta.conf.js
+++ b/config/cta.conf.js
@@ -7,15 +7,15 @@ module.exports = {
         // Define the ctas per version here. '*' is the default cta which will be shown if there's no specific cta
         // for the current version.
         // '*': {
-        //     runs: [
+        //     chunks: [
         //         { text: 'Example', style: 'bold' },
         //     ]
         // },
         // '1.2.3': {
-        //     runs: [
+        //     chunks: [
         //         { text: 'Example', style: 'mongosh:uri' },
         //     ]
         // }
     },
     isDryRun: false,
-}
\ No newline at end of file
+}
diff --git a/package.json b/package.json
index 90611bdf32..8051f48cd4 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
     "compile-all": "npm run compile-compass && npm run compile-exec",
     "evergreen-release": "cd packages/build && npm run evergreen-release --",
     "release": "cd packages/build && npm run release --",
+    "update-cta": "cd packages/build && npm run update-cta --",
     "report-missing-help": "npm run report-missing-help --workspace @mongosh/shell-api",
     "report-supported-api": "npm run report-supported-api --workspace @mongosh/shell-api",
     "post-process-nyc": "ts-node scripts/nyc/post-process-nyc-output.ts",
diff --git a/packages/build/package.json b/packages/build/package.json
index f96bf7c568..6077194abe 100644
--- a/packages/build/package.json
+++ b/packages/build/package.json
@@ -26,7 +26,8 @@
     "evergreen-release": "ts-node -r ../../scripts/import-expansions.js src/index.ts",
     "release": "ts-node src/index.ts trigger-release",
     "prettier": "prettier",
-    "reformat": "npm run prettier -- --write . && npm run eslint --fix"
+    "reformat": "npm run prettier -- --write . && npm run eslint --fix",
+    "update-cta": "ts-node src/index.ts update-cta"
   },
   "license": "Apache-2.0",
   "publishConfig": {
diff --git a/packages/build/src/download-center/config.spec.ts b/packages/build/src/download-center/config.spec.ts
index e0decdf9d4..31abb7148d 100644
--- a/packages/build/src/download-center/config.spec.ts
+++ b/packages/build/src/download-center/config.spec.ts
@@ -10,6 +10,9 @@ import {
   getUpdatedDownloadCenterConfig,
   createAndPublishDownloadCenterConfig,
   createJsonFeedEntry,
+  updateJsonFeedCTA,
+  UpdateCTAConfig,
+  JsonFeed,
 } from './config';
 import { promises as fs } from 'fs';
 import path from 'path';
@@ -529,4 +532,225 @@ describe('DownloadCenter config', function () {
         expect(serverTargets).to.include(target);
     });
   });
+
+  describe('updateJsonFeedCTA', function () {
+    let dlCenter: any;
+    let uploadConfig: sinon.SinonStub;
+    let downloadConfig: sinon.SinonStub;
+    let uploadAsset: sinon.SinonStub;
+    let downloadAsset: sinon.SinonStub;
+
+    const existingUploadedJsonFeed = require(path.resolve(
+      __dirname,
+      '..',
+      '..',
+      'test',
+      'fixtures',
+      'cta-versions.json'
+    )) as JsonFeed;
+
+    const getConfig = (ctas: UpdateCTAConfig['ctas']): UpdateCTAConfig => {
+      return {
+        ctas,
+        isDryRun: false,
+        awsAccessKeyId: 'accessKey',
+        awsSecretAccessKey: 'secretKey',
+      };
+    };
+
+    const getUploadedJsonFeed = (): JsonFeed => {
+      return JSON.parse(uploadAsset.lastCall.args[1]) as JsonFeed;
+    };
+
+    beforeEach(function () {
+      uploadConfig = sinon.stub();
+      downloadConfig = sinon.stub();
+      uploadAsset = sinon.stub();
+      downloadAsset = sinon.stub();
+      dlCenter = sinon.stub();
+
+      downloadAsset.returns(JSON.stringify(existingUploadedJsonFeed));
+
+      dlCenter.returns({
+        downloadConfig,
+        uploadConfig,
+        uploadAsset,
+        downloadAsset,
+      });
+    });
+
+    for (let dryRun of [false, true]) {
+      it(`when dryRun is ${dryRun}, does ${
+        dryRun ? 'not ' : ''
+      }upload the updated json feed`, async function () {
+        const config = getConfig({
+          '1.10.3': {
+            chunks: [{ text: 'Foo' }],
+          },
+          '*': {
+            chunks: [{ text: 'Bar' }],
+          },
+        });
+
+        config.isDryRun = dryRun;
+
+        await updateJsonFeedCTA(config, dlCenter);
+        if (dryRun) {
+          expect(uploadAsset).to.not.have.been.called;
+        } else {
+          expect(uploadAsset).to.have.been.called;
+
+          const updatedJsonFeed = getUploadedJsonFeed();
+          expect(updatedJsonFeed.cta?.chunks).to.deep.equal([{ text: 'Bar' }]);
+          expect(
+            updatedJsonFeed.versions.filter((v) => v.version === '1.10.3')[0]
+              .cta?.chunks
+          ).to.deep.equal([{ text: 'Foo' }]);
+          expect(
+            updatedJsonFeed.versions.filter((v) => v.version === '1.10.4')[0]
+              .cta
+          ).to.be.undefined;
+        }
+      });
+    }
+
+    it('cannot add new versions', async function () {
+      expect(
+        existingUploadedJsonFeed.versions.filter((v) => v.version === '1.10.5')
+      ).to.have.lengthOf(0);
+
+      const config = getConfig({
+        '1.10.5': {
+          chunks: [{ text: 'Foo' }],
+        },
+      });
+
+      await updateJsonFeedCTA(config, dlCenter);
+
+      const updatedJsonFeed = getUploadedJsonFeed();
+
+      expect(
+        updatedJsonFeed.versions.filter((v) => v.version === '1.10.5')
+      ).to.have.lengthOf(0);
+    });
+
+    it('can remove global cta', async function () {
+      // Preserve existing CTAs, but omit the global one
+      const ctas = (existingUploadedJsonFeed.versions as any[]).reduce(
+        (acc, current) => {
+          acc[current.version] = current.cta;
+          return acc;
+        },
+        {}
+      );
+      const config = getConfig(ctas);
+
+      expect(config.ctas['*']).to.be.undefined;
+      await updateJsonFeedCTA(config, dlCenter);
+
+      const updatedJsonFeed = getUploadedJsonFeed();
+
+      expect(updatedJsonFeed.cta).to.be.undefined;
+    });
+
+    it('can remove version specific cta', async function () {
+      expect(
+        existingUploadedJsonFeed.versions.map((v) => v.cta).filter((cta) => cta)
+      ).to.have.length.greaterThan(0);
+
+      const config = getConfig({
+        '*': existingUploadedJsonFeed.cta!,
+      });
+
+      await updateJsonFeedCTA(config, dlCenter);
+
+      const updatedJsonFeed = getUploadedJsonFeed();
+      expect(updatedJsonFeed.cta).to.not.be.undefined;
+      expect(
+        updatedJsonFeed.versions.map((v) => v.cta).filter((cta) => cta)
+      ).to.have.lengthOf(0);
+    });
+
+    it('can update global cta', async function () {
+      const config = getConfig({
+        '*': {
+          chunks: [{ text: "It's a beautiful day", style: 'imagePositive' }],
+        },
+      });
+
+      await updateJsonFeedCTA(config, dlCenter);
+
+      const updatedJsonFeed = getUploadedJsonFeed();
+
+      expect(updatedJsonFeed.cta).to.deep.equal({
+        chunks: [{ text: "It's a beautiful day", style: 'imagePositive' }],
+      });
+    });
+
+    it('can update version-specific cta', async function () {
+      const config = getConfig({
+        '1.10.3': {
+          chunks: [{ text: "It's a beautiful day", style: 'imagePositive' }],
+        },
+      });
+
+      await updateJsonFeedCTA(config, dlCenter);
+
+      const updatedJsonFeed = getUploadedJsonFeed();
+
+      expect(
+        updatedJsonFeed.versions.filter((v) => v.version === '1.10.3')[0].cta
+      ).to.deep.equal({
+        chunks: [{ text: "It's a beautiful day", style: 'imagePositive' }],
+      });
+    });
+
+    it('can add global cta', async function () {
+      // Remove the existing cta
+      existingUploadedJsonFeed.cta = undefined;
+
+      const config = getConfig({
+        '*': {
+          chunks: [
+            { text: 'Go outside and enjoy the sun', style: 'imagePositive' },
+          ],
+        },
+      });
+
+      await updateJsonFeedCTA(config, dlCenter);
+
+      const updatedJsonFeed = getUploadedJsonFeed();
+
+      expect(updatedJsonFeed.cta).to.deep.equal({
+        chunks: [
+          { text: 'Go outside and enjoy the sun', style: 'imagePositive' },
+        ],
+      });
+    });
+
+    it('can add version-specific cta', async function () {
+      // Remove the existing cta
+      existingUploadedJsonFeed.cta = undefined;
+
+      const config = getConfig({
+        '1.10.4': {
+          chunks: [
+            { text: 'Go outside and enjoy the sun', style: 'imagePositive' },
+          ],
+        },
+      });
+
+      await updateJsonFeedCTA(config, dlCenter);
+
+      const updatedJsonFeed = getUploadedJsonFeed();
+
+      expect(
+        updatedJsonFeed.versions.filter((v) => v.version === '1.10.4')[0].cta
+      ).to.deep.equal({
+        chunks: [
+          { text: 'Go outside and enjoy the sun', style: 'imagePositive' },
+        ],
+      });
+    });
+  });
 });
diff --git a/packages/build/src/download-center/config.ts b/packages/build/src/download-center/config.ts
index d8f3230c71..02d26b5483 100644
--- a/packages/build/src/download-center/config.ts
+++ b/packages/build/src/download-center/config.ts
@@ -241,20 +241,20 @@ export function createVersionConfig(
 interface GreetingCTADetails {
   chunks: {
     text: string;
-    style: string; // TODO: this is actually clr.ts/StyleDefinition
+    style?: string; // TODO: this is actually clr.ts/StyleDefinition
   }[];
 }
 
 export interface UpdateCTAConfig {
   ctas: {
-    [version: string]: GreetingCTADetails;
+    [version: string | '*']: GreetingCTADetails;
   };
   awsAccessKeyId: string;
   awsSecretAccessKey: string;
   isDryRun: boolean;
 }
 
-interface JsonFeed {
+export interface JsonFeed {
   versions: JsonFeedVersionEntry[];
   cta?: GreetingCTADetails;
 }
diff --git a/packages/build/test/fixtures/cta-versions.json b/packages/build/test/fixtures/cta-versions.json
new file mode 100644
index 0000000000..f73ad3bd36
--- /dev/null
+++ b/packages/build/test/fixtures/cta-versions.json
@@ -0,0 +1,33 @@
+{
+  "versions": [
+    {
+      "version": "1.10.3",
+      "cta": {
+        "chunks": [
+          {
+            "text": "Critical update available: 1.10.4 ",
+            "style": "bold"
+          },
+          {
+            "text": "https://www.mongodb.com/try/download/shell",
+            "style": "mongosh:uri"
+          }
+        ]
+      }
+    },
+    {
+      "version": "1.10.4"
+    }
+  ],
+  "cta": {
+    "chunks": [
+      {
+        "text": "Vote for your favorite feature in mongosh "
+      },
+      {
+        "text": "https://mongodb.com/surveys/shell/2024-Q4",
+        "style": "mongosh:uri"
+      }
+    ]
+  }
+}
diff --git a/packages/cli-repl/src/mongosh-repl.ts b/packages/cli-repl/src/mongosh-repl.ts
index d37b966a63..6efb394479 100644
--- a/packages/cli-repl/src/mongosh-repl.ts
+++ b/packages/cli-repl/src/mongosh-repl.ts
@@ -586,9 +586,6 @@ class MongoshNodeRepl implements EvaluationListener {
     mongodVersion: string,
     greeting?: GreetingDetails
   ): Promise<void> {
-    this.output.write('sadfasdfasdfassafsa');
-    this.output.write(JSON.stringify({ mongodVersion, greeting }) + '\n');
-
     if (this.shellCliOptions.quiet) {
       return;
     }

From b1fb4f8b0ea9e7ce45dfe7da46a7af6135c92556 Mon Sep 17 00:00:00 2001
From: Nikola Irinchev <irinchev@me.com>
Date: Thu, 6 Feb 2025 15:12:46 +0100
Subject: [PATCH 6/8] Rework cta validations

---
 config/build.conf.js                          |   6 +
 config/cta-config.json                        |   3 +
 config/cta-config.schema.json                 |  94 +++++++++++
 config/cta.conf.js                            |  21 ---
 package-lock.json                             |  23 +++
 packages/build/package.json                   |   1 +
 packages/build/src/config/config.ts           |  15 ++
 .../build/src/download-center/config.spec.ts  | 156 +++++++++++-------
 packages/build/src/download-center/config.ts  |  45 +++--
 packages/build/src/index.ts                   |  79 +++++----
 packages/build/src/publish-mongosh.ts         |   3 +-
 packages/build/test/helpers.ts                |   2 +
 12 files changed, 312 insertions(+), 136 deletions(-)
 create mode 100644 config/cta-config.json
 create mode 100644 config/cta-config.schema.json
 delete mode 100644 config/cta.conf.js

diff --git a/config/build.conf.js b/config/build.conf.js
index 75b1435091..a45ae8bf0b 100644
--- a/config/build.conf.js
+++ b/config/build.conf.js
@@ -75,6 +75,10 @@ const MANPAGE_NAME = 'mongosh.1.gz'
  */
 const PACKAGE_VARIANT = process.env.PACKAGE_VARIANT;
 
+const CTA_CONFIG = require(path.join(ROOT, 'config', 'cta-config.json'));
+
+const CTA_CONFIG_SCHEMA = require(path.join(ROOT, 'config', 'cta-config.schema.json'));
+
 /**
  * Export the configuration for the build.
  */
@@ -194,4 +198,6 @@ module.exports = {
     downloadPath: path.resolve(TMP_DIR, 'manpage'),
     fileName: MANPAGE_NAME,
   },
+  ctaConfig: CTA_CONFIG,
+  ctaConfigSchema: CTA_CONFIG_SCHEMA,
 };
diff --git a/config/cta-config.json b/config/cta-config.json
new file mode 100644
index 0000000000..885a4b7f24
--- /dev/null
+++ b/config/cta-config.json
@@ -0,0 +1,3 @@
+{
+    "$schema": "./cta-config.schema.json"
+}
diff --git a/config/cta-config.schema.json b/config/cta-config.schema.json
new file mode 100644
index 0000000000..778ca8aaf2
--- /dev/null
+++ b/config/cta-config.schema.json
@@ -0,0 +1,94 @@
+{
+    "$id": "https://mongodb.com/schemas/mongosh/cta-config",
+    "$schema": "http://json-schema.org/draft-07/schema#",
+    "title": "CTAConfig",
+    "type": "object",
+    "properties": {
+        "*": {
+            "$ref": "#/definitions/GreetingCTADetails",
+            "description": "The default CTA for all versions that don't have an explicit one defined."
+        },
+        "$schema": {
+            "type": "string"
+        }
+    },
+    "patternProperties": {
+        "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$": {
+            "$ref": "#/definitions/GreetingCTADetails",
+            "description": "The CTA for a specific version.",
+            "$comment": "The property name must be a valid semver string."
+        }
+    },
+    "additionalProperties": false,
+    "definitions": {
+        "GreetingCTADetails": {
+            "type": "object",
+            "additionalProperties": false,
+            "properties": {
+                "chunks": {
+                    "description": "The chunks that make up the CTA. They will be combined sequentially with no additional spacing added.",
+                    "items": {
+                        "properties": {
+                            "style": {
+                                "description": "The style to apply to the text. It must match the values from clr.ts/StyleDefinition.",
+                                "enum": [
+                                    "reset",
+                                    "bold",
+                                    "italic",
+                                    "underline",
+                                    "fontDefault",
+                                    "font2",
+                                    "font3",
+                                    "font4",
+                                    "font5",
+                                    "font6",
+                                    "imageNegative",
+                                    "imagePositive",
+                                    "black",
+                                    "red",
+                                    "green",
+                                    "yellow",
+                                    "blue",
+                                    "magenta",
+                                    "cyan",
+                                    "white",
+                                    "grey",
+                                    "gray",
+                                    "bg-black",
+                                    "bg-red",
+                                    "bg-green",
+                                    "bg-yellow",
+                                    "bg-blue",
+                                    "bg-magenta",
+                                    "bg-cyan",
+                                    "bg-white",
+                                    "bg-grey",
+                                    "bg-gray",
+                                    "mongosh:warning",
+                                    "mongosh:error",
+                                    "mongosh:section-header",
+                                    "mongosh:uri",
+                                    "mongosh:filename",
+                                    "mongosh:additional-error-info"
+                                ],
+                                "type": "string"
+                            },
+                            "text": {
+                                "type": "string",
+                                "description": "The text in the chunk."
+                            }
+                        },
+                        "type": "object",
+                        "required": [
+                            "text"
+                        ]
+                    },
+                    "type": "array"
+                }
+            },
+            "required": [
+                "chunks"
+            ]
+        }
+    }
+}
diff --git a/config/cta.conf.js b/config/cta.conf.js
deleted file mode 100644
index f1dc72b761..0000000000
--- a/config/cta.conf.js
+++ /dev/null
@@ -1,21 +0,0 @@
-'use strict';
-
-module.exports = {
-    awsAccessKeyId: process.env.DOWNLOAD_CENTER_AWS_KEY,
-    awsSecretAccessKey: process.env.DOWNLOAD_CENTER_AWS_SECRET,
-    ctas: {
-        // Define the ctas per version here. '*' is the default cta which will be shown if there's no specific cta
-        // for the current version.
-        // '*': {
-        //     chunks: [
-        //         { text: 'Example', style: 'bold' },
-        //     ]
-        // },
-        // '1.2.3': {
-        //     chunks: [
-        //         { text: 'Example', style: 'mongosh:uri' },
-        //     ]
-        // }
-    },
-    isDryRun: false,
-}
diff --git a/package-lock.json b/package-lock.json
index 3d4bca0a8a..244c238226 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29158,6 +29158,7 @@
         "@mongodb-js/monorepo-tools": "^1.1.16",
         "@mongodb-js/signing-utils": "^0.3.7",
         "@octokit/rest": "^17.9.0",
+        "ajv": "^8.17.1",
         "aws-sdk": "^2.674.0",
         "boxednode": "^2.4.3",
         "command-exists": "^1.2.9",
@@ -29226,6 +29227,28 @@
         "@types/node": "*"
       }
     },
+    "packages/build/node_modules/ajv": {
+      "version": "8.17.1",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+      "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "packages/build/node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "license": "MIT"
+    },
     "packages/build/node_modules/node-fetch": {
       "version": "2.6.12",
       "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz",
diff --git a/packages/build/package.json b/packages/build/package.json
index 1438cb14f4..cf1a56c207 100644
--- a/packages/build/package.json
+++ b/packages/build/package.json
@@ -70,6 +70,7 @@
     "@mongodb-js/monorepo-tools": "^1.1.16",
     "@mongodb-js/signing-utils": "^0.3.7",
     "@octokit/rest": "^17.9.0",
+    "ajv": "^8.17.1",
     "aws-sdk": "^2.674.0",
     "boxednode": "^2.4.3",
     "command-exists": "^1.2.9",
diff --git a/packages/build/src/config/config.ts b/packages/build/src/config/config.ts
index 47d376aa9b..0c2676f0a1 100644
--- a/packages/build/src/config/config.ts
+++ b/packages/build/src/config/config.ts
@@ -1,3 +1,4 @@
+import { Schema } from 'ajv';
 import type { PackageInformationProvider } from '../packaging/package';
 import type { PackageVariant } from './build-variant';
 
@@ -7,6 +8,18 @@ interface ManPageConfig {
   fileName: string;
 }
 
+// TODO: this is duplicated in update-notification-manager.ts
+export interface GreetingCTADetails {
+  chunks: {
+    text: string;
+    style?: string; // TODO: this is actually clr.ts/StyleDefinition
+  }[];
+}
+
+export type CTAConfig = {
+  [version: string | '*']: GreetingCTADetails;
+};
+
 /**
  * Defines the configuration interface for the build system.
  */
@@ -47,4 +60,6 @@ export interface Config {
   manpage?: ManPageConfig;
   isDryRun?: boolean;
   useAuxiliaryPackagesOnly?: boolean;
+  ctaConfig: CTAConfig;
+  ctaConfigSchema: Schema;
 }
diff --git a/packages/build/src/download-center/config.spec.ts b/packages/build/src/download-center/config.spec.ts
index 73c412261d..f9dee525cd 100644
--- a/packages/build/src/download-center/config.spec.ts
+++ b/packages/build/src/download-center/config.spec.ts
@@ -2,7 +2,7 @@ import type { DownloadCenterConfig } from '@mongodb-js/dl-center/dist/download-c
 import { type PackageInformationProvider } from '../packaging';
 import { expect } from 'chai';
 import sinon from 'sinon';
-import type { Config } from '../config';
+import type { Config, CTAConfig } from '../config';
 import { type PackageVariant } from '../config';
 import {
   createVersionConfig,
@@ -12,7 +12,7 @@ import {
   createJsonFeedEntry,
   updateJsonFeedCTA,
 } from './config';
-import type { UpdateCTAConfig, JsonFeed } from './config';
+import type { JsonFeed } from './config';
 import { promises as fs } from 'fs';
 import path from 'path';
 import fetch from 'node-fetch';
@@ -38,6 +38,10 @@ const packageInformation = (version: string) =>
     };
   }) as PackageInformationProvider;
 
+const DUMMY_ACCESS_KEY = 'accessKey';
+const DUMMY_SECRET_KEY = 'secretKey';
+const DUMMY_CTA_CONFIG: CTAConfig = {};
+
 describe('DownloadCenter config', function () {
   let outputDir: string;
   before(async function () {
@@ -267,23 +271,24 @@ describe('DownloadCenter config', function () {
         await createAndPublishDownloadCenterConfig(
           outputDir,
           packageInformation('2.0.1'),
-          'accessKey',
-          'secretKey',
+          DUMMY_ACCESS_KEY,
+          DUMMY_SECRET_KEY,
           '',
           false,
+          DUMMY_CTA_CONFIG,
           dlCenter as any,
           baseUrl
         );
 
         expect(dlCenter).to.have.been.calledWith({
           bucket: 'info-mongodb-com',
-          accessKeyId: 'accessKey',
-          secretAccessKey: 'secretKey',
+          accessKeyId: DUMMY_ACCESS_KEY,
+          secretAccessKey: DUMMY_SECRET_KEY,
         });
         expect(dlCenter).to.have.been.calledWith({
           bucket: 'downloads.10gen.com',
-          accessKeyId: 'accessKey',
-          secretAccessKey: 'secretKey',
+          accessKeyId: DUMMY_ACCESS_KEY,
+          secretAccessKey: DUMMY_SECRET_KEY,
         });
 
         expect(uploadConfig).to.be.calledOnce;
@@ -325,23 +330,24 @@ describe('DownloadCenter config', function () {
         await createAndPublishDownloadCenterConfig(
           outputDir,
           packageInformation('1.2.2'),
-          'accessKey',
-          'secretKey',
+          DUMMY_ACCESS_KEY,
+          DUMMY_SECRET_KEY,
           '',
           false,
+          DUMMY_CTA_CONFIG,
           dlCenter as any,
           baseUrl
         );
 
         expect(dlCenter).to.have.been.calledWith({
           bucket: 'info-mongodb-com',
-          accessKeyId: 'accessKey',
-          secretAccessKey: 'secretKey',
+          accessKeyId: DUMMY_ACCESS_KEY,
+          secretAccessKey: DUMMY_SECRET_KEY,
         });
         expect(dlCenter).to.have.been.calledWith({
           bucket: 'downloads.10gen.com',
-          accessKeyId: 'accessKey',
-          secretAccessKey: 'secretKey',
+          accessKeyId: DUMMY_ACCESS_KEY,
+          secretAccessKey: DUMMY_SECRET_KEY,
         });
 
         expect(uploadConfig).to.be.calledOnce;
@@ -423,8 +429,8 @@ describe('DownloadCenter config', function () {
         await createAndPublishDownloadCenterConfig(
           outputDir,
           packageInformation('2.0.0'),
-          'accessKey',
-          'secretKey',
+          DUMMY_ACCESS_KEY,
+          DUMMY_SECRET_KEY,
           path.resolve(
             __dirname,
             '..',
@@ -434,19 +440,20 @@ describe('DownloadCenter config', function () {
             'mongosh-versions.json'
           ),
           false,
+          DUMMY_CTA_CONFIG,
           dlCenter as any,
           baseUrl
         );
 
         expect(dlCenter).to.have.been.calledWith({
           bucket: 'info-mongodb-com',
-          accessKeyId: 'accessKey',
-          secretAccessKey: 'secretKey',
+          accessKeyId: DUMMY_ACCESS_KEY,
+          secretAccessKey: DUMMY_SECRET_KEY,
         });
         expect(dlCenter).to.have.been.calledWith({
           bucket: 'downloads.10gen.com',
-          accessKeyId: 'accessKey',
-          secretAccessKey: 'secretKey',
+          accessKeyId: DUMMY_ACCESS_KEY,
+          secretAccessKey: DUMMY_SECRET_KEY,
         });
 
         expect(uploadConfig).to.be.calledOnce;
@@ -533,7 +540,7 @@ describe('DownloadCenter config', function () {
   });
 
   describe('updateJsonFeedCTA', function () {
-    let dlCenter: sinon.SinonStub | DownloadCenterConfig;
+    let dlCenter: sinon.SinonStub;
     let uploadConfig: sinon.SinonStub;
     let downloadConfig: sinon.SinonStub;
     let uploadAsset: sinon.SinonStub;
@@ -548,15 +555,6 @@ describe('DownloadCenter config', function () {
       'cta-versions.json'
     )) as JsonFeed;
 
-    const getConfig = (ctas: UpdateCTAConfig['ctas']): UpdateCTAConfig => {
-      return {
-        ctas,
-        isDryRun: false,
-        awsAccessKeyId: 'accessKey',
-        awsSecretAccessKey: 'secretKey',
-      };
-    };
-
     const getUploadedJsonFeed = (): JsonFeed => {
       return JSON.parse(uploadAsset.lastCall.args[1]) as JsonFeed;
     };
@@ -582,18 +580,22 @@ describe('DownloadCenter config', function () {
       it(`when dryRun is ${dryRun}, does ${
         dryRun ? 'not ' : ''
       }upload the updated json feed`, async function () {
-        const config = getConfig({
+        const config: CTAConfig = {
           '1.10.3': {
             chunks: [{ text: 'Foo' }],
           },
           '*': {
             chunks: [{ text: 'Bar' }],
           },
-        });
-
-        config.isDryRun = dryRun;
+        };
 
-        await updateJsonFeedCTA(config, dlCenter);
+        await updateJsonFeedCTA(
+          config,
+          DUMMY_ACCESS_KEY,
+          DUMMY_SECRET_KEY,
+          dryRun,
+          dlCenter as any
+        );
         if (dryRun) {
           expect(uploadAsset).to.not.have.been.called;
         } else {
@@ -618,13 +620,19 @@ describe('DownloadCenter config', function () {
         existingUploadedJsonFeed.versions.filter((v) => v.version === '1.10.5')
       ).to.have.lengthOf(0);
 
-      const config = getConfig({
+      const config: CTAConfig = {
         '1.10.5': {
           chunks: [{ text: 'Foo' }],
         },
-      });
+      };
 
-      await updateJsonFeedCTA(config, dlCenter);
+      await updateJsonFeedCTA(
+        config,
+        DUMMY_ACCESS_KEY,
+        DUMMY_SECRET_KEY,
+        false,
+        dlCenter as any
+      );
 
       const updatedJsonFeed = getUploadedJsonFeed();
 
@@ -642,10 +650,14 @@ describe('DownloadCenter config', function () {
         },
         {}
       );
-      const config = getConfig(ctas);
-
-      expect(config.ctas['*']).to.be.undefined;
-      await updateJsonFeedCTA(config, dlCenter);
+      expect(ctas['*']).to.be.undefined;
+      await updateJsonFeedCTA(
+        ctas,
+        DUMMY_ACCESS_KEY,
+        DUMMY_SECRET_KEY,
+        false,
+        dlCenter as any
+      );
 
       const updatedJsonFeed = getUploadedJsonFeed();
 
@@ -657,11 +669,17 @@ describe('DownloadCenter config', function () {
         existingUploadedJsonFeed.versions.map((v) => v.cta).filter((cta) => cta)
       ).to.have.length.greaterThan(0);
 
-      const config = getConfig({
+      const config = {
         '*': existingUploadedJsonFeed.cta!,
-      });
+      };
 
-      await updateJsonFeedCTA(config, dlCenter);
+      await updateJsonFeedCTA(
+        config,
+        DUMMY_ACCESS_KEY,
+        DUMMY_SECRET_KEY,
+        false,
+        dlCenter as any
+      );
 
       const updatedJsonFeed = getUploadedJsonFeed();
       expect(updatedJsonFeed.cta).to.not.be.undefined;
@@ -671,13 +689,19 @@ describe('DownloadCenter config', function () {
     });
 
     it('can update global cta', async function () {
-      const config = getConfig({
+      const config = {
         '*': {
           chunks: [{ text: "It's a beautiful day", style: 'imagePositive' }],
         },
-      });
+      };
 
-      await updateJsonFeedCTA(config, dlCenter);
+      await updateJsonFeedCTA(
+        config,
+        DUMMY_ACCESS_KEY,
+        DUMMY_SECRET_KEY,
+        false,
+        dlCenter as any
+      );
 
       const updatedJsonFeed = getUploadedJsonFeed();
 
@@ -687,13 +711,19 @@ describe('DownloadCenter config', function () {
     });
 
     it('can update version-specific cta', async function () {
-      const config = getConfig({
+      const config = {
         '1.10.3': {
           chunks: [{ text: "It's a beautiful day", style: 'imagePositive' }],
         },
-      });
+      };
 
-      await updateJsonFeedCTA(config, dlCenter);
+      await updateJsonFeedCTA(
+        config,
+        DUMMY_ACCESS_KEY,
+        DUMMY_SECRET_KEY,
+        false,
+        dlCenter as any
+      );
 
       const updatedJsonFeed = getUploadedJsonFeed();
 
@@ -708,15 +738,21 @@ describe('DownloadCenter config', function () {
       // Remove the existing cta
       existingUploadedJsonFeed.cta = undefined;
 
-      const config = getConfig({
+      const config = {
         '*': {
           chunks: [
             { text: 'Go outside and enjoy the sun', style: 'imagePositive' },
           ],
         },
-      });
+      };
 
-      await updateJsonFeedCTA(config, dlCenter);
+      await updateJsonFeedCTA(
+        config,
+        DUMMY_ACCESS_KEY,
+        DUMMY_SECRET_KEY,
+        false,
+        dlCenter as any
+      );
 
       const updatedJsonFeed = getUploadedJsonFeed();
 
@@ -731,15 +767,21 @@ describe('DownloadCenter config', function () {
       // Remove the existing cta
       existingUploadedJsonFeed.cta = undefined;
 
-      const config = getConfig({
+      const config = {
         '1.10.4': {
           chunks: [
             { text: 'Go outside and enjoy the sun', style: 'imagePositive' },
           ],
         },
-      });
+      };
 
-      await updateJsonFeedCTA(config, dlCenter);
+      await updateJsonFeedCTA(
+        config,
+        DUMMY_ACCESS_KEY,
+        DUMMY_SECRET_KEY,
+        false,
+        dlCenter as any
+      );
 
       const updatedJsonFeed = getUploadedJsonFeed();
 
diff --git a/packages/build/src/download-center/config.ts b/packages/build/src/download-center/config.ts
index 02d26b5483..09e0d75481 100644
--- a/packages/build/src/download-center/config.ts
+++ b/packages/build/src/download-center/config.ts
@@ -15,7 +15,7 @@ import {
   CONFIGURATIONS_BUCKET,
   ARTIFACTS_FALLBACK,
 } from './constants';
-import type { PackageVariant } from '../config';
+import type { CTAConfig, GreetingCTADetails, PackageVariant } from '../config';
 import {
   ALL_PACKAGE_VARIANTS,
   getDownloadCenterDistroDescription,
@@ -57,6 +57,7 @@ export async function createAndPublishDownloadCenterConfig(
   awsSecretAccessKey: string,
   injectedJsonFeedFile: string,
   isDryRun: boolean,
+  ctaConfig: CTAConfig,
   DownloadCenter: typeof DownloadCenterCls = DownloadCenterCls,
   publicArtifactBaseUrl: string = ARTIFACTS_URL_PUBLIC_BASE
 ): Promise<void> {
@@ -120,6 +121,8 @@ export async function createAndPublishDownloadCenterConfig(
     currentJsonFeedWrapped
   );
 
+  populateJsonFeedCTAs(newJsonFeed, ctaConfig);
+
   if (isDryRun) {
     console.warn('Not uploading download center config in dry-run mode');
     return;
@@ -135,13 +138,16 @@ export async function createAndPublishDownloadCenterConfig(
 }
 
 export async function updateJsonFeedCTA(
-  config: UpdateCTAConfig,
+  config: CTAConfig,
+  awsAccessKeyId: string,
+  awsSecretAccessKey: string,
+  isDryRun: boolean,
   DownloadCenter: typeof DownloadCenterCls = DownloadCenterCls
 ) {
   const dlcenterArtifacts = new DownloadCenter({
     bucket: ARTIFACTS_BUCKET,
-    accessKeyId: config.awsAccessKeyId,
-    secretAccessKey: config.awsSecretAccessKey,
+    accessKeyId: awsAccessKeyId,
+    secretAccessKey: awsSecretAccessKey,
   });
 
   const jsonFeed = await getCurrentJsonFeed(dlcenterArtifacts);
@@ -149,13 +155,10 @@ export async function updateJsonFeedCTA(
     throw new Error('No existing JSON feed found');
   }
 
-  jsonFeed.cta = config.ctas['*'];
-  for (const version of jsonFeed.versions) {
-    version.cta = config.ctas[version.version];
-  }
+  populateJsonFeedCTAs(jsonFeed, config);
 
   const patchedJsonFeed = JSON.stringify(jsonFeed, null, 2);
-  if (config.isDryRun) {
+  if (isDryRun) {
     console.warn('Not uploading JSON feed in dry-run mode');
     console.warn(`Patched JSON feed: ${patchedJsonFeed}`);
     return;
@@ -164,6 +167,13 @@ export async function updateJsonFeedCTA(
   await dlcenterArtifacts.uploadAsset(JSON_FEED_ARTIFACT_KEY, patchedJsonFeed);
 }
 
+function populateJsonFeedCTAs(jsonFeed: JsonFeed, ctas: CTAConfig) {
+  jsonFeed.cta = ctas['*'];
+  for (const version of jsonFeed.versions) {
+    version.cta = ctas[version.version];
+  }
+}
+
 export function getUpdatedDownloadCenterConfig(
   downloadedConfig: DownloadCenterConfig,
   getVersionConfig: () => ReturnType<typeof createVersionConfig>
@@ -237,23 +247,6 @@ export function createVersionConfig(
   };
 }
 
-// TODO: this is duplicated in update-notification-manager.ts
-interface GreetingCTADetails {
-  chunks: {
-    text: string;
-    style?: string; // TODO: this is actually clr.ts/StyleDefinition
-  }[];
-}
-
-export interface UpdateCTAConfig {
-  ctas: {
-    [version: string | '*']: GreetingCTADetails;
-  };
-  awsAccessKeyId: string;
-  awsSecretAccessKey: string;
-  isDryRun: boolean;
-}
-
 export interface JsonFeed {
   versions: JsonFeedVersionEntry[];
   cta?: GreetingCTADetails;
diff --git a/packages/build/src/index.ts b/packages/build/src/index.ts
index 86791aa466..f818ee6eda 100644
--- a/packages/build/src/index.ts
+++ b/packages/build/src/index.ts
@@ -7,7 +7,7 @@ import type { ReleaseCommand } from './release';
 import { release } from './release';
 import type { Config, PackageVariant } from './config';
 import { updateJsonFeedCTA } from './download-center';
-import type { UpdateCTAConfig } from './download-center';
+import Ajv from 'ajv';
 
 export { getArtifactUrl, downloadMongoDb };
 
@@ -30,6 +30,37 @@ const isValidCommand = (
 ): cmd is ReleaseCommand | 'trigger-release' | 'update-cta' =>
   (validCommands as string[]).includes(cmd);
 
+const getBuildConfig = (): Config => {
+  const config: Config = require(path.join(
+    __dirname,
+    '..',
+    '..',
+    '..',
+    'config',
+    'build.conf.js'
+  ));
+
+  const cliBuildVariant = process.argv
+    .map((arg) => /^--build-variant=(.+)$/.exec(arg))
+    .filter(Boolean)[0];
+  if (cliBuildVariant) {
+    config.packageVariant = cliBuildVariant[1] as PackageVariant;
+    validatePackageVariant(config.packageVariant);
+  }
+
+  const ajv = new Ajv();
+  const validateSchema = ajv.compile(config.ctaConfigSchema);
+  if (!validateSchema(config.ctaConfig)) {
+    console.warn('CTA schema validation failed:', validateSchema.errors);
+    throw new Error('CTA validation failed, see above for details');
+  }
+
+  config.isDryRun ||= process.argv.includes('--dry-run');
+  config.useAuxiliaryPackagesOnly ||= process.argv.includes('--auxiliary');
+
+  return config;
+};
+
 if (require.main === module) {
   Error.stackTraceLimit = 200;
 
@@ -46,40 +77,26 @@ if (require.main === module) {
         await triggerRelease(process.argv.slice(3));
         break;
       case 'update-cta':
-        const ctaConfig: UpdateCTAConfig = require(path.join(
-          __dirname,
-          '..',
-          '..',
-          '..',
-          'config',
-          'cta.conf.js'
-        ));
+        const {
+          ctaConfig,
+          downloadCenterAwsKey,
+          downloadCenterAwsSecret,
+          isDryRun,
+        } = getBuildConfig();
 
-        ctaConfig.isDryRun ||= process.argv.includes('--dry-run');
+        if (!downloadCenterAwsKey || !downloadCenterAwsSecret) {
+          throw new Error('Missing AWS credentials for download center');
+        }
 
-        await updateJsonFeedCTA(ctaConfig);
+        await updateJsonFeedCTA(
+          ctaConfig,
+          downloadCenterAwsKey,
+          downloadCenterAwsSecret,
+          !!isDryRun
+        );
         break;
       default:
-        const config: Config = require(path.join(
-          __dirname,
-          '..',
-          '..',
-          '..',
-          'config',
-          'build.conf.js'
-        ));
-
-        const cliBuildVariant = process.argv
-          .map((arg) => /^--build-variant=(.+)$/.exec(arg))
-          .filter(Boolean)[0];
-        if (cliBuildVariant) {
-          config.packageVariant = cliBuildVariant[1] as PackageVariant;
-          validatePackageVariant(config.packageVariant);
-        }
-
-        config.isDryRun ||= process.argv.includes('--dry-run');
-        config.useAuxiliaryPackagesOnly ||=
-          process.argv.includes('--auxiliary');
+        const config = getBuildConfig();
 
         await release(command, config);
         break;
diff --git a/packages/build/src/publish-mongosh.ts b/packages/build/src/publish-mongosh.ts
index f1cae0a556..c3a1a03393 100644
--- a/packages/build/src/publish-mongosh.ts
+++ b/packages/build/src/publish-mongosh.ts
@@ -92,7 +92,8 @@ export async function publishMongosh(
     config.downloadCenterAwsKey || '',
     config.downloadCenterAwsSecret || '',
     config.injectedJsonFeedFile || '',
-    !!config.isDryRun
+    !!config.isDryRun,
+    config.ctaConfig
   );
 
   await mongoshGithubRepo.promoteRelease(config);
diff --git a/packages/build/test/helpers.ts b/packages/build/test/helpers.ts
index 27568d0bc0..1048a89714 100644
--- a/packages/build/test/helpers.ts
+++ b/packages/build/test/helpers.ts
@@ -75,4 +75,6 @@ export const dummyConfig: Config = Object.freeze({
     } as PackageInformation),
   execNodeVersion: process.version,
   rootDir: path.resolve(__dirname, '..', '..'),
+  ctaConfig: {},
+  ctaConfigSchema: {},
 });

From 2e138015ed669d446aaa46713ec2085d4fff69a0 Mon Sep 17 00:00:00 2001
From: Nikola Irinchev <irinchev@me.com>
Date: Mon, 10 Feb 2025 13:13:12 +0100
Subject: [PATCH 7/8] CR comments

---
 .github/workflows/update-cta.yml    | 3 +++
 packages/build/src/config/config.ts | 7 +++++--
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/update-cta.yml b/.github/workflows/update-cta.yml
index 859d40de4f..369134d29a 100644
--- a/.github/workflows/update-cta.yml
+++ b/.github/workflows/update-cta.yml
@@ -18,6 +18,9 @@ on:
         required: true
         default: CTA-Production
 
+permissions:
+  contents: read
+
 jobs:
   dry-run:
     name: Update greeting CTA
diff --git a/packages/build/src/config/config.ts b/packages/build/src/config/config.ts
index 0c2676f0a1..35001e961d 100644
--- a/packages/build/src/config/config.ts
+++ b/packages/build/src/config/config.ts
@@ -8,11 +8,14 @@ interface ManPageConfig {
   fileName: string;
 }
 
-// TODO: this is duplicated in update-notification-manager.ts
+// This needs to match the interface in cli-repl/update-notification-manager.ts
 export interface GreetingCTADetails {
   chunks: {
     text: string;
-    style?: string; // TODO: this is actually clr.ts/StyleDefinition
+    // This is actually cli-repl/clr.ts/StyleDefinition, but we can't import it here.
+    // The correct type is already enforced in json schema, so treating it as a generic
+    // string is fine.
+    style?: string;
   }[];
 }
 

From 2030107a074380ade9318f76f3f97a23b4a83888 Mon Sep 17 00:00:00 2001
From: Nikola Irinchev <irinchev@me.com>
Date: Tue, 11 Feb 2025 13:19:55 +0100
Subject: [PATCH 8/8] fix lint

---
 packages/build/src/config/config.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/build/src/config/config.ts b/packages/build/src/config/config.ts
index 35001e961d..35c514b764 100644
--- a/packages/build/src/config/config.ts
+++ b/packages/build/src/config/config.ts
@@ -1,4 +1,4 @@
-import { Schema } from 'ajv';
+import type { Schema } from 'ajv';
 import type { PackageInformationProvider } from '../packaging/package';
 import type { PackageVariant } from './build-variant';