Repository: Redocly/redocly-cli
Pull request #1090 (2023-05-19 12:28:41)

feat: create update notifier for cli
## What/Why/How?

Create update notifier for cli and display message if new version available 

## Reference

Closes: https://github.com/Redocly/redocly-cli/issues/779
## Testing

## Screenshots (optional)
![Знімок екрана 2023-05-22 о 10 33 09](https://github.com/Redocly/redocly-cli/assets/106662428/e14d69fc-f11e-4464-816b-d660ed95e0ed)



## Check yourself

- [x] Code is linted
- [x] Tested with redoc/reference-docs/workflows
- [x] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 



| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 73.38%     |    3496/4764    |
|                                                                           🟡                                                                           | Branches   | 64.91%     |    1981/3052    |
|                                                                           🟡                                                                           | Functions  | 65.66%     |     587/894     |
|                                                                           🟡                                                                           | Lines      | 73.44%     |    3254/4431    |

<details><summary>Show new covered files 🐣</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                                                     | Statements | Branches | Functions | Lines |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------- | :--------- | :------- | :-------- | :---- |
|                                                                           🔴                                                                           | <div title="cli/src/update-version-notifier.ts">`...` / update-version-notifier.ts</div> | 0%         | 0%       | 0%        | 0%    |

</details>


<details><summary>Show files with reduced coverage 🔻</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File             | Statements | Branches | Functions                                                   | Lines |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------- | :--------- | :------- | :---------------------------------------------------------- | :---- |
|                                                                           🔴                                                                           | cli/src/utils.ts | 59.77%     | 44.23%   | <div title="Base coverage is: 70%">68.29% (-1.71% 🔻)</div> | 59.3% |

</details>

## Test suite run success
585 tests passing in 85 suites.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from 3c72f65fdf3dda33f854e0a263571cae8f22abd1</p>

tatomyr: @IgorKarpiuk could you also check if it runs in a TEST environment, so the notifier won't get triggered in that case?
SmoliyY: > @IgorKarpiuk could you also check if it runs in a TEST environment, so the notifier won't get triggered in that case?

Maybe it is better to rename `TEST` on `REDOCLY_TEST` because as we know some other libraries also relay on such env variable and our tests can work in not appropriate way. We have  this issue with prev implementation of the same feature 
tatomyr: @SmoliyY I believe `NODE_TEST` is the conventional env variable.
tatomyr: Actually, we might only want to skip this in CI environment. Not sure if `NODE_ENV` makes too much sense. 

Patch:
diff --git a/.github/workflows/performance.yaml b/.github/workflows/performance.yaml
index 0e408610..424734c5 100644
--- a/.github/workflows/performance.yaml
+++ b/.github/workflows/performance.yaml
@@ -29,6 +29,8 @@ jobs:
       - run: redocly --version
       - name: Run Benchmark
         run: hyperfine -i --warmup 3 'redocly lint packages/core/src/benchmark/benches/rebilly.yaml' 'redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml' --export-markdown benchmark_check.md --export-json benchmark_check.json
+        env:
+          CI: true
       - name: Comment PR
         uses: thollander/actions-comment-pull-request@v2
         with:
diff --git a/package-lock.json b/package-lock.json
index c7a6f709..dfeb499f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1508,6 +1508,12 @@
       "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
       "dev": true
     },
+    "node_modules/@types/semver": {
+      "version": "7.5.0",
+      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz",
+      "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
+      "dev": true
+    },
     "node_modules/@types/sizzle": {
       "version": "2.3.3",
       "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz",
@@ -5850,7 +5856,6 @@
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
       "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-      "dev": true,
       "dependencies": {
         "yallist": "^4.0.0"
       },
@@ -7742,10 +7747,9 @@
       }
     },
     "node_modules/semver": {
-      "version": "7.3.7",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
-      "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
-      "dev": true,
+      "version": "7.5.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz",
+      "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==",
       "dependencies": {
         "lru-cache": "^6.0.0"
       },
@@ -9510,8 +9514,7 @@
     "node_modules/yallist": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-      "dev": true
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
     },
     "node_modules/yaml": {
       "version": "1.10.2",
@@ -9589,6 +9592,7 @@
         "react": "^17.0.1",
         "react-dom": "^17.0.1",
         "redoc": "~2.0.0",
+        "semver": "^7.5.1",
         "simple-websocket": "^9.0.0",
         "styled-components": "5.3.3",
         "yargs": "17.0.1"
@@ -9601,18 +9605,24 @@
         "@types/configstore": "^5.0.1",
         "@types/react": "^17.0.8",
         "@types/react-dom": "^17.0.5",
+        "@types/semver": "^7.5.0",
         "@types/styled-components": "^5.1.1",
-        "@types/yargs": "16.0.2",
+        "@types/yargs": "17.0.5",
         "typescript": "^4.0.3"
       },
       "engines": {
         "node": ">=12.0.0"
       }
     },
+    "packages/cli/node_modules/@types/node": {
+      "version": "14.18.47",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.47.tgz",
+      "integrity": "sha512-OuJi8bIng4wYHHA3YpKauL58dZrPxro3d0tabPHyiNF8rKfGKuVfr83oFlPLmKri1cX+Z3cJP39GXmnqkP11Gw=="
+    },
     "packages/cli/node_modules/@types/yargs": {
-      "version": "16.0.2",
-      "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.2.tgz",
-      "integrity": "sha512-EkZDJvHXblOE2/iFZb3Ty5c8RHPssmFvrXqj3V/ZLXtOt0f7xJh9Fd7p1pl2ITrNHM7Rl3Db3JJVBT7q/C0wUg==",
+      "version": "17.0.5",
+      "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.5.tgz",
+      "integrity": "sha512-4HNq144yhaVjJs+ON6A07NEoi9Hh0Rhl/jI9Nt/l/YRjt+T6St/QK3meFARWZ8IgkzoD1LC0PdTdJenlQQi2WQ==",
       "dev": true,
       "dependencies": {
         "@types/yargs-parser": "*"
@@ -10587,8 +10597,9 @@
         "@types/configstore": "^5.0.1",
         "@types/react": "^17.0.8",
         "@types/react-dom": "^17.0.5",
+        "@types/semver": "^7.5.0",
         "@types/styled-components": "^5.1.1",
-        "@types/yargs": "16.0.2",
+        "@types/yargs": "17.0.5",
         "assert-node-version": "^1.0.3",
         "chokidar": "^3.5.1",
         "colorette": "^1.2.0",
@@ -10600,16 +10611,21 @@
         "react": "^17.0.1",
         "react-dom": "^17.0.1",
         "redoc": "~2.0.0",
+        "semver": "^7.5.1",
         "simple-websocket": "^9.0.0",
         "styled-components": "5.3.3",
         "typescript": "^4.0.3",
         "yargs": "17.0.1"
       },
       "dependencies": {
+        "@types/node": {
+          "version": "https://registry.npmjs.org/@types/node/-/node-14.18.47.tgz",
+          "integrity": "sha512-OuJi8bIng4wYHHA3YpKauL58dZrPxro3d0tabPHyiNF8rKfGKuVfr83oFlPLmKri1cX+Z3cJP39GXmnqkP11Gw=="
+        },
         "@types/yargs": {
-          "version": "16.0.2",
-          "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.2.tgz",
-          "integrity": "sha512-EkZDJvHXblOE2/iFZb3Ty5c8RHPssmFvrXqj3V/ZLXtOt0f7xJh9Fd7p1pl2ITrNHM7Rl3Db3JJVBT7q/C0wUg==",
+          "version": "17.0.5",
+          "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.5.tgz",
+          "integrity": "sha512-4HNq144yhaVjJs+ON6A07NEoi9Hh0Rhl/jI9Nt/l/YRjt+T6St/QK3meFARWZ8IgkzoD1LC0PdTdJenlQQi2WQ==",
           "dev": true,
           "requires": {
             "@types/yargs-parser": "*"
@@ -10937,6 +10953,12 @@
       "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
       "dev": true
     },
+    "@types/semver": {
+      "version": "7.5.0",
+      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz",
+      "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
+      "dev": true
+    },
     "@types/sizzle": {
       "version": "2.3.3",
       "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz",
@@ -14281,7 +14303,6 @@
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
       "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-      "dev": true,
       "requires": {
         "yallist": "^4.0.0"
       }
@@ -15696,10 +15717,9 @@
       }
     },
     "semver": {
-      "version": "7.3.7",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
-      "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
-      "dev": true,
+      "version": "7.5.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz",
+      "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==",
       "requires": {
         "lru-cache": "^6.0.0"
       }
@@ -17028,8 +17048,7 @@
     "yallist": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-      "dev": true
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
     },
     "yaml": {
       "version": "1.10.2",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 1bdbaa39..e1c421da 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -45,6 +45,7 @@
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
     "redoc": "~2.0.0",
+    "semver": "^7.5.1",
     "simple-websocket": "^9.0.0",
     "styled-components": "5.3.3",
     "yargs": "17.0.1"
@@ -54,7 +55,8 @@
     "@types/react": "^17.0.8",
     "@types/react-dom": "^17.0.5",
     "@types/styled-components": "^5.1.1",
-    "@types/yargs": "16.0.2",
+    "@types/yargs": "17.0.5",
+    "@types/semver": "^7.5.0",
     "typescript": "^4.0.3"
   }
 }
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 5ce2e4d9..acceab2e 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -14,8 +14,12 @@ import { handleBundle } from './commands/bundle';
 import { handleLogin } from './commands/login';
 import { handlerBuildCommand } from './commands/build-docs';
 import type { BuildDocsArgv } from './commands/build-docs/types';
+import { cacheLatestVersion, notifyUpdateCliVersion } from './update-version-notifier';
+
 const version = require('../package.json').version;
 
+cacheLatestVersion();
+
 yargs
   .version('version', 'Show version number.', version)
   .help('help', 'Show help.')
@@ -429,4 +433,5 @@ yargs
   )
   .completion('completion', 'Generate completion script.')
   .demandCommand(1)
+  .middleware([notifyUpdateCliVersion])
   .strict().argv;
diff --git a/packages/cli/src/update-version-notifier.ts b/packages/cli/src/update-version-notifier.ts
new file mode 100644
index 00000000..1a42998e
--- /dev/null
+++ b/packages/cli/src/update-version-notifier.ts
@@ -0,0 +1,103 @@
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
+import { compare } from 'semver';
+import fetch from 'node-fetch';
+import { cyan, green, yellow } from 'colorette';
+import { cleanColors } from './utils';
+
+const { version, name } = require('../package.json');
+
+const VERSION_CACHE_FILE = 'redocly-cli-version';
+const SPACE_TO_BORDER = 4;
+
+const INTERVAL_TO_CHECK = 1000 * 60 * 60 * 12;
+const SHOULD_NOT_NOTIFY =
+  process.env.NODE_ENV === 'test' || process.env.CI || !!process.env.LAMBDA_TASK_ROOT;
+
+export const notifyUpdateCliVersion = () => {
+  if (SHOULD_NOT_NOTIFY) {
+    return;
+  }
+  try {
+    const latestVersion = readFileSync(join(tmpdir(), VERSION_CACHE_FILE)).toString();
+
+    if (isNewVersionAvailable(version, latestVersion)) {
+      renderUpdateBanner(version, latestVersion);
+    }
+  } catch (e) {
+    return;
+  }
+};
+
+const isNewVersionAvailable = (current: string, latest: string) => compare(current, latest) < 0;
+
+const getLatestVersion = async (packageName: string): Promise<string> => {
+  const latestUrl = `http://registry.npmjs.org/${packageName}/latest`;
+  const response = await fetch(latestUrl);
+  const info = await response.json();
+  return info.version;
+};
+
+export const cacheLatestVersion = () => {
+  if (!isNeedToBeCached() || SHOULD_NOT_NOTIFY) {
+    return;
+  }
+
+  getLatestVersion(name)
+    .then((version) => {
+      const lastCheckFile = join(tmpdir(), VERSION_CACHE_FILE);
+      writeFileSync(lastCheckFile, version);
+    })
+    .catch(() => {});
+};
+
+const renderUpdateBanner = (current: string, latest: string) => {
+  const messageLines = [
+    `A new version of ${cyan('Redocly CLI')} (${green(latest)}) is available.`,
+    `Update now: \`${cyan('npm i -g @redocly/cli@latest')}\`.`,
+    `Changelog: https://redocly.com/docs/cli/changelog/`,
+  ];
+  const maxLength = Math.max(...messageLines.map((line) => cleanColors(line).length));
+
+  const border = yellow('═'.repeat(maxLength + SPACE_TO_BORDER));
+
+  const banner = `
+    ${yellow('╔' + border + '╗')}
+    ${yellow('║' + ' '.repeat(maxLength + SPACE_TO_BORDER) + '║')}
+    ${messageLines
+      .map((line, index) => {
+        return getLineWithPadding(maxLength, line, index);
+      })
+      .join('\n')}
+    ${yellow('║' + ' '.repeat(maxLength + SPACE_TO_BORDER) + '║')}
+    ${yellow('╚' + border + '╝')}
+  `;
+
+  process.stderr.write(banner);
+};
+
+const getLineWithPadding = (maxLength: number, line: string, index: number): string => {
+  const padding = ' '.repeat(maxLength - cleanColors(line).length);
+  const extraSpaces = index !== 0 ? ' '.repeat(SPACE_TO_BORDER) : '';
+  return `${extraSpaces}${yellow('║')}  ${line}${padding}  ${yellow('║')}`;
+};
+
+const isNeedToBeCached = (): boolean => {
+  try {
+    // Last version from npm is stored in a file in the OS temp folder
+    const versionFile = join(tmpdir(), VERSION_CACHE_FILE);
+
+    if (!existsSync(versionFile)) {
+      return true;
+    }
+
+    const now = new Date().getTime();
+    const stats = statSync(versionFile);
+    const lastCheck = stats.mtime.getTime();
+
+    return now - lastCheck >= INTERVAL_TO_CHECK;
+  } catch (e) {
+    return false;
+  }
+};
diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts
index f64e8010..6552321a 100644
--- a/packages/cli/src/utils.ts
+++ b/packages/cli/src/utils.ts
@@ -470,3 +470,8 @@ export function checkIfRulesetExist(rules: typeof StyleguideConfig.prototype.rul
     );
   }
 }
+
+export function cleanColors(input: string): string {
+  // eslint-disable-next-line no-control-regex
+  return input.replace(/\x1b\[\d+m/g, '');
+}


Test patch:
diff --git a/packages/cli/src/__tests__/utils.test.ts b/packages/cli/src/__tests__/utils.test.ts
index 6172bdb1..1aa2172e 100644
--- a/packages/cli/src/__tests__/utils.test.ts
+++ b/packages/cli/src/__tests__/utils.test.ts
@@ -8,6 +8,7 @@ import {
   handleError,
   CircularJSONNotSupportedError,
   sortTopLevelKeysForOas,
+  cleanColors,
 } from '../utils';
 import {
   ResolvedApi,
@@ -439,3 +440,12 @@ describe('checkIfRulesetExist', () => {
     expect(process.exit).not.toHaveBeenCalled();
   });
 });
+
+describe('cleanColors', () => {
+  it('should remove colors from string', () => {
+    const stringWithColors = `String for ${red('test')}`;
+    const result = cleanColors(stringWithColors);
+
+    expect(result).not.toMatch(/\x1b\[\d+m/g);
+  });
+});