diff --git a/.changeset/tough-eels-bow.md b/.changeset/tough-eels-bow.md new file mode 100644 index 0000000000..bd628f87ef --- /dev/null +++ b/.changeset/tough-eels-bow.md @@ -0,0 +1,7 @@ +--- +'@shopify/cli-kit': minor +'@shopify/app': minor +'@shopify/cli': minor +--- + +Notification system diff --git a/notifications.json b/notifications.json new file mode 100644 index 0000000000..8617fa5f2e --- /dev/null +++ b/notifications.json @@ -0,0 +1 @@ +{"notifications":[]} diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 12217eee23..5edddc83d5 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -49,6 +49,7 @@ import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array' import {checkIfIgnoredInGitRepository} from '@shopify/cli-kit/node/git' import {renderInfo} from '@shopify/cli-kit/node/ui' import {currentProcessIsGlobal} from '@shopify/cli-kit/node/is-global' +import {showNotificationsIfNeeded} from '@shopify/cli-kit/node/notifications-system' import {globalCLIVersion, localCLIVersion} from '@shopify/cli-kit/node/version' const defaultExtensionDirectory = 'extensions/*' @@ -327,6 +328,10 @@ class AppLoader module.type) + await showNotificationsIfNeeded(extensionTypes) + if (!this.errors.isEmpty()) appClass.errors = this.errors await logMetadataForLoadedApp(appClass, { diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index 0f11224f28..0c288d648c 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -89,7 +89,9 @@ "static": [ "@oclif/core", "./context/utilities.js", - "../../private/node/demo-recorder.js" + "../../private/node/demo-recorder.js", + "../../private/node/conf-store.js", + "url" ] } ] diff --git a/packages/cli-kit/src/private/node/conf-store.test.ts b/packages/cli-kit/src/private/node/conf-store.test.ts index 713b74690e..8a06b67f60 100644 --- a/packages/cli-kit/src/private/node/conf-store.test.ts +++ b/packages/cli-kit/src/private/node/conf-store.test.ts @@ -155,14 +155,15 @@ describe('cacheRetrieve', () => { await inTemporaryDirectory(async (cwd) => { // Given const config = new LocalStorage({cwd}) - const cacheValue = {'identity-introspection-url-IDENTITYURL': {value: 'URL1', timestamp: Date.now()}} - config.set('cache', cacheValue) + const cacheValue = {value: 'URL1', timestamp: Date.now()} + const cacheEntry = {'identity-introspection-url-IDENTITYURL': cacheValue} + config.set('cache', cacheEntry) // When const got = cacheRetrieve('identity-introspection-url-IDENTITYURL', config) // Then - expect(got).toEqual('URL1') + expect(got).toEqual(cacheValue) }) }) diff --git a/packages/cli-kit/src/private/node/conf-store.ts b/packages/cli-kit/src/private/node/conf-store.ts index af106bd8ac..b052f48487 100644 --- a/packages/cli-kit/src/private/node/conf-store.ts +++ b/packages/cli-kit/src/private/node/conf-store.ts @@ -9,15 +9,19 @@ interface CacheValue { export type IntrospectionUrlKey = `identity-introspection-url-${string}` export type PackageVersionKey = `npm-package-${string}` +export type NotificationsKey = `notifications-${string}` +export type NotificationKey = `notification-${string}` type MostRecentOccurrenceKey = `most-recent-occurrence-${string}` type RateLimitKey = `rate-limited-occurrences-${string}` -type ExportedKey = IntrospectionUrlKey | PackageVersionKey +type ExportedKey = IntrospectionUrlKey | PackageVersionKey | NotificationsKey | NotificationKey interface Cache { [introspectionUrlKey: IntrospectionUrlKey]: CacheValue [packageVersionKey: PackageVersionKey]: CacheValue - [mostRecentOccurrenceKey: MostRecentOccurrenceKey]: CacheValue + [notifications: NotificationsKey]: CacheValue + [notification: NotificationKey]: CacheValue + [MostRecentOccurrenceKey: MostRecentOccurrenceKey]: CacheValue [rateLimitKey: RateLimitKey]: CacheValue } @@ -85,28 +89,31 @@ export async function cacheRetrieveOrRepopulate( timeout?: number, config = cliKitStore(), ): Promise> { - const cache: Cache = config.get('cache') || {} - const cached = cache[key] + const cached = cacheRetrieve(key, config) if (cached?.value !== undefined && (timeout === undefined || Date.now() - cached.timestamp < timeout)) { return cached.value } const value = await fn() + cacheStore(key, value, config) + return value +} + +export function cacheStore(key: ExportedKey, value: string, config = cliKitStore()): void { + const cache: Cache = config.get('cache') || {} cache[key] = {value, timestamp: Date.now()} config.set('cache', cache) - return value } /** * Fetch from cache if already populated, otherwise return undefined. * @param key - The key to use for the cache. - * @returns The value from the cache or the result of the function. + * @returns The chache element. */ -export function cacheRetrieve(key: ExportedKey, config = cliKitStore()): CacheValueForKey | undefined { +export function cacheRetrieve(key: ExportedKey, config = cliKitStore()): CacheValue | undefined { const cache: Cache = config.get('cache') || {} - const cached = cache[key] - return cached?.value + return cache[key] } export function cacheClear(config = cliKitStore()): void { diff --git a/packages/cli-kit/src/public/node/base-command.ts b/packages/cli-kit/src/public/node/base-command.ts index c8f06d195b..9adb73b888 100644 --- a/packages/cli-kit/src/public/node/base-command.ts +++ b/packages/cli-kit/src/public/node/base-command.ts @@ -8,6 +8,8 @@ import {outputContent, outputInfo, outputToken} from './output.js' import {terminalSupportsPrompting} from './system.js' import {hashString} from './crypto.js' import {isTruthy} from './context/utilities.js' +import {showNotificationsIfNeeded} from './notifications-system.js' +import {setCurrentCommandId} from './global-context.js' import {JsonMap} from '../../private/common/json.js' import {underscore} from '../common/string.js' import {Command, Errors} from '@oclif/core' @@ -45,11 +47,13 @@ abstract class BaseCommand extends Command { // eslint-disable-next-line @typescript-eslint/no-explicit-any protected async init(): Promise { this.exitWithTimestampWhenEnvVariablePresent() + setCurrentCommandId(this.id || '') if (!isDevelopment()) { // This function runs just prior to `run` await registerCleanBugsnagErrorsFromWithinPlugins(this.config) } this.showNpmFlagWarning() + await showNotificationsIfNeeded() return super.init() } diff --git a/packages/cli-kit/src/public/node/cli.ts b/packages/cli-kit/src/public/node/cli.ts index 69604dd7d1..54439dafa4 100644 --- a/packages/cli-kit/src/public/node/cli.ts +++ b/packages/cli-kit/src/public/node/cli.ts @@ -1,7 +1,7 @@ import {isTruthy} from './context/utilities.js' import {printEventsJson} from '../../private/node/demo-recorder.js' +import {cacheClear} from '../../private/node/conf-store.js' import {Flags} from '@oclif/core' -// eslint-disable-next-line @shopify/cli/specific-imports-in-bootstrap-code import {fileURLToPath} from 'url' /** @@ -202,3 +202,10 @@ export const globalFlags = { env: 'SHOPIFY_FLAG_VERBOSE', }), } + +/** + * Clear the CLI cache, used to store some API responses and handle notifications status + */ +export function clearCache(): void { + cacheClear() +} diff --git a/packages/cli-kit/src/public/node/global-context.ts b/packages/cli-kit/src/public/node/global-context.ts new file mode 100644 index 0000000000..fdbb5d87a2 --- /dev/null +++ b/packages/cli-kit/src/public/node/global-context.ts @@ -0,0 +1,35 @@ +export interface GlobalContext { + currentCommandId: string +} + +let _globalContext: GlobalContext | undefined + +/** + * Get the global context. + * + * @returns Global context. + */ +function getGlobalContext(): GlobalContext { + if (!_globalContext) { + _globalContext = {currentCommandId: ''} + } + return _globalContext +} + +/** + * Get the current command ID. + * + * @returns Current command ID. + */ +export function getCurrentCommandId(): string { + return getGlobalContext().currentCommandId +} + +/** + * Set the current command ID. + * + * @param commandId - Command ID. + */ +export function setCurrentCommandId(commandId: string): void { + getGlobalContext().currentCommandId = commandId +} diff --git a/packages/cli-kit/src/public/node/node-package-manager.ts b/packages/cli-kit/src/public/node/node-package-manager.ts index 118998f77a..99094948dd 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.ts @@ -288,7 +288,7 @@ export async function checkForNewVersion( */ export function checkForCachedNewVersion(dependency: string, currentVersion: string): string | undefined { const cacheKey: PackageVersionKey = `npm-package-${dependency}` - const lastVersion = cacheRetrieve(cacheKey) + const lastVersion = cacheRetrieve(cacheKey)?.value if (lastVersion && new SemVer(currentVersion).compare(lastVersion) < 0) { return lastVersion diff --git a/packages/cli-kit/src/public/node/notifications-system.test.ts b/packages/cli-kit/src/public/node/notifications-system.test.ts new file mode 100644 index 0000000000..f07b422335 --- /dev/null +++ b/packages/cli-kit/src/public/node/notifications-system.test.ts @@ -0,0 +1,366 @@ +import {Notification, filterNotifications, showNotificationsIfNeeded} from './notifications-system.js' +import {renderError, renderInfo, renderWarning} from './ui.js' +import {cacheRetrieve, cacheRetrieveOrRepopulate} from '../../private/node/conf-store.js' +import {afterEach, describe, expect, test, vi} from 'vitest' + +vi.mock('./ui.js') +vi.mock('../../private/node/conf-store.js') + +const betweenVersins1and2: Notification = { + id: 'betweenVersins1and2', + message: 'message', + type: 'info', + frequency: 'always', + ownerChannel: 'channel', + minVersion: '1.0', + maxVersion: '2.0', +} + +const betweenDatesIn2000: Notification = { + id: 'betweenDatesIn2000', + message: 'message', + type: 'info', + frequency: 'always', + ownerChannel: 'channel', + minDate: '2000-01-01', + maxDate: '2000-12-31', +} + +const fromVersion1: Notification = { + id: 'fromVersion1', + message: 'message', + type: 'info', + frequency: 'always', + ownerChannel: 'channel', + minVersion: '1.0', +} + +const upToVersion2: Notification = { + id: 'upToVersion2', + message: 'message', + type: 'info', + frequency: 'always', + ownerChannel: 'channel', + maxVersion: '2.0', +} + +const fromDateJan2000: Notification = { + id: 'fromDateJan2000', + message: 'message', + type: 'info', + frequency: 'always', + ownerChannel: 'channel', + minDate: '2000-01-01', +} + +const upToDateDec2000: Notification = { + id: 'upToDateDec2000', + message: 'message', + type: 'info', + frequency: 'always', + ownerChannel: 'channel', + maxDate: '2000-12-31', +} + +const onlyForDevCommand: Notification = { + id: 'onlyForDevCommand', + message: 'message', + type: 'info', + frequency: 'always', + ownerChannel: 'channel', + commands: ['app:dev'], +} + +const onlyForThemeSurface: Notification = { + id: 'onlyForThemeSurface', + message: 'message', + type: 'info', + frequency: 'always', + ownerChannel: 'channel', + surface: 'theme', +} + +const unknownSurface: Notification = { + id: 'unknownSurface', + message: 'message', + type: 'info', + frequency: 'always', + ownerChannel: 'channel', + surface: 'unknown', +} + +const extensionSurface: Notification = { + id: 'extensionSurface', + message: 'message', + type: 'info', + frequency: 'always', + ownerChannel: 'channel', + surface: 'ui-extension', +} + +const showOnce: Notification = { + id: 'showOnce', + message: 'message', + type: 'info', + frequency: 'once', + ownerChannel: 'channel', +} + +const showOnceADay: Notification = { + id: 'showOnceADay', + message: 'message', + type: 'info', + frequency: 'once_a_day', + ownerChannel: 'channel', +} + +const showOnceAWeek: Notification = { + id: 'showOnceAWeek', + message: 'message', + type: 'info', + frequency: 'once_a_week', + ownerChannel: 'channel', +} + +const showAlways: Notification = { + id: 'showAlways', + message: 'message', + type: 'info', + frequency: 'always', + ownerChannel: 'channel', +} + +const infoNotification: Notification = { + id: 'infoNotification', + message: 'message', + type: 'info', + frequency: 'always', + ownerChannel: 'channel', +} + +const errorNotification: Notification = { + id: 'errorNotification', + message: 'message', + type: 'error', + frequency: 'always', + ownerChannel: 'channel', +} + +const warningNotification: Notification = { + id: 'warningNotification', + message: 'message', + type: 'warning', + frequency: 'always', + ownerChannel: 'channel', +} + +const defaultInput = [ + betweenVersins1and2, + betweenDatesIn2000, + fromVersion1, + upToVersion2, + fromDateJan2000, + upToDateDec2000, + onlyForDevCommand, + onlyForThemeSurface, + unknownSurface, + extensionSurface, +] + +/** + * Represents a test case + * @param input - the initial notifications received from remote + * @param comamndId - The current command being executed + * @param veresion - The current version of the CLI + * @param date - The current date for the user + * @param output - The expected filtered notifications + */ +interface TestCase { + name: string + input: Notification[] + commandId: string + version: string + date: string + surfaces?: string[] + output: Notification[] +} + +const testCases: TestCase[] = [ + { + name: 'Only for app:info command, excludes notifications for explicit commands', + input: defaultInput, + commandId: 'app:info', + version: '1.0.0', + date: '2000-02-01', + output: [betweenVersins1and2, betweenDatesIn2000, fromVersion1, upToVersion2, fromDateJan2000, upToDateDec2000], + }, + { + name: 'Notifications for version 2.1.0', + input: defaultInput, + commandId: 'app:info', + version: '2.1.0', + date: '2000-02-01', + output: [betweenDatesIn2000, fromVersion1, fromDateJan2000, upToDateDec2000], + }, + { + name: 'Notifications for year 9999', + input: defaultInput, + commandId: 'app:info', + version: '1.0.0', + date: '9999-02-01', + output: [betweenVersins1and2, fromVersion1, upToVersion2, fromDateJan2000], + }, + { + name: 'Notifications for version 1.5, in year 1990', + input: defaultInput, + commandId: 'app:info', + version: '1.5.0', + date: '1990-02-01', + output: [betweenVersins1and2, fromVersion1, upToVersion2, upToDateDec2000], + }, + { + name: 'Notifications for version 2.1, and year 2024 and dev command', + input: defaultInput, + commandId: 'app:dev', + version: '2.1.0', + date: '2024-02-01', + output: [fromVersion1, fromDateJan2000, onlyForDevCommand], + }, + { + name: 'Notifications for theme surface', + input: defaultInput, + commandId: 'theme:dev', + version: '2.1.0', + date: '2024-02-01', + output: [fromVersion1, fromDateJan2000, onlyForThemeSurface], + }, + { + name: 'Notifications for unknown surface is never shown', + input: defaultInput, + commandId: 'version', + version: '2.1.0', + date: '2024-02-01', + output: [fromVersion1, fromDateJan2000], + }, + { + name: 'Notifications for extension type surface is shown', + input: defaultInput, + commandId: 'version', + version: '2.1.0', + date: '2024-02-01', + surfaces: ['ui-extension', 'function'], + output: [extensionSurface], + }, +] + +afterEach(() => { + // Restore Date mock + vi.useRealTimers() +}) + +describe('notifications-system filter notifications', () => { + test.each(testCases)('Filter for %name', ({input, commandId, version, date, surfaces, output}) => { + // When + const result = filterNotifications(input, commandId, surfaces, new Date(date), version) + + // Then + expect(result).toEqual(output) + }) + + test('Filter for frequency with always', async () => { + // Given + const current = new Date('2020-01-15T00:00:00.000Z') + const yesterday = new Date('2020-01-14T08:00:00.000Z') + vi.setSystemTime(current) + vi.mocked(cacheRetrieve).mockReturnValue({value: yesterday.getTime().toString(), timestamp: 0}) + + // When + const result = filterNotifications([showAlways], 'version') + + // Then + expect(result).toEqual([showAlways]) + }) + + test('Filter for frequency with once', async () => { + // Given + const current = new Date('2020-01-15T00:00:00.000Z') + vi.setSystemTime(current) + vi.mocked(cacheRetrieve).mockReturnValueOnce(undefined) + vi.mocked(cacheRetrieve).mockReturnValueOnce({value: current.getTime().toString(), timestamp: 0}) + + // When/Then + const result = filterNotifications([showOnce], 'version') + expect(result).toEqual([showOnce]) + const result2 = filterNotifications([showOnce], 'version') + expect(result2).toEqual([]) + }) + + test('Filter for frequency with once_a_day', async () => { + // Given + const current = new Date('2020-01-15T08:00:00.000Z') + const yesterday = new Date('2020-01-14T00:00:00.000Z') + vi.setSystemTime(current) + vi.mocked(cacheRetrieve).mockReturnValueOnce({value: yesterday.getTime().toString(), timestamp: 0}) + vi.mocked(cacheRetrieve).mockReturnValueOnce({value: current.getTime().toString(), timestamp: 0}) + + // When/Then + const result = filterNotifications([showOnceADay], 'version') + expect(result).toEqual([showOnceADay]) + const result2 = filterNotifications([showOnceADay], 'version') + expect(result2).toEqual([]) + }) + + test('Filter for frequency with once_a_week', async () => { + // Given + const current = new Date('2020-01-15T08:00:00.000Z') + const yesterday = new Date('2020-01-14T08:00:00.000Z') + const lastWeek = new Date('2020-01-03T00:00:00.000Z') + vi.setSystemTime(current) + vi.mocked(cacheRetrieve).mockReturnValueOnce({value: lastWeek.getTime().toString(), timestamp: 0}) + vi.mocked(cacheRetrieve).mockReturnValueOnce({value: yesterday.getTime().toString(), timestamp: 0}) + + // When/Then + const result = filterNotifications([showOnceAWeek], 'version') + expect(result).toEqual([showOnceAWeek]) + const result2 = filterNotifications([showOnceAWeek], 'version') + expect(result2).toEqual([]) + }) +}) + +describe('notifications-system', () => { + test('an info notification triggers a renderInfo call', async () => { + // Given + const notifications = [infoNotification] + vi.mocked(cacheRetrieveOrRepopulate).mockResolvedValue(JSON.stringify({notifications})) + + // When + await showNotificationsIfNeeded() + + // Then + expect(renderInfo).toHaveBeenCalled() + }) + + test('a warning notification triggers a renderWarning call', async () => { + // Given + const notifications = [warningNotification] + vi.mocked(cacheRetrieveOrRepopulate).mockResolvedValue(JSON.stringify({notifications})) + + // When + await showNotificationsIfNeeded() + + // Then + expect(renderWarning).toHaveBeenCalled() + }) + + test('an error notification triggers a renderError call and throws an error', async () => { + // Given + const notifications = [errorNotification] + vi.mocked(cacheRetrieveOrRepopulate).mockResolvedValue(JSON.stringify({notifications})) + + // When + await expect(showNotificationsIfNeeded()).rejects.toThrowError() + + // Then + expect(renderError).toHaveBeenCalled() + }) +}) diff --git a/packages/cli-kit/src/public/node/notifications-system.ts b/packages/cli-kit/src/public/node/notifications-system.ts new file mode 100644 index 0000000000..dfe3b016bb --- /dev/null +++ b/packages/cli-kit/src/public/node/notifications-system.ts @@ -0,0 +1,264 @@ +import {versionSatisfies} from './node-package-manager.js' +import {renderError, renderInfo, renderWarning} from './ui.js' +import {getCurrentCommandId} from './global-context.js' +import {fileExists, readFile} from './fs.js' +import {outputDebug} from './output.js' +import {zod} from './schema.js' +import {AbortSilentError} from './error.js' +import {CLI_KIT_VERSION} from '../common/version.js' +import { + NotificationKey, + NotificationsKey, + cacheRetrieve, + cacheRetrieveOrRepopulate, + cacheStore, +} from '../../private/node/conf-store.js' +import {fetch} from '@shopify/cli-kit/node/http' + +const URL = 'https://raw.githubusercontent.com/Shopify/cli/main/notifications.json' +const CACHE_DURATION_IN_MS = 3600 * 1000 + +function url(): string { + return process.env.SHOPIFY_CLI_NOTIFICATIONS_URL ?? URL +} + +const NotificationSchema = zod.object({ + id: zod.string(), + message: zod.string(), + type: zod.enum(['info', 'warning', 'error']), + frequency: zod.enum(['always', 'once', 'once_a_day', 'once_a_week']), + ownerChannel: zod.string(), + cta: zod + .object({ + label: zod.string(), + url: zod.string().url(), + }) + .optional(), + title: zod.string().optional(), + minVersion: zod.string().optional(), + maxVersion: zod.string().optional(), + minDate: zod.string().optional(), + maxDate: zod.string().optional(), + commands: zod.array(zod.string()).optional(), + surface: zod.string().optional(), +}) +export type Notification = zod.infer + +const NotificationsSchema = zod.object({notifications: zod.array(NotificationSchema)}) +export type Notifications = zod.infer + +/** + * Shows notifications to the user if they meet the criteria specified in the notifications.json file. + * + * @param currentSurfaces - The surfaces present in the current project (usually for app extensions). + * @returns - A promise that resolves when the notifications have been shown. + */ +export async function showNotificationsIfNeeded(currentSurfaces?: string[]): Promise { + try { + const notifications = await getNotifications() + const commandId = getCurrentCommandId() + const notificationsToShow = filterNotifications(notifications.notifications, commandId, currentSurfaces) + outputDebug(`Notifications to show: ${notificationsToShow.length}`) + await renderNotifications(notificationsToShow) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.message === 'abort') throw new AbortSilentError() + const errorMessage = `Error retrieving notifications: ${error.message}` + outputDebug(errorMessage) + // This is very prone to becoming a circular dependency, so we import it dynamically + const {sendErrorToBugsnag} = await import('./error-handler.js') + await sendErrorToBugsnag(errorMessage, 'unexpected_error') + } +} + +/** + * Renders the first 2 notifications to the user. + * + * @param notifications - The notifications to render. + */ +async function renderNotifications(notifications: Notification[]) { + notifications.slice(0, 2).forEach((notification) => { + const content = { + headline: notification.title, + body: notification.message.replaceAll('\\n', '\n'), + link: notification.cta, + } + switch (notification.type) { + case 'info': { + renderInfo(content) + break + } + case 'warning': { + renderWarning(content) + break + } + case 'error': { + renderError(content) + throw new Error('abort') + } + } + cacheStore(`notification-${notification.id}`, new Date().getTime().toString()) + }) +} + +/** + * Get notifications list from cache (refreshed every hour) or fetch it if not present. + * + * @returns A Notifications object. + */ +export async function getNotifications(): Promise { + const cacheKey: NotificationsKey = `notifications-${url()}` + const rawNotifications = await cacheRetrieveOrRepopulate(cacheKey, fetchNotifications, CACHE_DURATION_IN_MS) + const notifications: object = JSON.parse(rawNotifications) + return NotificationsSchema.parse(notifications) +} + +/** + * Fetch notifications from GitHub. + */ +async function fetchNotifications(): Promise { + outputDebug(`No cached notifications found. Fetching them...`) + const response = await fetch(url(), {signal: AbortSignal.timeout(3 * 1000)}) + if (response.status !== 200) throw new Error(`Failed to fetch notifications: ${response.statusText}`) + return response.text() as unknown as string +} + +/** + * Filters notifications based on the version of the CLI. + * + * @param notifications - The notifications to filter. + * @param commandId - The command ID to filter by. + * @param currentSurfaces - The surfaces present in the current project (usually for app extensions). + * @param today - The current date. + * @param currentVersion - The current version of the CLI. + * @returns - The filtered notifications. + */ +export function filterNotifications( + notifications: Notification[], + commandId: string, + currentSurfaces?: string[], + today: Date = new Date(new Date().setUTCHours(0, 0, 0, 0)), + currentVersion: string = CLI_KIT_VERSION, +): Notification[] { + return notifications + .filter((notification) => filterByVersion(notification, currentVersion)) + .filter((notifications) => filterByDate(notifications, today)) + .filter((notification) => filterByCommand(notification, commandId)) + .filter((notification) => filterBySurface(notification, commandId, currentSurfaces)) + .filter((notification) => filterByFrequency(notification)) +} + +/** + * Filters notifications based on the version of the CLI. + * + * @param notification - The notification to filter. + * @param currentVersion - The current version of the CLI. + */ +function filterByVersion(notification: Notification, currentVersion: string) { + const minVersion = !notification.minVersion || versionSatisfies(currentVersion, `>=${notification.minVersion}`) + const maxVersion = !notification.maxVersion || versionSatisfies(currentVersion, `<=${notification.maxVersion}`) + return minVersion && maxVersion +} + +/** + * Filters notifications based on the date. + * + * @param notification - The notification to filter. + * @param today - The current date. + */ +function filterByDate(notification: Notification, today: Date) { + const minDate = !notification.minDate || new Date(notification.minDate) <= today + const maxDate = !notification.maxDate || new Date(notification.maxDate) >= today + return minDate && maxDate +} + +/** + * Filters notifications based on the command ID. + * + * @param notification - The notification to filter. + * @param commandId - The command ID to filter by. + * @returns - A boolean indicating whether the notification should be shown. + */ +function filterByCommand(notification: Notification, commandId: string) { + if (commandId === '') return true + return !notification.commands || notification.commands.includes(commandId) +} + +/** + * Filters notifications based on the surface. + * + * @param notification - The notification to filter. + * @param commandId - The command id. + * @param surfacesFromContext - The surfaces present in the current project (usually for app extensions). + * @returns - A boolean indicating whether the notification should be shown. + */ +function filterBySurface(notification: Notification, commandId: string, surfacesFromContext?: string[]) { + const surfaceFromCommand = commandId.split(':')[0] ?? 'all' + const notificationSurface = notification.surface ?? 'all' + + if (surfacesFromContext) return surfacesFromContext.includes(notificationSurface) + + return notificationSurface === surfaceFromCommand || notificationSurface === 'all' +} + +/** + * Filters notifications based on the frequency. + * + * @param notification - The notification to filter. + * @returns - A boolean indicating whether the notification should be shown. + */ +function filterByFrequency(notification: Notification): boolean { + if (!notification.frequency) return true + const cacheKey: NotificationKey = `notification-${notification.id}` + const lastShown = cacheRetrieve(cacheKey)?.value as unknown as string + if (!lastShown) return true + + switch (notification.frequency) { + case 'always': { + return true + } + case 'once': { + return false + } + case 'once_a_day': { + return new Date().getTime() - Number(lastShown) > 24 * 3600 * 1000 + } + case 'once_a_week': { + return new Date().getTime() - Number(lastShown) > 7 * 24 * 3600 * 1000 + } + } +} + +/** + * Returns a string with the filters from a notification, one by line. + * + * @param notification - The notification to get the filters from. + * @returns A string with human-readable filters from the notification. + */ +export function stringifyFilters(notification: Notification): string { + const filters = [] + if (notification.minDate) filters.push(`from ${notification.minDate}`) + if (notification.maxDate) filters.push(`to ${notification.maxDate}`) + if (notification.minVersion) filters.push(`from v${notification.minVersion}`) + if (notification.maxVersion) filters.push(`to v${notification.maxVersion}`) + if (notification.frequency === 'once') filters.push('show only once') + if (notification.frequency === 'once_a_day') filters.push('show once a day') + if (notification.frequency === 'once_a_week') filters.push('show once a week') + if (notification.surface) filters.push(`surface = ${notification.surface}`) + if (notification.commands) filters.push(`commands = ${notification.commands.join(', ')}`) + return filters.join('\n') +} + +/** + * Reads the notifications from the local file. + * + * @returns A Notifications object. + */ +export async function getLocalNotifications(): Promise { + const filePath = './notifications.json' + if (!(await fileExists(filePath))) return {notifications: []} + + const rawNotifications = await readFile(filePath) + const notifications: object = JSON.parse(rawNotifications) + return NotificationsSchema.parse(notifications) +} diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 5d47583ba1..3167def79b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2061,6 +2061,25 @@ "pluginType": "core", "strict": true }, + "cache:clear": { + "aliases": [ + ], + "args": { + }, + "description": "Clear the CLI cache, used to store some API responses and handle notifications status", + "enableJsonFlag": false, + "flags": { + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "cache:clear", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true + }, "commands": { "aliases": [ ], @@ -4233,6 +4252,44 @@ "pluginType": "core", "strict": true }, + "notifications:generate": { + "aliases": [ + ], + "args": { + }, + "description": "Generate a new notification for the the CLI.", + "enableJsonFlag": false, + "flags": { + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "notifications:generate", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true + }, + "notifications:list": { + "aliases": [ + ], + "args": { + }, + "description": "List current notifications configured for the CLI.", + "enableJsonFlag": false, + "flags": { + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "notifications:list", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true + }, "plugins": { "aliases": [ ], diff --git a/packages/cli/src/cli/commands/cache/clear.ts b/packages/cli/src/cli/commands/cache/clear.ts new file mode 100644 index 0000000000..ea901b071c --- /dev/null +++ b/packages/cli/src/cli/commands/cache/clear.ts @@ -0,0 +1,11 @@ +import Command from '@shopify/cli-kit/node/base-command' +import {clearCache} from '@shopify/cli-kit/node/cli' + +export default class ClearCache extends Command { + static description = 'Clear the CLI cache, used to store some API responses and handle notifications status' + static hidden = true + + async run(): Promise { + clearCache() + } +} diff --git a/packages/cli/src/cli/commands/notifications/generate.ts b/packages/cli/src/cli/commands/notifications/generate.ts new file mode 100644 index 0000000000..cbed8e0ac1 --- /dev/null +++ b/packages/cli/src/cli/commands/notifications/generate.ts @@ -0,0 +1,12 @@ +import {generate} from '../../services/commands/notifications.js' +import Command from '@shopify/cli-kit/node/base-command' + +export default class Generate extends Command { + static description = 'Generate a new notification for the the CLI.' + + static hidden = true + + async run(): Promise { + await generate() + } +} diff --git a/packages/cli/src/cli/commands/notifications/list.ts b/packages/cli/src/cli/commands/notifications/list.ts new file mode 100644 index 0000000000..4d251ef63b --- /dev/null +++ b/packages/cli/src/cli/commands/notifications/list.ts @@ -0,0 +1,11 @@ +import {list} from '../../services/commands/notifications.js' +import Command from '@shopify/cli-kit/node/base-command' + +export default class List extends Command { + static description = 'List current notifications configured for the CLI.' + static hidden = true + + async run(): Promise { + await list() + } +} diff --git a/packages/cli/src/cli/services/commands/notifications.ts b/packages/cli/src/cli/services/commands/notifications.ts new file mode 100644 index 0000000000..2141710825 --- /dev/null +++ b/packages/cli/src/cli/services/commands/notifications.ts @@ -0,0 +1,118 @@ +import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {randomUUID} from '@shopify/cli-kit/node/crypto' +import {writeFile} from '@shopify/cli-kit/node/fs' +import { + Notifications, + getLocalNotifications, + Notification, + stringifyFilters, +} from '@shopify/cli-kit/node/notifications-system' +import {outputInfo} from '@shopify/cli-kit/node/output' +import {renderSelectPrompt, renderTextPrompt, renderSuccess, renderTable, TableColumn} from '@shopify/cli-kit/node/ui' + +export async function generate() { + const today = new Date() + const formattedToday = `${today.getFullYear()}-${(today.getMonth() + 1).toString().padStart(2, '0')}-${today + .getDate() + .toString() + .padStart(2, '0')}` + const id = randomUUID() + + const type: 'info' | 'warning' | 'error' = await renderSelectPrompt({ + message: 'Type of message?', + choices: [ + {label: 'Info', value: 'info'}, + {label: 'Warning', value: 'warning'}, + {label: 'Error', value: 'error'}, + ], + }) + const title = await renderTextPrompt({ + message: 'Title', + }) + const message = await renderTextPrompt({ + message: 'Message', + }) + const frequency: 'always' | 'once' | 'once_a_day' | 'once_a_week' = await renderSelectPrompt({ + message: 'Frequency', + choices: [ + {label: 'Only once', value: 'once'}, + {label: 'Once a week', value: 'once_a_week'}, + {label: 'Once a day', value: 'once_a_day'}, + {label: 'Always', value: 'always'}, + ], + }) + const minVersion = await renderTextPrompt({ + message: 'Minimum CLI version (optional)', + initialAnswer: CLI_KIT_VERSION, + allowEmpty: true, + }) + const maxVersion = await renderTextPrompt({ + message: 'Maximum CLI version (optional)', + initialAnswer: CLI_KIT_VERSION, + allowEmpty: true, + }) + const minDate = await renderTextPrompt({ + message: 'Minimum date in YYYY-MM-DD format (optional)', + initialAnswer: formattedToday, + allowEmpty: true, + }) + const maxDate = await renderTextPrompt({ + message: 'Maximum date in YYYY-MM-DD format (optional)', + initialAnswer: formattedToday, + allowEmpty: true, + }) + const surface = await renderTextPrompt({ + message: 'Surface. E.g.: app, theme, hydrogen, theme_app_extension... (optional)', + allowEmpty: true, + }) + const commands = await renderTextPrompt({ + message: 'Comma separated list of commands. E.g.: app:generate:extension (optional)', + allowEmpty: true, + }) + const ownerChannel = await renderTextPrompt({ + message: 'Slack channel of the team who will own this notification', + }) + + const notifications: Notifications = await getLocalNotifications() + const notification: Notification = { + id, + type, + title, + frequency, + message, + minVersion: minVersion.length === 0 ? undefined : minVersion, + maxVersion: maxVersion.length === 0 ? undefined : maxVersion, + minDate: minDate.length === 0 ? undefined : minDate, + maxDate: maxDate.length === 0 ? undefined : maxDate, + surface: surface.length === 0 ? undefined : surface, + commands: commands.length === 0 ? undefined : commands.split(',').map((command) => command.trim()), + ownerChannel, + } + notifications.notifications.push(notification) + await writeFile('./notifications.json', JSON.stringify(notifications)) + + renderSuccess({headline: 'notifications.json file updated successfully.'}) +} + +export async function list() { + const notifications: Notifications = await getLocalNotifications() + + const columns: TableColumn<{type: string; title: string; message: string; filters: string}> = { + type: {header: 'Type', color: 'dim'}, + title: {header: 'Title', color: 'dim'}, + message: {header: 'Message', color: 'dim'}, + filters: {header: 'Filters', color: 'dim'}, + } + + const rows = notifications.notifications.map((notification: Notification) => { + return { + type: notification.type, + title: notification.title || '', + message: notification.message, + filters: stringifyFilters(notification), + } + }) + + renderTable({rows, columns}) + outputInfo('\n') +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fd6c2fe286..5dab8c5f2c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -14,6 +14,9 @@ import KitchenSinkStatic from './cli/commands/kitchen-sink/static.js' import KitchenSink from './cli/commands/kitchen-sink/index.js' import DocsGenerate from './cli/commands/docs/generate.js' import HelpCommand from './cli/commands/help.js' +import List from './cli/commands/notifications/list.js' +import Generate from './cli/commands/notifications/generate.js' +import ClearCache from './cli/commands/cache/clear.js' import ThemeCommands from '@shopify/theme' import {COMMANDS as HydrogenCommands, HOOKS as HydrogenHooks} from '@shopify/cli-hydrogen' import {commands as AppCommands} from '@shopify/app' @@ -146,6 +149,9 @@ export const COMMANDS: any = { 'kitchen-sink:prompts': KitchenSinkPrompts, 'kitchen-sink:static': KitchenSinkStatic, 'docs:generate': DocsGenerate, + 'notifications:list': List, + 'notifications:generate': Generate, + 'cache:clear': ClearCache, } export default runShopifyCLI diff --git a/packages/eslint-plugin-cli/rules/specific-imports-in-bootstrap-code.js b/packages/eslint-plugin-cli/rules/specific-imports-in-bootstrap-code.js index 73bc5aeb57..3efed068f5 100644 --- a/packages/eslint-plugin-cli/rules/specific-imports-in-bootstrap-code.js +++ b/packages/eslint-plugin-cli/rules/specific-imports-in-bootstrap-code.js @@ -16,7 +16,7 @@ function checkImport(allowList, context, node) { const gotMatch = allowList.includes(importTarget) if (!gotMatch) { - context.report(node, `Forbidden import source "${importTarget}", update allow list if required`) + context.report(node, `Forbidden import source "${importTarget}", update allow list if required in the package.json`) } }