diff --git a/packages/central-server/src/apiV2/deleteAccount.js b/packages/central-server/src/apiV2/deleteAccount.js index 6b6b14cb1f..beec13a516 100644 --- a/packages/central-server/src/apiV2/deleteAccount.js +++ b/packages/central-server/src/apiV2/deleteAccount.js @@ -4,23 +4,24 @@ */ import { requireEnv, respond } from '@tupaia/utils'; import { sendEmail } from '@tupaia/server-utils'; -import { getUserInfoInString } from './utilities'; -const sendRequest = userInfo => { +const sendRequest = user => { const TUPAIA_ADMIN_EMAIL_ADDRESS = requireEnv('TUPAIA_ADMIN_EMAIL_ADDRESS'); - const emailText = `${userInfo} has requested to delete their account`; return sendEmail(TUPAIA_ADMIN_EMAIL_ADDRESS, { subject: 'Tupaia Account Deletion Request', - text: emailText, + templateName: 'deleteAccount', + templateContext: { + user, + }, }); }; export const deleteAccount = async (req, res) => { const { userId: requestUserId, params, models } = req; const userId = requestUserId || params.userId; - const userInfo = await getUserInfoInString(userId, models); - await sendRequest(userInfo); + const user = await models.user.findById(userId); + await sendRequest(user); respond(res, { message: 'Account deletion requested.' }, 200); }; diff --git a/packages/central-server/src/apiV2/import/importSurveyResponses/constructImportEmail.js b/packages/central-server/src/apiV2/import/importSurveyResponses/constructImportEmail.js index 1ce4d17d19..65f5dd796f 100644 --- a/packages/central-server/src/apiV2/import/importSurveyResponses/constructImportEmail.js +++ b/packages/central-server/src/apiV2/import/importSurveyResponses/constructImportEmail.js @@ -13,24 +13,37 @@ Any responses not listed here have been successfully imported, and can be remove return message; }; -const constructMessage = responseBody => { +const constructTemplateContextMessage = responseBody => { const { error, failures = [] } = responseBody; // global error, whole import has failed if (error) { - return `Unfortunately, your survey response import failed. + return { + message: `Unfortunately, your survey response import failed. -${error}`; +${error}`, + + title: 'Import Failed', + }; } // at least one response failed, but import finished processing if (failures.length > 0) { - return constructFailuresMessage(failures); + return { + message: constructFailuresMessage(failures), + title: 'Import Finished with Failures', + }; } - return 'Your survey responses have been successfully imported.'; + return { + message: 'Your survey responses have been successfully imported.', + title: 'Import Successful', + }; }; export const constructImportEmail = responseBody => { - return { subject: 'Tupaia Survey Response Import', message: constructMessage(responseBody) }; + return { + subject: 'Tupaia Survey Response Import', + templateContext: constructTemplateContextMessage(responseBody), + }; }; diff --git a/packages/central-server/src/apiV2/requestCountryAccess.js b/packages/central-server/src/apiV2/requestCountryAccess.js index d3bf7c158e..35fe0f88d5 100644 --- a/packages/central-server/src/apiV2/requestCountryAccess.js +++ b/packages/central-server/src/apiV2/requestCountryAccess.js @@ -6,7 +6,6 @@ import { requireEnv, respond, UnauthenticatedError, ValidationError } from '@tupaia/utils'; import { sendEmail } from '@tupaia/server-utils'; import { getTokenClaimsFromBearerAuth } from '@tupaia/auth'; -import { getUserInfoInString } from './utilities'; const checkUserPermission = (req, userId) => { const authHeader = req.headers.authorization; @@ -17,26 +16,28 @@ const checkUserPermission = (req, userId) => { } }; -const sendRequest = (userInfo, countryNames, message, project) => { +const sendRequest = async (userId, models, countries, message, project) => { + const user = await models.user.findById(userId); + const TUPAIA_ADMIN_EMAIL_ADDRESS = requireEnv('TUPAIA_ADMIN_EMAIL_ADDRESS'); - const emailText = ` -${userInfo} has requested access to countries: -${countryNames.map(n => ` - ${n}`).join('\n')} -${ - project - ? ` -For the project ${project.code} (linked to permission groups: ${project.permission_groups.join( - ', ', - )}) - ` - : '' -} -With the message: '${message}' -`; return sendEmail(TUPAIA_ADMIN_EMAIL_ADDRESS, { subject: 'Tupaia Country Access Request', - text: emailText, + templateName: 'requestCountryAccess', + templateContext: { + title: 'You have a new country request!', + cta: { + url: `${process.env.ADMIN_PANEL_URL}/users/access-requests/${userId}`, + text: 'Approve or deny request', + }, + countries, + message, + project: { + code: project.code, + permissionGroups: project.permission_groups.join(', '), + }, + user, + }, }); }; @@ -79,13 +80,12 @@ export const requestCountryAccess = async (req, res) => { } catch (error) { throw new UnauthenticatedError(error.message); } - const userInfo = await getUserInfoInString(userId, models); const project = projectCode && (await models.project.findOne({ code: projectCode })); await createAccessRequests(models, userId, entities, message, project); const countryNames = entities.map(e => e.name); - await sendRequest(userInfo, countryNames, message, project); + await sendRequest(userId, models, countryNames, message, project); respond(res, { message: 'Country access requested' }, 200); }; diff --git a/packages/central-server/src/apiV2/requestPasswordReset.js b/packages/central-server/src/apiV2/requestPasswordReset.js index 2366ef1399..52c805e2e9 100644 --- a/packages/central-server/src/apiV2/requestPasswordReset.js +++ b/packages/central-server/src/apiV2/requestPasswordReset.js @@ -33,17 +33,18 @@ export const requestPasswordReset = async (req, res) => { resetPasswordUrl || process.env.TUPAIA_FRONT_END_URL }/reset-password?passwordResetToken={token}`; const resetUrl = passwordResetUrl.replace('{token}', token); - const emailText = `Dear ${user.fullName}, -You are receiving this email because someone requested a password reset for -this user account on Tupaia.org. To reset your password follow the link below. - -${resetUrl} - -If you believe this email was sent to you in error, please contact us immediately at -admin@tupaia.org.`; - - sendEmail(user.email, { subject: 'Password reset on Tupaia.org', text: emailText }); + sendEmail(user.email, { + subject: 'Password reset on Tupaia.org', + templateName: 'passwordReset', + templateContext: { + userName: user.fullName, + cta: { + text: 'Reset your password', + url: resetUrl, + }, + }, + }); respond(res, { success: true, diff --git a/packages/central-server/src/apiV2/utilities/emailVerification.js b/packages/central-server/src/apiV2/utilities/emailVerification.js index 96080eba74..3f499712a7 100644 --- a/packages/central-server/src/apiV2/utilities/emailVerification.js +++ b/packages/central-server/src/apiV2/utilities/emailVerification.js @@ -10,35 +10,23 @@ import { requireEnv } from '@tupaia/utils'; const EMAILS = { tupaia: { subject: 'Tupaia email verification', - body: (token, url) => - 'Thank you for registering with tupaia.org.\n' + - 'Please click on the following link to register your email address.\n\n' + - `${url}/verify-email?verifyEmailToken=${token}\n\n` + - 'If you believe this email was sent to you in error, please contact us immediately at admin@tupaia.org.\n', + platformName: 'tupaia.org', }, datatrak: { subject: 'Tupaia Datatrak email verification', - body: (token, url) => - 'Thank you for registering with datatrak.tupaia.org.\n' + - 'Please click on the following link to register your email address.\n\n' + - `${url}/verify-email?verifyEmailToken=${token}\n\n` + - 'If you believe this email was sent to you in error, please contact us immediately at admin@tupaia.org.\n', + platformName: 'datatrak.tupaia.org', }, lesmis: { subject: 'LESMIS email verification', - body: (token, url) => - 'Thank you for registering with lesmis.la.\n' + - 'Please click on the following link to register your email address.\n\n' + - `${url}/en/verify-email?verifyEmailToken=${token}\n\n` + - 'If you believe this email was sent to you in error, please contact us immediately at admin@tupaia.org.\n', signOff: 'Best regards,\nThe LESMIS Team', + platformName: 'lesmis.la', }, }; export const sendEmailVerification = async user => { const token = encryptPassword(user.email + user.password_hash, user.password_salt); const platform = user.primary_platform ? user.primary_platform : 'tupaia'; - const { subject, body, signOff } = EMAILS[platform]; + const { subject, signOff, platformName } = EMAILS[platform]; const TUPAIA_FRONT_END_URL = requireEnv('TUPAIA_FRONT_END_URL'); const LESMIS_FRONT_END_URL = requireEnv('LESMIS_FRONT_END_URL'); const DATATRAK_FRONT_END_URL = requireEnv('DATATRAK_FRONT_END_URL'); @@ -46,10 +34,24 @@ export const sendEmailVerification = async user => { const url = { tupaia: TUPAIA_FRONT_END_URL, datatrak: DATATRAK_FRONT_END_URL, - lesmis: LESMIS_FRONT_END_URL, + lesmis: `${LESMIS_FRONT_END_URL}/en`, }[platform]; - return sendEmail(user.email, { subject, text: body(token, url), signOff }); + const fullUrl = `${url}/verify-email?verifyEmailToken=${token}`; + + return sendEmail(user.email, { + subject, + signOff, + templateName: 'verifyEmail', + templateContext: { + title: 'Verify your email address', + platform: platformName, + cta: { + text: 'Verify email', + url: fullUrl, + }, + }, + }); }; export const verifyEmailHelper = async (models, searchCondition, token) => { diff --git a/packages/central-server/src/database/models/UserEntityPermission.js b/packages/central-server/src/database/models/UserEntityPermission.js index 29f7fa60c3..541f3ff3b2 100644 --- a/packages/central-server/src/database/models/UserEntityPermission.js +++ b/packages/central-server/src/database/models/UserEntityPermission.js @@ -13,21 +13,13 @@ export class UserEntityPermissionModel extends CommonUserEntityPermissionModel { const EMAILS = { tupaia: { subject: 'Tupaia Permission Granted', - body: (userName, permissionGroupName, entityName) => - `Hi ${userName},\n\n` + - `This is just to let you know that you've been added to the ${permissionGroupName} access group for ${entityName}. ` + - 'This allows you to collect surveys through the Tupaia data collection app, and to see reports and map overlays on Tupaia.org.\n\n' + - "Please note that you'll need to log out and then log back in to get access to the new permissions.\n\n" + - 'Have fun exploring Tupaia, and feel free to get in touch if you have any questions.\n', + description: + 'This allows you to collect surveys through the Tupaia data collection app, and to see reports and map overlays on Tupaia.org.', }, lesmis: { subject: 'LESMIS Permission Granted', - body: (userName, permissionGroupName, entityName) => - `Hi ${userName},\n\n` + - `This is just to let you know that you've been added to the ${permissionGroupName} access group for ${entityName}. ` + - 'This allows you to see reports and map overlays on lesmis.la.\n\n' + - "Please note that you'll need to log out and then log back in to get access to the new permissions.\n\n" + - 'Feel free to get in touch if you have any questions.\n', + description: + 'This allows you to see reports and map overlays on lesmis.la.', signOff: 'Best regards,\nThe LESMIS Team', }, }; @@ -51,12 +43,19 @@ async function onUpsertSendPermissionGrantEmail( const permissionGroup = await models.permissionGroup.findById(newRecord.permission_group_id); const platform = user.primary_platform ? user.primary_platform : 'tupaia'; - const { subject, body, signOff } = EMAILS[platform]; + const { subject, description, signOff } = EMAILS[platform]; sendEmail(user.email, { subject, - text: body(user.first_name, permissionGroup.name, entity.name), signOff, + templateName: 'permissionGranted', + templateContext: { + title: 'Permission Granted', + description, + userName: user.first_name, + entityName: entity.name, + permissionGroupName: permissionGroup.name, + }, }); } diff --git a/packages/server-boilerplate/src/utils/emailAfterTimeout.ts b/packages/server-boilerplate/src/utils/emailAfterTimeout.ts index ec2eda5fed..b37f1f3e68 100644 --- a/packages/server-boilerplate/src/utils/emailAfterTimeout.ts +++ b/packages/server-boilerplate/src/utils/emailAfterTimeout.ts @@ -7,26 +7,39 @@ import { sendEmail } from '@tupaia/server-utils'; import { UserAccount } from '@tupaia/types'; import { respond } from '@tupaia/utils'; +type TemplateContext = { + title: string; + message: string; + cta?: { + text: string; + url: string; + }; +}; + type ConstructEmailFromResponseT = ( responseBody: any, req: any, ) => Promise<{ subject: string; - message: string; attachments?: { filename: string; content: Buffer }[]; + templateContext: TemplateContext; }>; const sendResponseAsEmail = ( user: UserAccount, subject: string, - message: string, + templateContext: TemplateContext, attachments?: { filename: string; content: Buffer }[], ) => { - const text = `Hi ${user.first_name}, - -${message} - `; - sendEmail(user.email, { subject, text, attachments }); + sendEmail(user.email, { + subject, + attachments, + templateName: 'emailAfterTimeout', + templateContext: { + ...templateContext, + userName: user.first_name, + }, + }); }; const setupEmailResponse = async ( @@ -54,8 +67,11 @@ const setupEmailResponse = async ( // override the respond function so that when the endpoint handler finishes (or throws an error), // the response is sent via email res.overrideRespond = async (responseBody: any) => { - const { subject, message, attachments } = await constructEmailFromResponse(responseBody, req); - sendResponseAsEmail(user, subject, message, attachments); + const { subject, attachments, templateContext } = await constructEmailFromResponse( + responseBody, + req, + ); + sendResponseAsEmail(user, subject, templateContext, attachments); }; }; diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index cd2cce4483..37949b625f 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -12,7 +12,8 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "rm -rf dist && npm run --prefix ../../ package:build:ts", + "copy-templates": "copyfiles -u 1 src/email/templates/**/**/* ./dist", + "build": "rm -rf dist && npm run --prefix ../../ package:build:ts && npm run copy-templates", "build-dev": "npm run build", "lint": "yarn package:lint", "lint:fix": "yarn lint --fix", @@ -25,13 +26,15 @@ "@aws-sdk/lib-storage": "^3.348.0", "@tupaia/utils": "workspace:*", "cookie": "^0.5.0", + "copyfiles": "^2.4.1", "dotenv": "^16.4.5", + "handlebars": "^4.7.8", "nodemailer": "^6.9.12", "puppeteer": "^15.4.0", "sha256": "^0.2.0" }, "devDependencies": { - "@types/nodemailer": "^6.4.13", + "@types/nodemailer": "^6.4.15", "@types/sha256": "^0.2.2" } } diff --git a/packages/server-utils/src/constructExportEmail.ts b/packages/server-utils/src/constructExportEmail.ts index 2733c81510..0b76182e36 100644 --- a/packages/server-utils/src/constructExportEmail.ts +++ b/packages/server-utils/src/constructExportEmail.ts @@ -39,8 +39,11 @@ export const constructExportEmail = async (responseBody: ResponseBody, req: Req) if (error) { return { subject, - message: `Unfortunately, your export failed. -${error}`, + templateContext: { + title: 'Export failed', + message: `Unfortunately, your export failed. + ${error}`, + }, }; } @@ -51,15 +54,25 @@ ${error}`, if (emailExportFileMode === EmailExportFileModes.ATTACHMENT) { return { subject, - message: 'Please find your requested export attached to this email.', attachments: await generateAttachments(filePath), + templateContext: { + title: 'Your export is ready', + message: 'Please find your requested export attached to this email.', + }, }; } const downloadLink = createDownloadLink(filePath); return { subject, - message: `Please click this one-time link to download your requested export: ${downloadLink} -Note that you need to be logged in to the admin panel for it to work, and after clicking it once, you won't be able to download the file again.`, + templateContext: { + title: 'Your export is ready', + message: + "Here is your one time link to access your requested export.\nNote that you need to be logged in to the admin panel for it to work, and after clicking it once, you won't be able to download the file again.", + cta: { + url: downloadLink, + text: 'Download export', + }, + }, }; }; diff --git a/packages/server-utils/src/email/index.ts b/packages/server-utils/src/email/index.ts new file mode 100644 index 0000000000..7a2169c9e9 --- /dev/null +++ b/packages/server-utils/src/email/index.ts @@ -0,0 +1,6 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export { sendEmail } from './sendEmail'; diff --git a/packages/server-utils/src/sendEmail.ts b/packages/server-utils/src/email/sendEmail.ts similarity index 52% rename from packages/server-utils/src/sendEmail.ts rename to packages/server-utils/src/email/sendEmail.ts index 79e0b775cb..de8750a232 100644 --- a/packages/server-utils/src/sendEmail.ts +++ b/packages/server-utils/src/email/sendEmail.ts @@ -3,38 +3,57 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ +import fs from 'fs'; +import path from 'path'; import nodemailer from 'nodemailer'; import { getEnvVarOrDefault, getIsProductionEnvironment, requireEnv } from '@tupaia/utils'; import Mail from 'nodemailer/lib/mailer'; +import handlebars from 'handlebars'; -const TEXT_SIGN_OFF = 'Cheers,\n\nThe Tupaia Team'; -const HTML_SIGN_OFF = '
Cheers,
The Tupaia Team
The latest data for the {{dashboardName}} dashboard in {{entityName}} is ready to view.
++ {{user.first_name}} {{user.last_name}} ({{user.email}} - {{user.id}}, {{user.position}} at + {{user.employer}}) has requested to delete their account. +
+Hi {{userName}},
+{{message}}
+Hi {{userName}}
++ You are receiving this email because someone requested a password reset for this user account on + Tupaia.org. To reset your password follow the link below. +
++ If you believe this email was sent to you in error, please contact us immediately at + admin@tupaia.org. +
+Hi {{userName}}
++ This is just to let you know that you've been added to the {{permissionGroupName}} access group + for for {{entityName}}. +
+{{{description}}}
++ Please note that you'll need to log out and then log back in to get access to the new + permissions. +
+Feel free to get in touch if you have any questions.
++ {{user.first_name}} {{user.last_name}} ({{user.email}} - {{user.id}}, {{user.position}} at + {{user.employer}}) has requested access to countries: +
++ For the project {{project.code}} (linked to permission groups: {{project.permissionGroups}}) +
+ {{/if}} +With the message: "{{message}}"
+Thank you for registering with {{platform}}
+Please click below to register your email address.
++ If you believe this email was sent to you in error, please contact us immediately at + admin@tupaia.org. +
++ |
+ ![]() |
+ + | |
+ {{title}}+ |
+ |||
{{{content}}} | +|||
+ {{cta.text}} + | +|||
+ {{#if signoff}}
+ {{signoff}} + {{else}} +
+ Cheers,
+ ![]() |
+
+ ![]() |
+ ||
+ tupaia.org
+ bes.au
+
+ Beyond Essential Systems
+ |
+ |||
+ + If you wish to unsubscribe from these emails please click + here + + |
+
Latest data for the ${dashboard.name} dashboard in ${entity.name}.
`; const filename = `${projectEntity.name}-${entity.name}-${dashboard.name}-export.pdf`; emails.forEach(email => { @@ -122,13 +121,16 @@ export class EmailDashboardRoute extends RouteCheers,
The Tupaia Team
${unsubscribeHtml}
`; return sendEmail(email, { subject, - html, - signOff, attachments: [{ filename, content: buffer }], + templateName: 'dashboardSubscription', + templateContext: { + title: 'Your Tupaia Dashboard Export is ready', + dashboardName: dashboard.name, + entityName: entity.name, + unsubscribeUrl, + }, }); }); diff --git a/yarn.lock b/yarn.lock index b8227055d6..4d22eea9ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12494,10 +12494,12 @@ __metadata: "@aws-sdk/credential-providers": ^3.341.0 "@aws-sdk/lib-storage": ^3.348.0 "@tupaia/utils": "workspace:*" - "@types/nodemailer": ^6.4.13 + "@types/nodemailer": ^6.4.15 "@types/sha256": ^0.2.2 cookie: ^0.5.0 + copyfiles: ^2.4.1 dotenv: ^16.4.5 + handlebars: ^4.7.8 nodemailer: ^6.9.12 puppeteer: ^15.4.0 sha256: ^0.2.0 @@ -13753,12 +13755,12 @@ __metadata: languageName: node linkType: hard -"@types/nodemailer@npm:^6.4.13": - version: 6.4.13 - resolution: "@types/nodemailer@npm:6.4.13" +"@types/nodemailer@npm:^6.4.15": + version: 6.4.15 + resolution: "@types/nodemailer@npm:6.4.15" dependencies: "@types/node": "*" - checksum: fd27d57d5801aaa7594d3bab748aedb1addc0c3a8ff9a21ea7675eec0e7e99cc477d05264b757b97691612fcfab37e52ded6c0725a700a90ad24fc7829dcc641 + checksum: f6f9a2f8a669703ecc3ca6359c12345b16f6b2e5691b93c406b9af7de639c02092ec00133526e6fecd8c60d884890a7cd0f967d8e64bedab46d5c3d8be0882d7 languageName: node linkType: hard @@ -19410,6 +19412,24 @@ __metadata: languageName: node linkType: hard +"copyfiles@npm:^2.4.1": + version: 2.4.1 + resolution: "copyfiles@npm:2.4.1" + dependencies: + glob: ^7.0.5 + minimatch: ^3.0.3 + mkdirp: ^1.0.4 + noms: 0.0.0 + through2: ^2.0.1 + untildify: ^4.0.0 + yargs: ^16.1.0 + bin: + copyfiles: copyfiles + copyup: copyfiles + checksum: aea69873bb99cc5f553967660cbfb70e4eeda198f572a36fb0f748b36877ff2c90fd906c58b1d540adbad8afa8ee82820172f1c18e69736f7ab52792c12745a7 + languageName: node + linkType: hard + "core-js-compat@npm:^3.14.0, core-js-compat@npm:^3.16.0": version: 3.17.3 resolution: "core-js-compat@npm:3.17.3" @@ -25377,6 +25397,24 @@ __metadata: languageName: node linkType: hard +"handlebars@npm:^4.7.8": + version: 4.7.8 + resolution: "handlebars@npm:4.7.8" + dependencies: + minimist: ^1.2.5 + neo-async: ^2.6.2 + source-map: ^0.6.1 + uglify-js: ^3.1.4 + wordwrap: ^1.0.0 + dependenciesMeta: + uglify-js: + optional: true + bin: + handlebars: bin/handlebars + checksum: 00e68bb5c183fd7b8b63322e6234b5ac8fbb960d712cb3f25587d559c2951d9642df83c04a1172c918c41bcfc81bfbd7a7718bbce93b893e0135fc99edea93ff + languageName: node + linkType: hard + "har-schema@npm:^2.0.0": version: 2.0.0 resolution: "har-schema@npm:2.0.0" @@ -31574,7 +31612,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": +"minimatch@npm:^3.0.3, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" dependencies: @@ -32277,7 +32315,7 @@ __metadata: languageName: node linkType: hard -"neo-async@npm:^2.6.0": +"neo-async@npm:^2.6.0, neo-async@npm:^2.6.2": version: 2.6.2 resolution: "neo-async@npm:2.6.2" checksum: deac9f8d00eda7b2e5cd1b2549e26e10a0faa70adaa6fdadca701cc55f49ee9018e427f424bac0c790b7c7e2d3068db97f3093f1093975f2acb8f8818b936ed9 @@ -32667,6 +32705,16 @@ __metadata: languageName: node linkType: hard +"noms@npm:0.0.0": + version: 0.0.0 + resolution: "noms@npm:0.0.0" + dependencies: + inherits: ^2.0.1 + readable-stream: ~1.0.31 + checksum: a05f056dabf764c86472b6b5aad10455f3adcb6971f366cdf36a72b559b29310a940e316bca30802f2804fdd41707941366224f4cba80c4f53071512245bf200 + languageName: node + linkType: hard + "nopt@npm:^4.0.3, nopt@npm:~4.0.1": version: 4.0.3 resolution: "nopt@npm:4.0.3" @@ -37265,6 +37313,18 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:~1.0.31": + version: 1.0.34 + resolution: "readable-stream@npm:1.0.34" + dependencies: + core-util-is: ~1.0.0 + inherits: ~2.0.1 + isarray: 0.0.1 + string_decoder: ~0.10.x + checksum: 85042c537e4f067daa1448a7e257a201070bfec3dd2706abdbd8ebc7f3418eb4d3ed4b8e5af63e2544d69f88ab09c28d5da3c0b77dc76185fddd189a59863b60 + languageName: node + linkType: hard + "readdirp@npm:^2.2.1": version: 2.2.1 resolution: "readdirp@npm:2.2.1" @@ -43818,7 +43878,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:16, yargs@npm:16.2.0, yargs@npm:^16.1.1, yargs@npm:^16.2.0": +"yargs@npm:16, yargs@npm:16.2.0, yargs@npm:^16.1.0, yargs@npm:^16.1.1, yargs@npm:^16.2.0": version: 16.2.0 resolution: "yargs@npm:16.2.0" dependencies: