diff --git a/.github/workflows/update-cta.yml b/.github/workflows/update-cta.yml new file mode 100644 index 0000000000..369134d29a --- /dev/null +++ b/.github/workflows/update-cta.yml @@ -0,0 +1,49 @@ +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 + +permissions: + contents: read + +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/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/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/package.json b/package.json index af12edefb6..88dcf5a547 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 3c0a2bdcbd..cf1a56c207 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -30,7 +30,8 @@ "publish": "ts-node src/index.ts publish", "bump-auxiliary": "ts-node src/index.ts bump --auxiliary", "publish-auxiliary": "ts-node src/index.ts publish --auxiliary", - "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": { @@ -69,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..35c514b764 100644 --- a/packages/build/src/config/config.ts +++ b/packages/build/src/config/config.ts @@ -1,3 +1,4 @@ +import type { Schema } from 'ajv'; import type { PackageInformationProvider } from '../packaging/package'; import type { PackageVariant } from './build-variant'; @@ -7,6 +8,21 @@ interface ManPageConfig { fileName: string; } +// This needs to match the interface in cli-repl/update-notification-manager.ts +export interface GreetingCTADetails { + chunks: { + text: string; + // 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; + }[]; +} + +export type CTAConfig = { + [version: string | '*']: GreetingCTADetails; +}; + /** * Defines the configuration interface for the build system. */ @@ -47,4 +63,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 e0decdf9d4..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, @@ -10,7 +10,9 @@ import { getUpdatedDownloadCenterConfig, createAndPublishDownloadCenterConfig, createJsonFeedEntry, + updateJsonFeedCTA, } from './config'; +import type { JsonFeed } from './config'; import { promises as fs } from 'fs'; import path from 'path'; import fetch from 'node-fetch'; @@ -36,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 () { @@ -265,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; @@ -323,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; @@ -421,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, '..', @@ -432,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; @@ -529,4 +538,260 @@ describe('DownloadCenter config', function () { expect(serverTargets).to.include(target); }); }); + + describe('updateJsonFeedCTA', function () { + let dlCenter: sinon.SinonStub; + 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 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 (const dryRun of [false, true]) { + it(`when dryRun is ${dryRun}, does ${ + dryRun ? 'not ' : '' + }upload the updated json feed`, async function () { + const config: CTAConfig = { + '1.10.3': { + chunks: [{ text: 'Foo' }], + }, + '*': { + chunks: [{ text: 'Bar' }], + }, + }; + + await updateJsonFeedCTA( + config, + DUMMY_ACCESS_KEY, + DUMMY_SECRET_KEY, + dryRun, + dlCenter as any + ); + 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: CTAConfig = { + '1.10.5': { + chunks: [{ text: 'Foo' }], + }, + }; + + await updateJsonFeedCTA( + config, + DUMMY_ACCESS_KEY, + DUMMY_SECRET_KEY, + false, + dlCenter as any + ); + + 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; + }, + {} + ); + expect(ctas['*']).to.be.undefined; + await updateJsonFeedCTA( + ctas, + DUMMY_ACCESS_KEY, + DUMMY_SECRET_KEY, + false, + dlCenter as any + ); + + 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 = { + '*': existingUploadedJsonFeed.cta!, + }; + + await updateJsonFeedCTA( + config, + DUMMY_ACCESS_KEY, + DUMMY_SECRET_KEY, + false, + dlCenter as any + ); + + 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 = { + '*': { + chunks: [{ text: "It's a beautiful day", style: 'imagePositive' }], + }, + }; + + await updateJsonFeedCTA( + config, + DUMMY_ACCESS_KEY, + DUMMY_SECRET_KEY, + false, + dlCenter as any + ); + + 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 = { + '1.10.3': { + chunks: [{ text: "It's a beautiful day", style: 'imagePositive' }], + }, + }; + + await updateJsonFeedCTA( + config, + DUMMY_ACCESS_KEY, + DUMMY_SECRET_KEY, + false, + dlCenter as any + ); + + 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 = { + '*': { + chunks: [ + { text: 'Go outside and enjoy the sun', style: 'imagePositive' }, + ], + }, + }; + + await updateJsonFeedCTA( + config, + DUMMY_ACCESS_KEY, + DUMMY_SECRET_KEY, + false, + dlCenter as any + ); + + 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 = { + '1.10.4': { + chunks: [ + { text: 'Go outside and enjoy the sun', style: 'imagePositive' }, + ], + }, + }; + + await updateJsonFeedCTA( + config, + DUMMY_ACCESS_KEY, + DUMMY_SECRET_KEY, + false, + dlCenter as any + ); + + 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 b88890037d..09e0d75481 100644 --- a/packages/build/src/download-center/config.ts +++ b/packages/build/src/download-center/config.ts @@ -9,13 +9,13 @@ 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, ARTIFACTS_FALLBACK, } from './constants'; -import type { PackageVariant } from '../config'; +import type { CTAConfig, GreetingCTADetails, PackageVariant } from '../config'; import { ALL_PACKAGE_VARIANTS, getDownloadCenterDistroDescription, @@ -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, @@ -39,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 { @@ -80,20 +99,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; @@ -114,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; @@ -122,12 +131,49 @@ 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: CTAConfig, + awsAccessKeyId: string, + awsSecretAccessKey: string, + isDryRun: boolean, + DownloadCenter: typeof DownloadCenterCls = DownloadCenterCls +) { + const dlcenterArtifacts = new DownloadCenter({ + bucket: ARTIFACTS_BUCKET, + accessKeyId: awsAccessKeyId, + secretAccessKey: awsSecretAccessKey, + }); + + const jsonFeed = await getCurrentJsonFeed(dlcenterArtifacts); + if (!jsonFeed) { + throw new Error('No existing JSON feed found'); + } + + populateJsonFeedCTAs(jsonFeed, config); + + const patchedJsonFeed = JSON.stringify(jsonFeed, null, 2); + if (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); +} + +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 @@ -201,13 +247,15 @@ export function createVersionConfig( }; } -interface JsonFeed { +export interface JsonFeed { versions: JsonFeedVersionEntry[]; + cta?: GreetingCTADetails; } interface JsonFeedVersionEntry { version: string; downloads: JsonFeedDownloadEntry[]; + cta?: GreetingCTADetails; } interface JsonFeedDownloadEntry { @@ -275,6 +323,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 665e90d946..f818ee6eda 100644 --- a/packages/build/src/index.ts +++ b/packages/build/src/index.ts @@ -6,10 +6,12 @@ import { triggerRelease } from './local'; import type { ReleaseCommand } from './release'; import { release } from './release'; import type { Config, PackageVariant } from './config'; +import { updateJsonFeedCTA } from './download-center'; +import Ajv from 'ajv'; export { getArtifactUrl, downloadMongoDb }; -const validCommands: (ReleaseCommand | 'trigger-release')[] = [ +const validCommands: (ReleaseCommand | 'trigger-release' | 'update-cta')[] = [ 'bump', 'compile', 'package', @@ -20,13 +22,45 @@ 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); +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; @@ -38,30 +72,34 @@ 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, + downloadCenterAwsKey, + downloadCenterAwsSecret, + isDryRun, + } = getBuildConfig(); - const cliBuildVariant = process.argv - .map((arg) => /^--build-variant=(.+)$/.exec(arg)) - .filter(Boolean)[0]; - if (cliBuildVariant) { - config.packageVariant = cliBuildVariant[1] as PackageVariant; - validatePackageVariant(config.packageVariant); - } + if (!downloadCenterAwsKey || !downloadCenterAwsSecret) { + throw new Error('Missing AWS credentials for download center'); + } - config.isDryRun ||= process.argv.includes('--dry-run'); - config.useAuxiliaryPackagesOnly ||= process.argv.includes('--auxiliary'); + await updateJsonFeedCTA( + ctaConfig, + downloadCenterAwsKey, + downloadCenterAwsSecret, + !!isDryRun + ); + break; + default: + const config = getBuildConfig(); - await release(command, config); + await release(command, config); + break; } })().then( () => process.exit(0), 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/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/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: {}, }); diff --git a/packages/cli-repl/src/cli-repl.ts b/packages/cli-repl/src/cli-repl.ts index 11c4076d67..78c4be2457 100644 --- a/packages/cli-repl/src/cli-repl.ts +++ b/packages/cli-repl/src/cli-repl.ts @@ -449,7 +449,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(); @@ -1309,6 +1313,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' ); @@ -1316,14 +1321,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 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 988217ffc9..e10f4210db 100644 --- a/packages/cli-repl/src/mongosh-repl.ts +++ b/packages/cli-repl/src/mongosh-repl.ts @@ -119,6 +119,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]; @@ -185,7 +190,7 @@ class MongoshNodeRepl implements EvaluationListener { */ async initialize( serviceProvider: ServiceProvider, - moreRecentMongoshVersion?: string | null + greeting?: GreetingDetails ): Promise { const usePlainVMContext = this.shellCliOptions.jsContext === 'plain-vm'; @@ -229,7 +234,7 @@ class MongoshNodeRepl implements EvaluationListener { (mongodVersion ? mongodVersion + ' ' : '') + `(API Version ${apiVersion})`; } - await this.greet(mongodVersion, moreRecentMongoshVersion); + await this.greet(mongodVersion, greeting); } } @@ -603,7 +608,7 @@ class MongoshNodeRepl implements EvaluationListener { */ async greet( mongodVersion: string, - moreRecentMongoshVersion?: string | null + greeting?: GreetingDetails ): Promise { if (this.shellCliOptions.quiet) { return; @@ -617,15 +622,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 85faa8f681..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 () { @@ -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 12a7604d3c..4ced1c5dd9 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?.find((v) => v.version === currentVersion)?.cta ?? + jsonContents?.cta; + + const latestKnownVersionCTA = + jsonContents?.versions?.find( + (v) => v.version === this.latestKnownMongoshVersion + )?.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 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(); }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index bbe2617794..f98c443b7f 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 {