From d6bd6c2f524386ef54c3e78530f4013e2dbf6f22 Mon Sep 17 00:00:00 2001 From: Kisaragi Hiu Date: Fri, 2 Jun 2023 05:58:06 +0900 Subject: [PATCH 1/3] fix(deps): replace string-similarity with fastest-levenshtein string-similarity is no longer maintained and deprecated, resulting in another warning when installing Netlify CLI. While the algorithm is different (Levenshtein distance instead of Dice's coefficient), the difference shouldn't be big enough to matter as this is only used for suggestion after a typo. --- package-lock.json | 26 ++++++++++++++------------ package.json | 2 +- src/commands/main.mjs | 6 ++---- src/commands/recipes/recipes.mjs | 6 ++---- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d0dbc61bac..93bd651438c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "express": "4.18.2", "express-logging": "1.1.1", "extract-zip": "2.0.1", + "fastest-levenshtein": "1.0.16", "fastify": "4.17.0", "find-up": "6.3.0", "flush-write-stream": "2.0.0", @@ -101,7 +102,6 @@ "read-pkg-up": "9.1.0", "semver": "7.5.1", "source-map-support": "0.5.21", - "string-similarity": "4.0.4", "strip-ansi-control-characters": "2.0.0", "tabtab": "3.0.2", "tempy": "3.0.0", @@ -11638,6 +11638,14 @@ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.2.0.tgz", "integrity": "sha512-cIusKBIt/R/oI6z/1nyfe2FvGKVTohVRfvkOhvx0nCEW+xf5NoCXjAHcWp93uOUBchzYcsvPlrapAdX1uW+YGg==" }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastify": { "version": "4.17.0", "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.17.0.tgz", @@ -20280,12 +20288,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string-similarity": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/string-similarity/-/string-similarity-4.0.4.tgz", - "integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info." - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -30756,6 +30758,11 @@ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.2.0.tgz", "integrity": "sha512-cIusKBIt/R/oI6z/1nyfe2FvGKVTohVRfvkOhvx0nCEW+xf5NoCXjAHcWp93uOUBchzYcsvPlrapAdX1uW+YGg==" }, + "fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==" + }, "fastify": { "version": "4.17.0", "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.17.0.tgz", @@ -37086,11 +37093,6 @@ "safe-buffer": "~5.1.0" } }, - "string-similarity": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/string-similarity/-/string-similarity-4.0.4.tgz", - "integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==" - }, "string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", diff --git a/package.json b/package.json index 0db8cfaae12..9579536927a 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "express": "4.18.2", "express-logging": "1.1.1", "extract-zip": "2.0.1", + "fastest-levenshtein": "1.0.16", "fastify": "4.17.0", "find-up": "6.3.0", "flush-write-stream": "2.0.0", @@ -167,7 +168,6 @@ "read-pkg-up": "9.1.0", "semver": "7.5.1", "source-map-support": "0.5.21", - "string-similarity": "4.0.4", "strip-ansi-control-characters": "2.0.0", "tabtab": "3.0.2", "tempy": "3.0.0", diff --git a/src/commands/main.mjs b/src/commands/main.mjs index cbea75cd3ce..d945bd0fe8e 100644 --- a/src/commands/main.mjs +++ b/src/commands/main.mjs @@ -2,8 +2,8 @@ import process from 'process' import { Option } from 'commander' +import { closest } from 'fastest-levenshtein' import inquirer from 'inquirer' -import { findBestMatch } from 'string-similarity' import { BANG, chalk, error, exit, log, NETLIFY_CYAN, USER_AGENT, warn } from '../utils/command-helpers.mjs' import execa from '../utils/execa.mjs' @@ -118,9 +118,7 @@ const mainCommand = async function (options, command) { warn(`${chalk.yellow(command.args[0])} is not a ${command.name()} command.`) const allCommands = command.commands.map((cmd) => cmd.name()) - const { - bestMatch: { target: suggestion }, - } = findBestMatch(command.args[0], allCommands) + const suggestion = closest(command.args[0], allCommands) const applySuggestion = await new Promise((resolve) => { const prompt = inquirer.prompt({ diff --git a/src/commands/recipes/recipes.mjs b/src/commands/recipes/recipes.mjs index ad2c74a9b16..06cd40fadc7 100644 --- a/src/commands/recipes/recipes.mjs +++ b/src/commands/recipes/recipes.mjs @@ -1,8 +1,8 @@ // @ts-check import { basename } from 'path' +import { closest } from 'fastest-levenshtein' import inquirer from 'inquirer' -import { findBestMatch } from 'string-similarity' import { NETLIFYDEVERR, chalk, log } from '../../utils/command-helpers.mjs' @@ -36,9 +36,7 @@ const recipesCommand = async (recipeName, options, command) => { const recipes = await listRecipes() const recipeNames = recipes.map(({ name }) => name) - const { - bestMatch: { target: suggestion }, - } = findBestMatch(recipeName, recipeNames) + const suggestion = closest(recipeName, recipeNames) const applySuggestion = await new Promise((resolve) => { const prompt = inquirer.prompt({ type: 'confirm', From 34a04beb5cb9362e873c4ec004359463d9267998 Mon Sep 17 00:00:00 2001 From: Kisaragi Hiu Date: Sat, 3 Jun 2023 00:49:09 +0900 Subject: [PATCH 2/3] chore(main): add tests for typo suggestion --- tests/integration/didyoumean.test.cjs | 17 +++++++++++ .../snapshots/didyoumean.test.cjs.md | 27 ++++++++++++++++++ .../snapshots/didyoumean.test.cjs.snap | Bin 0 -> 287 bytes 3 files changed, 44 insertions(+) create mode 100644 tests/integration/didyoumean.test.cjs create mode 100644 tests/integration/snapshots/didyoumean.test.cjs.md create mode 100644 tests/integration/snapshots/didyoumean.test.cjs.snap diff --git a/tests/integration/didyoumean.test.cjs b/tests/integration/didyoumean.test.cjs new file mode 100644 index 00000000000..57dceb2c427 --- /dev/null +++ b/tests/integration/didyoumean.test.cjs @@ -0,0 +1,17 @@ +const test = require('ava') + +const callCli = require('./utils/call-cli.cjs') +const { normalize } = require('./utils/snapshots.cjs') + +test('suggests closest matching command on typo', async (t) => { + // failures are expected since we effectively quit out of the prompts + const errors = await Promise.all([ + t.throwsAsync(() => callCli(['sta'])), + t.throwsAsync(() => callCli(['opeen'])), + t.throwsAsync(() => callCli(['hel'])), + t.throwsAsync(() => callCli(['versio'])), + ]) + errors.forEach((error) => { + t.snapshot(normalize(error.stdout, { duration: true, filePath: true })) + }) +}) diff --git a/tests/integration/snapshots/didyoumean.test.cjs.md b/tests/integration/snapshots/didyoumean.test.cjs.md new file mode 100644 index 00000000000..3c0b4180099 --- /dev/null +++ b/tests/integration/snapshots/didyoumean.test.cjs.md @@ -0,0 +1,27 @@ +# Snapshot report for `tests/integration/didyoumean.test.cjs` + +The actual snapshot is saved in `didyoumean.test.cjs.snap`. + +Generated by [AVA](https://avajs.dev). + +## suggests closest matching command on typo + +> Snapshot 1 + + `› Warning: sta is not a netlify command.␊ + ? Did you mean api (y/N) ` + +> Snapshot 2 + + `› Warning: opeen is not a netlify command.␊ + ? Did you mean open (y/N) ` + +> Snapshot 3 + + `› Warning: hel is not a netlify command.␊ + ? Did you mean dev (y/N) ` + +> Snapshot 4 + + `› Warning: versio is not a netlify command.␊ + ? Did you mean serve (y/N) ` diff --git a/tests/integration/snapshots/didyoumean.test.cjs.snap b/tests/integration/snapshots/didyoumean.test.cjs.snap new file mode 100644 index 0000000000000000000000000000000000000000..27eda10496109b0af50c26f835d6baf407dde6dd GIT binary patch literal 287 zcmV+)0pR{YRzVKnw=pE+T?A&tCqcdr=VF1uq`DXAwjc1R>63+rjCi zG}&Tr3ckqt7(R|qVhhT$3qo@UIVAb`*URT9?nU1}*@8uWo$9J6SWV%gXg{DLb7e>c zoKzJh4@t1DmY|7hEyeU>?L8SiJndx%*-(@H>}YcH{q_amo|@EOUqX!#6iEz(#1_M{ z`}=u%c!fpq=wyY8DS_&MlkV(#isQ%gi^b6LUxl`m+Z{z}<}}{v*V6`?4bUtf6{qAl lp7GrN7sqWWcRNbUjRqMHDK{-|sQEz6^B)NlAh6~E008v>gZcmf literal 0 HcmV?d00001 From e35b7c0e1c2cd0ddf302dfe770169301977b4590 Mon Sep 17 00:00:00 2001 From: Kisaragi Hiu Date: Sat, 3 Jun 2023 00:28:35 +0900 Subject: [PATCH 3/3] fix(recipes): suggest a replacement on typo again When the user specifies a recipe that doesn't exist, the recipes command is supposed to suggest a replacement. The way it detects this scenario is to catch the module not found error, but it was catching MODULE_NOT_FOUND, which is only thrown for CommonJS modules: > MODULE_NOT_FOUND > > A module file could not be resolved by the CommonJS modules loader > while attempting a require() operation or when loading the program entry > point. > > --- https://nodejs.org/api/errors.html#module_not_found For ESM modules, when a module isn't found, the ERR_MODULE_NOT_FOUND error is thrown instead: > ERR_MODULE_NOT_FOUND > > A module file could not be resolved by the ECMAScript modules loader > while attempting an import operation or when loading the program entry > point. > > --- https://nodejs.org/api/errors.html#err_module_not_found This commit makes it catch ERR_MODULE_NOT_FOUND instead. --- src/commands/recipes/recipes.mjs | 5 ++++- tests/integration/640.command.recipes.test.cjs | 6 ++++++ .../snapshots/640.command.recipes.test.cjs.md | 7 +++++++ .../snapshots/640.command.recipes.test.cjs.snap | Bin 291 -> 385 bytes 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/commands/recipes/recipes.mjs b/src/commands/recipes/recipes.mjs index 06cd40fadc7..879334eddee 100644 --- a/src/commands/recipes/recipes.mjs +++ b/src/commands/recipes/recipes.mjs @@ -28,7 +28,10 @@ const recipesCommand = async (recipeName, options, command) => { try { return await runRecipe({ config, recipeName: sanitizedRecipeName, repositoryRoot }) } catch (error) { - if (error.code !== 'MODULE_NOT_FOUND') { + if ( + // The ESM loader throws this instead of MODULE_NOT_FOUND + error.code !== 'ERR_MODULE_NOT_FOUND' + ) { throw error } diff --git a/tests/integration/640.command.recipes.test.cjs b/tests/integration/640.command.recipes.test.cjs index 65e5c80750f..f9e99a9e59e 100644 --- a/tests/integration/640.command.recipes.test.cjs +++ b/tests/integration/640.command.recipes.test.cjs @@ -139,3 +139,9 @@ test('Handles JSON with comments', async (t) => { t.deepEqual([...settings['deno.enablePaths']], ['/some/path', 'netlify/edge-functions']) }) }) + +test('Suggests closest matching recipe on typo', async (t) => { + const cliResponse = await callCli(['recipes', 'vsc']) + + t.snapshot(normalize(cliResponse)) +}) diff --git a/tests/integration/snapshots/640.command.recipes.test.cjs.md b/tests/integration/snapshots/640.command.recipes.test.cjs.md index dd2f7985fc0..9620309738d 100644 --- a/tests/integration/snapshots/640.command.recipes.test.cjs.md +++ b/tests/integration/snapshots/640.command.recipes.test.cjs.md @@ -15,3 +15,10 @@ Generated by [AVA](https://avajs.dev). |--------|-------------------------------------------------------------------------|␊ | vscode | Create VS Code settings for an optimal experience with Netlify projects |␊ '----------------------------------------------------------------------------------'` + +## Suggests closest matching recipe on typo + +> Snapshot 1 + + `◈ vsc is not a valid recipe name.␊ + ? Did you mean vscode (y/N) ` diff --git a/tests/integration/snapshots/640.command.recipes.test.cjs.snap b/tests/integration/snapshots/640.command.recipes.test.cjs.snap index cb5be4cc62b223817d2f73ae53c03927ca7138cd..16fed80262654395eed28af7c99756f1928c3246 100644 GIT binary patch literal 385 zcmV-{0e=2LRzV)?JBp-_i00000000B6lD|&FFc8MuB81e9ndP>&14Uv&2*J=!3>~OIMNC)ck{BF2 z%3c3N$^sK_z+4`UC&89dR0$Q9e1m13?7N>of17x2oOkG0+D~N_kQo2@p$R$$zwbzMp(-S3bHoJ$$f4g2k#SS;oulMu?~x`KTkQnvNJ8x>1e>8rk~(-Ve_y80039{hA{vD