UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

303 lines • 12 kB
import { versionSatisfies } from './node-package-manager.js'; import { renderError, renderInfo, renderWarning } from './ui.js'; import { getCurrentCommandId } from './global-context.js'; import { outputDebug } from './output.js'; import { zod } from './schema.js'; import { AbortSilentError } from './error.js'; import { isTruthy } from './context/utilities.js'; import { exec } from './system.js'; import { jsonOutputEnabled } from './environment.js'; import { fetch } from './http.js'; import { CLI_KIT_VERSION } from '../common/version.js'; import { cacheRetrieve, cacheStore } from '../../private/node/conf-store.js'; const URL = 'https://cdn.shopify.com/static/cli/notifications.json'; const EMPTY_CACHE_MESSAGE = 'Cache is empty'; const COMMANDS_TO_SKIP = [ 'notifications:list', 'notifications:generate', 'init', 'app:init', 'theme:init', 'hydrogen:init', 'cache:clear', ]; function url() { 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(), }); const NotificationsSchema = zod.object({ notifications: zod.array(NotificationSchema) }); /** * 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). * @param environment - Process environment variables. * @returns - A promise that resolves when the notifications have been shown. */ export async function showNotificationsIfNeeded(currentSurfaces, environment = process.env) { try { const commandId = getCurrentCommandId(); if (skipNotifications(commandId, environment) || jsonOutputEnabled(environment)) return; const notifications = await getNotifications(); 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) { if (error.message === EMPTY_CACHE_MESSAGE) { outputDebug('Notifications to show: 0 (Cache is empty)'); return; } if (error.message === 'abort') throw new AbortSilentError(); const errorMessage = `Error showing 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'); } } function skipNotifications(currentCommand, environment = process.env) { return (isTruthy(environment.CI) || isTruthy(environment.SHOPIFY_UNIT_TEST) || COMMANDS_TO_SKIP.includes(currentCommand)); } /** * Renders the first 2 notifications to the user. * * @param notifications - The notifications to render. */ async function renderNotifications(notifications) { notifications.slice(0, 2).forEach((notification) => { const content = { headline: notification.title, body: notification.message.replace(/\\n/g, '\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, that is updated in the background from bin/fetch-notifications.json. * * @returns A Notifications object. */ export async function getNotifications() { const cacheKey = `notifications-${url()}`; const rawNotifications = cacheRetrieve(cacheKey)?.value; if (!rawNotifications) throw new Error(EMPTY_CACHE_MESSAGE); const notifications = JSON.parse(rawNotifications); return NotificationsSchema.parse(notifications); } /** * Fetch notifications from the CDN and chache them. * * @returns A string with the notifications. */ export async function fetchNotifications() { outputDebug(`Fetching notifications...`); const response = await fetch(url(), undefined, { useNetworkLevelRetry: false, useAbortSignal: true, timeoutMs: 3 * 1000, }); if (response.status !== 200) throw new Error(`Failed to fetch notifications: ${response.statusText}`); const rawNotifications = await response.text(); const notifications = JSON.parse(rawNotifications); const result = NotificationsSchema.parse(notifications); await cacheNotifications(rawNotifications); return result; } /** * Store the notifications in the cache. * * @param notifications - String with the notifications to cache. * @returns A Notifications object. */ async function cacheNotifications(notifications) { cacheStore(`notifications-${url()}`, notifications); outputDebug(`Notifications from ${url()} stored in the cache`); } /** * Fetch notifications in background as a detached process. * * @param currentCommand - The current Shopify command being run. * @param argv - The arguments passed to the current process. * @param environment - Process environment variables. */ export function fetchNotificationsInBackground(currentCommand, argv = process.argv, environment = process.env) { if (skipNotifications(currentCommand, environment)) return; if (!argv[0] || !argv[1]) return; // Run the Shopify command the same way as the current execution const nodeBinary = argv[0]; const shopifyBinary = argv[1]; const args = [shopifyBinary, 'notifications', 'list', '--ignore-errors']; // eslint-disable-next-line no-void void exec(nodeBinary, args, { background: true, env: { ...process.env, SHOPIFY_CLI_NO_ANALYTICS: '1' }, externalErrorHandler: async (error) => { outputDebug(`Failed to fetch notifications in background: ${error.message}`); }, }); } /** * 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, commandId, currentSurfaces, today = new Date(new Date().setUTCHours(0, 0, 0, 0)), currentVersion = CLI_KIT_VERSION) { 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, currentVersion) { 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, today) { 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, commandId) { 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, commandId, surfacesFromContext) { 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) { if (!notification.frequency) return true; const cacheKey = `notification-${notification.id}`; const lastShown = cacheRetrieve(cacheKey)?.value; 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) { 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'); } //# sourceMappingURL=notifications-system.js.map