From acf3779242cf4b36f9ed64bf7f1792b090714370 Mon Sep 17 00:00:00 2001 From: Tsowa Mainasara Al-amin Date: Fri, 23 Feb 2024 14:37:38 +0100 Subject: [PATCH] feat: automate installing dependencies (#20) * feat: automate installing dependencies * Detect current package manager * chore: Variable name cleanups for dependency installation hook * Added 'Get started with' message * Use execa to test cli * test: Add dependencies test * test: Add bin script to allow triggering ./bin using a dedicated package manager * test: Use a specific test-dir directory to store test project files * chore: Add link to handleQuestions inspiration * test: Before running a test, build the ./bin file * ci: Fix package manager selection & programmatically run build * test: Remove bin file after test to prevent clash with ci * ci: Fix linting issues * ci: Fix formatting issues * chore: Add format & format:fix scripts to package.json * ci: Fix package manager detection bug --- .gitignore | 3 +- package.json | 7 +- src/github.ts | 2 +- src/hook.ts | 15 ++- src/hooks/after-create.ts | 2 +- src/hooks/dependencies.test.ts | 201 +++++++++++++++++++++++++++++++++ src/hooks/dependencies.ts | 76 +++++++++++++ src/index.ts | 21 +++- yarn.lock | 69 +++++++++++ 9 files changed, 386 insertions(+), 10 deletions(-) create mode 100644 src/hooks/dependencies.test.ts create mode 100644 src/hooks/dependencies.ts diff --git a/.gitignore b/.gitignore index eb564c6..14c33f0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules bin package-lock.json yarn-error.log -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml +test-dir \ No newline at end of file diff --git a/package.json b/package.json index 037058c..9032ede 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,14 @@ "version": "0.4.0", "scripts": { "build": "tsx ./build.ts", + "bin": "./bin", "test": "vitest --run", "prepack": "yarn build", "release": "np", "lint": "eslint --ext js,ts src", - "lint:fix": "eslint --ext js,ts src --fix" + "lint:fix": "eslint --ext js,ts src --fix", + "format": "prettier src --check", + "format:fix": "prettier src --write" }, "bin": "./bin", "files": [ @@ -31,9 +34,11 @@ "@types/yargs-parser": "^21.0.0", "esbuild": "^0.16.17", "eslint": "^8.55.0", + "execa": "^8.0.1", "kleur": "^4.1.5", "node-fetch": "^3.3.0", "np": "^7.6.3", + "prettier": "^3.2.5", "prompts": "^2.4.2", "tiged": "^2.12.7", "tsx": "^3.12.2", diff --git a/src/github.ts b/src/github.ts index 6093bed..9333dd1 100644 --- a/src/github.ts +++ b/src/github.ts @@ -23,7 +23,7 @@ export const viaContentsApi = async ({ }: Options) => { const files = [] const contents = await api( - `${user}/${repository}/contents/${directory}?ref=${ref}` + `${user}/${repository}/contents/${directory}?ref=${ref}`, ) if ('message' in contents) { diff --git a/src/hook.ts b/src/hook.ts index 88993b2..0799d5c 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -1,5 +1,6 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export class Hook any> { +type HookSignature = (...args: any[]) => any +export class Hook { #hookMap: Map constructor() { this.#hookMap = new Map() @@ -39,5 +40,15 @@ type AfterHookOptions = { } type AfterHookFunction = (options: AfterHookOptions) => void - export const afterCreateHook = new Hook() + +/** + * Dependencies Hook + */ + +type DependenciesHookOptions = { + directoryPath: string +} + +type DependenciesHookFunction = (options: DependenciesHookOptions) => void +export const projectDependenciesHook = new Hook() diff --git a/src/hooks/after-create.ts b/src/hooks/after-create.ts index 8ad5990..e4be7cf 100644 --- a/src/hooks/after-create.ts +++ b/src/hooks/after-create.ts @@ -14,7 +14,7 @@ afterCreateHook.addHook( .replaceAll(/[^a-z0-9\-_]/gm, '-') const rewritten = wrangler.replaceAll(PROJECT_NAME, convertProjectName) writeFileSync(wranglerPath, rewritten) - } + }, ) export { afterCreateHook } diff --git a/src/hooks/dependencies.test.ts b/src/hooks/dependencies.test.ts new file mode 100644 index 0000000..81d7e3e --- /dev/null +++ b/src/hooks/dependencies.test.ts @@ -0,0 +1,201 @@ +import { Buffer } from 'buffer' + +import { existsSync, rmSync } from 'fs' +import { cwd } from 'process' +import { execa, execaSync } from 'execa' +import type { ExecaChildProcess } from 'execa' +import { afterAll, describe, expect, it } from 'vitest' + +let cmdBuffer = '' + +const packageManagersCommands: { [key: string]: string[] } = { + npm: 'npm run bin'.split(' '), + bun: 'bun bin'.split(' '), + pnpm: 'pnpm run bin'.split(' '), + yarn: 'yarn run bin'.split(' '), +} + +const packageManagersLockfiles: { [key: string]: string } = { + npm: 'package-lock.json', + bun: 'bun.lockb', + pnpm: 'pnpm-lock.yml', + yarn: 'yarn.lock', +} + +const availablePackageManagers = Object.keys(packageManagersCommands).filter( + (p) => { + if (p === 'npm') return true // Skip check for npm because it's most likely here and for some wierd reason, it returns an exitCode of 1 from `npm -h` + + let stderr = '' + + try { + const { stderr: err } = execaSync(p, ['-h']) + stderr = err + } catch (error) { + stderr = error as string + } + + return stderr.length == 0 + }, +) + +// Run build to have ./bin +execaSync('yarn', 'run build'.split(' ')) +execaSync('chmod', ['+x', './bin']) + +describe('dependenciesHook', async () => { + afterAll(() => { + rmSync('test-dir', { recursive: true, force: true }) + rmSync('bin') // Might be beneficial to remove the bin file + }) + + describe.each(availablePackageManagers.map((p) => ({ pm: p })))( + '$pm', + ({ pm }) => { + const proc = execa( + packageManagersCommands[pm][0], + packageManagersCommands[pm].slice(1), + { + cwd: cwd(), + stdin: 'pipe', + stdout: 'pipe', + env: { ...process.env, npm_config_user_agent: pm }, + }, + ) + const targetDirectory = 'test-dir/' + generateRandomAlphanumericString(8) + + afterAll(() => { + rmSync(targetDirectory, { recursive: true, force: true }) + }) + + it('should ask for a target directory', async () => { + const out = await handleQuestions(proc, [ + { + question: 'Target directory', + answer: answerWithValue(targetDirectory), + }, + ]) + + expect(out) + }) + + it('should clone a template to the directory', async () => { + const out = await handleQuestions(proc, [ + { + question: 'Which template do you want to use?', + answer: CONFIRM, // Should pick aws-lambda + }, + ]) + + expect(out, 'Selected aws-lambda') + }) + + it('should ask if you want to install dependencies', async () => { + const out = await handleQuestions(proc, [ + { + question: 'Do you want to install project dependencies?', + answer: CONFIRM, // Should pick Y + }, + ]) + + expect(out, 'Installing dependencies') + }) + + it('should ask for which package manager to use', async () => { + const out = await handleQuestions(proc, [ + { + question: 'Which package manager do you want to use?', + answer: CONFIRM, // Should pick current package manager + }, + ]) + + expect( + out.trim().includes(pm), + `Current package manager '${pm}' was picked`, + ) + }) + + it('should have installed dependencies', async () => { + while (!existsSync(targetDirectory + '/node_modules')) + await timeout(3_000) // 3 seconds; + + expect( + existsSync(targetDirectory + '/node_modules'), + 'node_modules directory exists', + ) + }) + + it( + 'should have package manager specific lock file (' + + packageManagersLockfiles[pm] + + ')', + async () => { + expect( + existsSync(targetDirectory + '/' + packageManagersLockfiles[pm]), + 'lockfile exists', + ) + + cmdBuffer = '' + }, + ) + }, + { timeout: 60_000 }, + ) +}) + +const generateRandomAlphanumericString = (length: number): string => { + const alphabet = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + let result = '' + + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * alphabet.length) + result += alphabet[randomIndex] + } + + return result +} + +const timeout = (milliseconds: number) => + new Promise((res) => setTimeout(res, milliseconds)) + +/** + * Utility to mock the stdin of the cli. You must provide the correct number of + * questions correctly typed or the process will keep waiting for input. + * https://github.com/netlify/cli/blob/0c91f20e14e84e9b21d39d592baf10c7abd8f37c/tests/integration/utils/handle-questions.js#L11 + */ +const handleQuestions = ( + process: ExecaChildProcess, + questions: { question: string; answer: string | string[] }[], +) => + new Promise((res) => { + process.stdout?.on('data', (data) => { + cmdBuffer = (cmdBuffer + data).replace(/\n/g, '') + const index = questions.findIndex(({ question }) => + cmdBuffer.includes(question), + ) + + if (index >= 0) { + res(cmdBuffer) + const { answer } = questions[index] + + writeResponse(process, Array.isArray(answer) ? answer : [answer]) + } + }) + }) + +const writeResponse = ( + process: ExecaChildProcess, + responses: string[], +) => { + const response = responses.shift() + if (!response) return + + if (!response.endsWith(CONFIRM)) + process.stdin?.write(Buffer.from(response + CONFIRM)) + else process.stdin?.write(Buffer.from(response)) +} + +export const answerWithValue = (value = '') => [value, CONFIRM].flat() + +export const CONFIRM = '\n' diff --git a/src/hooks/dependencies.ts b/src/hooks/dependencies.ts new file mode 100644 index 0000000..02e2388 --- /dev/null +++ b/src/hooks/dependencies.ts @@ -0,0 +1,76 @@ +import { exec } from 'child_process' +import { chdir, exit } from 'process' +import { bold, green, red } from 'kleur/colors' +import prompts from 'prompts' +import { projectDependenciesHook } from '../hook' + +type PackageManager = 'npm' | 'bun' | 'pnpm' | 'yarn' + +const knownPackageManagers: { [key: string]: string } = { + npm: 'npm install', + bun: 'bun install', + pnpm: 'pnpm install', + yarn: 'yarn', +} + +const knownPackageManagerNames = Object.keys(knownPackageManagers) +const currentPackageManager = getCurrentPackageManager() + +const registerInstallationHook = (template: string) => { + if (template == 'deno') return // Deno needs no dependency installation step + + projectDependenciesHook.addHook(template, async ({ directoryPath }) => { + const { installDeps } = await prompts({ + type: 'confirm', + name: 'installDeps', + message: 'Do you want to install project dependencies?', + initial: true, + }) + + if (!installDeps) return + + const { packageManager } = await prompts({ + type: 'select', + name: 'packageManager', + message: 'Which package manager do you want to use?', + choices: knownPackageManagerNames.map((template: string) => ({ + title: template, + value: template, + })), + initial: knownPackageManagerNames.indexOf(currentPackageManager), + }) + + chdir(directoryPath) + + if (!knownPackageManagers[packageManager]) { + exit(1) + } + + const proc = exec(knownPackageManagers[packageManager]) + + const procExit: number = await new Promise((res) => { + proc.on('exit', (code) => res(code == null ? 0xff : code)) + }) + + if (procExit == 0) { + console.log(bold(`${green('✔')} Installed project dependencies`)) + } else { + console.log(bold(`${red('×')} Failed to install project dependencies`)) + exit(procExit) + } + + return + }) +} + +function getCurrentPackageManager(): PackageManager { + const agent = process.env.npm_config_user_agent || 'npm' // Types say it might be undefined, just being cautious; + + if (agent.startsWith('bun')) return 'bun' + else if (agent.startsWith('pnpm')) return 'pnpm' + else if (agent.startsWith('yarn')) return 'yarn' + + return 'npm' +} + +export { registerInstallationHook } diff --git a/src/index.ts b/src/index.ts index 8290c03..7178bc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,9 @@ import tiged from 'tiged' import yargsParser from 'yargs-parser' import { version } from '../package.json' import { viaContentsApi } from './github.js' +import { projectDependenciesHook } from './hook' import { afterCreateHook } from './hooks/after-create' +import { registerInstallationHook } from './hooks/dependencies' const directoryName = 'templates' const config = { @@ -109,38 +111,49 @@ async function main() { mkdirp(target) } + const targetDirectoryPath = path.join(process.cwd(), target) + await new Promise((res) => { const emitter = tiged( `${config.user}/${config.repository}/${config.directory}/${templateName}#${config.ref}`, { cache: false, force: true, - } + }, ) emitter.on('info', (info: { message: string }) => { console.log(info.message) }) - emitter.clone(path.join(process.cwd(), target)).then(() => { + emitter.clone(targetDirectoryPath).then(() => { res({}) }) }) + registerInstallationHook(templateName) + try { afterCreateHook.applyHook(templateName, { projectName, - directoryPath: path.join(process.cwd(), target), + directoryPath: targetDirectoryPath, }) + + await Promise.all( + projectDependenciesHook.applyHook(templateName, { + directoryPath: targetDirectoryPath, + }), + ) } catch (e) { throw new Error( `Error running hook for ${templateName}: ${ e instanceof Error ? e.message : e - }` + }`, ) } console.log(bold(green('✔ Copied project files'))) + console.log(gray('Get started with:'), bold(`cd ${target}`)) } main() diff --git a/yarn.lock b/yarn.lock index 055b90b..946ee30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1829,6 +1829,21 @@ execa@^5.0.0: signal-exit "^3.0.3" strip-final-newline "^2.0.0" +execa@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-8.0.1.tgz#51f6a5943b580f963c3ca9c6321796db8cc39b8c" + integrity sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^8.0.1" + human-signals "^5.0.0" + is-stream "^3.0.0" + merge-stream "^2.0.0" + npm-run-path "^5.1.0" + onetime "^6.0.0" + signal-exit "^4.1.0" + strip-final-newline "^3.0.0" + external-editor@^3.0.3: version "3.1.0" resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" @@ -2060,6 +2075,11 @@ get-stream@^6.0.0: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== +get-stream@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-8.0.1.tgz#def9dfd71742cd7754a7761ed43749a27d02eca2" + integrity sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA== + get-symbol-description@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" @@ -2328,6 +2348,11 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +human-signals@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" + integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== + iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -2675,6 +2700,11 @@ is-stream@^2.0.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== +is-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" + integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== + is-string@^1.0.5, is-string@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" @@ -3081,6 +3111,11 @@ mimic-fn@^3.0.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-3.1.0.tgz#65755145bbf3e36954b949c16450427451d5ca74" integrity sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ== +mimic-fn@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" + integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== + mimic-response@^1.0.0, mimic-response@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" @@ -3310,6 +3345,13 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" +npm-run-path@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-5.2.0.tgz#224cdd22c755560253dd71b83a1ef2f758b2e955" + integrity sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg== + dependencies: + path-key "^4.0.0" + number-is-nan@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" @@ -3389,6 +3431,13 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +onetime@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-6.0.0.tgz#7c24c18ed1fd2e9bca4bd26806a33613c77d34b4" + integrity sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ== + dependencies: + mimic-fn "^4.0.0" + open@^7.3.0: version "7.4.2" resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" @@ -3593,6 +3642,11 @@ path-key@^3.0.0, path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== +path-key@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18" + integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== + path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" @@ -3665,6 +3719,11 @@ prepend-http@^2.0.0: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA== +prettier@^3.2.5: + version "3.2.5" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.2.5.tgz#e52bc3090586e824964a8813b09aba6233b28368" + integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A== + pretty-format@^29.5.0: version "29.7.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" @@ -4005,6 +4064,11 @@ signal-exit@^3.0.2, signal-exit@^3.0.3: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +signal-exit@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz" @@ -4172,6 +4236,11 @@ strip-final-newline@^2.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== +strip-final-newline@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" + integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== + strip-indent@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001"