diff --git a/.github/workflows/update-cta.yml b/.github/workflows/update-cta.yml new file mode 100644 index 000000000..859d40de4 --- /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 new file mode 100644 index 000000000..f1dc72b76 --- /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. + // '*': { + // chunks: [ + // { text: 'Example', style: 'bold' }, + // ] + // }, + // '1.2.3': { + // chunks: [ + // { text: 'Example', style: 'mongosh:uri' }, + // ] + // } + }, + isDryRun: false, +} diff --git a/configs/eslint-config-mongosh/index.js b/configs/eslint-config-mongosh/index.js index 71301c52f..edffe282d 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/package.json b/package.json index 90611bdf3..8051f48cd 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 a4963c70c..451ce16d0 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 e0decdf9d..31abb7148 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 b88890037..02d26b548 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 { + 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 @@ -201,13 +237,32 @@ export function createVersionConfig( }; } -interface JsonFeed { +// 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; } 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 b3d953fd3..5ba6a4d77 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 064557fa6..1a2e23782 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/build/test/fixtures/cta-versions.json b/packages/build/test/fixtures/cta-versions.json new file mode 100644 index 000000000..f73ad3bd3 --- /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/cli-repl.ts b/packages/cli-repl/src/cli-repl.ts index a138552fb..a577fb9ee 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(); @@ -1262,6 +1266,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' ); @@ -1269,14 +1274,19 @@ 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, + hasGreetingCTA: + !!(await this.updateNotificationManager.getGreetingCTAForCurrentVersion()), }); } catch (err: any) { this.bus.emit('mongosh:error', err, 'startup'); diff --git a/packages/cli-repl/src/clr.ts b/packages/cli-repl/src/clr.ts index 1a97c5e35..4b383e63e 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 80ee33818..1ce81618f 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 */ type Mutable = { -readonly [P in keyof T]: T[P]; @@ -179,7 +184,7 @@ class MongoshNodeRepl implements EvaluationListener { */ async initialize( serviceProvider: ServiceProvider, - moreRecentMongoshVersion?: string | null + greeting?: GreetingDetails ): Promise { const usePlainVMContext = this.shellCliOptions.jsContext === 'plain-vm'; @@ -223,7 +228,7 @@ class MongoshNodeRepl implements EvaluationListener { (mongodVersion ? mongodVersion + ' ' : '') + `(API Version ${apiVersion})`; } - await this.greet(mongodVersion, moreRecentMongoshVersion); + await this.greet(mongodVersion, greeting); } } @@ -583,7 +588,7 @@ class MongoshNodeRepl implements EvaluationListener { */ async greet( mongodVersion: string, - moreRecentMongoshVersion?: string | null + greeting?: GreetingDetails ): Promise { if (this.shellCliOptions.quiet) { return; @@ -597,15 +602,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.spec.ts b/packages/cli-repl/src/update-notification-manager.spec.ts index 85faa8f68..5a154171c 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 () { @@ -41,28 +42,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 +86,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 +94,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 +112,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' @@ -117,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 12a7604d3..374fdce40 100644 --- a/packages/cli-repl/src/update-notification-manager.ts +++ b/packages/cli-repl/src/update-notification-manager.ts @@ -7,18 +7,38 @@ import type { Response, } from '@mongodb-js/devtools-proxy-support'; import { createFetch } from '@mongodb-js/devtools-proxy-support'; +import type { StyleDefinition } from './clr'; + +interface GreetingCTADetails { + chunks: { + text: string; + style?: StyleDefinition; + }[]; +} + +export interface MongoshVersionsContents { + versions: { + version: string; + cta?: GreetingCTADetails; + }[]; + 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 | undefined = undefined; private fetch: (url: string, init: RequestInit) => Promise; @@ -49,12 +69,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 { let localFileContents: MongoshUpdateLocalFileContents | undefined; await (this.localFilesystemFetchInProgress = (async () => { @@ -90,6 +127,10 @@ export class UpdateNotificationManager { localFileContents.latestKnownMongoshVersion; } + if (localFileContents?.cta && currentVersion in localFileContents.cta) { + this.currentVersionGreetingCTA = localFileContents.cta[currentVersion]; + } + this.localFilesystemFetchInProgress = undefined; })()); @@ -123,17 +164,36 @@ 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 ?? jsonContents?.cta; + + const latestKnownVersionCTA = + jsonContents?.versions?.filter( + (v) => v.version === this.latestKnownMongoshVersion + )?.[0]?.cta ?? jsonContents?.cta; + localFileContents = { updateURL, lastChecked: Date.now(), etag: response.headers.get('etag') ?? undefined, latestKnownMongoshVersion: this.latestKnownMongoshVersion, + cta: { + [currentVersion]: this.currentVersionGreetingCTA, + + // 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/snippet-manager/src/snippet-manager.spec.ts b/packages/snippet-manager/src/snippet-manager.spec.ts index df1ae238e..83ce7665f 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(); }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 06fa242c0..22da72a78 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -173,10 +173,13 @@ export interface EditorReadVscodeExtensionsFailedEvent { export interface FetchingUpdateMetadataEvent { updateURL: string; localFilePath: string; + currentVersion: string; } export interface FetchingUpdateMetadataCompleteEvent { latest: string | null; + currentVersion: string; + hasGreetingCTA: boolean; } export interface SessionStartedEvent {