From e00c92696e21ff055fb787d870d71288cde698a0 Mon Sep 17 00:00:00 2001 From: Jon Ursenbach Date: Mon, 8 Aug 2022 21:46:39 -0700 Subject: [PATCH 01/21] feat: converting most of the library over to TS --- .eslintignore | 1 + .eslintrc | 22 +- .gitignore | 3 +- .prettierignore | 1 + .../{apiError.test.js => apiError.test.ts} | 2 +- .../lib/{fetch.test.js => fetch.test.ts} | 9 +- ...Version.test.js => getNodeVersion.test.ts} | 8 +- ...test.js => isSupportedNodeVersion.test.ts} | 2 +- __tests__/tsconfig.json | 8 + bin/rdme | 55 +- jest.config.js | 23 + package-lock.json | 970 +++++++++++++++++- package.json | 42 +- src/cli.ts | 55 + src/cmds/categories/{create.js => create.ts} | 52 +- src/cmds/categories/{index.js => index.ts} | 27 +- src/cmds/changelogs/{index.js => index.ts} | 36 +- src/cmds/changelogs/{single.js => single.ts} | 33 +- src/cmds/custompages/{index.js => index.ts} | 27 +- src/cmds/custompages/{single.js => single.ts} | 32 +- src/cmds/docs/{edit.js => edit.ts} | 50 +- src/cmds/docs/{index.js => index.ts} | 38 +- src/cmds/docs/{single.js => single.ts} | 36 +- src/cmds/{login.js => login.ts} | 46 +- src/cmds/{logout.js => logout.ts} | 21 +- src/cmds/{oas.js => oas.ts} | 18 +- src/cmds/open.js | 36 - src/cmds/open.ts | 45 + src/cmds/{openapi.js => openapi.ts} | 81 +- src/cmds/{swagger.js => swagger.ts} | 17 +- src/cmds/{validate.js => validate.ts} | 31 +- src/cmds/versions/{create.js => create.ts} | 48 +- src/cmds/versions/{delete.js => delete.ts} | 32 +- src/cmds/versions/{index.js => index.ts} | 56 +- src/cmds/versions/{update.js => update.ts} | 56 +- src/cmds/{whoami.js => whoami.ts} | 23 +- src/{index.js => index.ts} | 27 +- src/lib/apiError.js | 35 - src/lib/apiError.ts | 50 + src/lib/baseCommand.ts | 51 + src/lib/{commands.js => commands.ts} | 148 +-- src/lib/configstore.js | 4 - src/lib/configstore.ts | 4 + src/lib/{fetch.js => fetch.ts} | 58 +- .../{getCategories.js => getCategories.ts} | 11 +- src/lib/getNodeVersion.js | 10 - src/lib/getNodeVersion.ts | 11 + src/lib/{help.js => help.ts} | 42 +- src/lib/isGitHub.js | 9 - src/lib/isGitHub.ts | 10 + ...deVersion.js => isSupportedNodeVersion.ts} | 8 +- src/lib/{logger.js => logger.ts} | 36 +- src/lib/{prepareOas.js => prepareOas.ts} | 18 +- src/lib/prompts.js | 176 ---- src/lib/prompts.ts | 231 +++++ src/lib/{pushDoc.js => pushDoc.ts} | 47 +- ...cToRegistry.js => streamSpecToRegistry.ts} | 19 +- .../{versionSelect.js => versionSelect.ts} | 31 +- src/typings.d.ts | 4 + tsconfig.json | 14 + 60 files changed, 2212 insertions(+), 884 deletions(-) rename __tests__/lib/{apiError.test.js => apiError.test.ts} (96%) rename __tests__/lib/{fetch.test.js => fetch.test.ts} (94%) rename __tests__/lib/{getNodeVersion.test.js => getNodeVersion.test.ts} (52%) rename __tests__/lib/{isSupportedNodeVersion.test.js => isSupportedNodeVersion.test.ts} (85%) create mode 100644 __tests__/tsconfig.json create mode 100644 jest.config.js create mode 100644 src/cli.ts rename src/cmds/categories/{create.js => create.ts} (75%) rename src/cmds/categories/{index.js => index.ts} (60%) rename src/cmds/changelogs/{index.js => index.ts} (72%) rename src/cmds/changelogs/{single.js => single.ts} (70%) rename src/cmds/custompages/{index.js => index.ts} (76%) rename src/cmds/custompages/{single.js => single.ts} (72%) rename src/cmds/docs/{edit.js => edit.ts} (78%) rename src/cmds/docs/{index.js => index.ts} (75%) rename src/cmds/docs/{single.js => single.ts} (73%) rename src/cmds/{login.js => login.ts} (73%) rename src/cmds/{logout.js => logout.ts} (53%) rename src/cmds/{oas.js => oas.ts} (63%) delete mode 100644 src/cmds/open.js create mode 100644 src/cmds/open.ts rename src/cmds/{openapi.js => openapi.ts} (78%) rename src/cmds/{swagger.js => swagger.ts} (52%) rename src/cmds/{validate.js => validate.ts} (58%) rename src/cmds/versions/{create.js => create.ts} (74%) rename src/cmds/versions/{delete.js => delete.ts} (64%) rename src/cmds/versions/{index.js => index.ts} (77%) rename src/cmds/versions/{update.js => update.ts} (67%) rename src/cmds/{whoami.js => whoami.ts} (58%) rename src/{index.js => index.ts} (88%) delete mode 100644 src/lib/apiError.js create mode 100644 src/lib/apiError.ts create mode 100644 src/lib/baseCommand.ts rename src/lib/{commands.js => commands.ts} (62%) delete mode 100644 src/lib/configstore.js create mode 100644 src/lib/configstore.ts rename src/lib/{fetch.js => fetch.ts} (77%) rename src/lib/{getCategories.js => getCategories.ts} (82%) delete mode 100644 src/lib/getNodeVersion.js create mode 100644 src/lib/getNodeVersion.ts rename src/lib/{help.js => help.ts} (77%) delete mode 100644 src/lib/isGitHub.js create mode 100644 src/lib/isGitHub.ts rename src/lib/{isSupportedNodeVersion.js => isSupportedNodeVersion.ts} (53%) rename src/lib/{logger.js => logger.ts} (63%) rename src/lib/{prepareOas.js => prepareOas.ts} (84%) delete mode 100644 src/lib/prompts.js create mode 100644 src/lib/prompts.ts rename src/lib/{pushDoc.js => pushDoc.ts} (82%) rename src/lib/{streamSpecToRegistry.js => streamSpecToRegistry.ts} (75%) rename src/lib/{versionSelect.js => versionSelect.ts} (58%) create mode 100644 src/typings.d.ts create mode 100644 tsconfig.json diff --git a/.eslintignore b/.eslintignore index 2d218f01a..d5af397e1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ coverage/ +dist/ !.alexrc.js diff --git a/.eslintrc b/.eslintrc index 7cb7f394d..6f43a3ddb 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,16 +1,36 @@ { - "extends": ["@readme/eslint-config"], + "extends": [ + "@readme/eslint-config", + "@readme/eslint-config/typescript" + ], "root": true, "parserOptions": { "ecmaVersion": 2020 }, "rules": { + "@typescript-eslint/ban-types": ["error", { + "types": { + // We specify `{}` in `CommandOptions` generics when those commands don't have their own + // options and it's cleaner for us to do that than `Record`. + "{}": false + } + }], + /** * Because our command classes have a `run` method that might not always call `this` we need to * explicitly exclude `run` from this rule. */ "class-methods-use-this": ["error", { "exceptMethods": ["run"] }], + "import/order": ["error", { + "alphabetize": { + "order": "asc", + "caseInsensitive": true + }, + "groups": ["type", "builtin", "external", "internal", "parent", "sibling", "index", "object"], + "newlines-between": "always" + }], + /** * This is a small rule to prevent us from using console.log() statements in our commands. * diff --git a/.gitignore b/.gitignore index 2492c9c9a..760e0be38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -node_modules/ coverage/ +dist/ +node_modules/ swagger.json diff --git a/.prettierignore b/.prettierignore index 404abb221..d64c4ca28 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ coverage/ +dist/ diff --git a/__tests__/lib/apiError.test.js b/__tests__/lib/apiError.test.ts similarity index 96% rename from __tests__/lib/apiError.test.js rename to __tests__/lib/apiError.test.ts index e9d05e16b..7e57ee2f2 100644 --- a/__tests__/lib/apiError.test.js +++ b/__tests__/lib/apiError.test.ts @@ -1,4 +1,4 @@ -const APIError = require('../../src/lib/apiError'); +import APIError from '../../src/lib/apiError'; const response = { error: 'VERSION_FORK_EMPTY', diff --git a/__tests__/lib/fetch.test.js b/__tests__/lib/fetch.test.ts similarity index 94% rename from __tests__/lib/fetch.test.js rename to __tests__/lib/fetch.test.ts index 29d10ad2b..0cfe12e7e 100644 --- a/__tests__/lib/fetch.test.js +++ b/__tests__/lib/fetch.test.ts @@ -1,8 +1,7 @@ -const config = require('config'); -const fetch = require('../../src/lib/fetch'); -const { cleanHeaders, handleRes } = require('../../src/lib/fetch'); -const getApiNock = require('../get-api-nock'); -const pkg = require('../../package.json'); +import config from 'config'; +import fetch, { cleanHeaders, handleRes } from '../../src/lib/fetch'; +import getApiNock from '../get-api-nock'; +import pkg from '../../package.json'; describe('#fetch()', () => { describe('GitHub Actions environment', () => { diff --git a/__tests__/lib/getNodeVersion.test.js b/__tests__/lib/getNodeVersion.test.ts similarity index 52% rename from __tests__/lib/getNodeVersion.test.js rename to __tests__/lib/getNodeVersion.test.ts index 2fc7c60d0..1a1eaf349 100644 --- a/__tests__/lib/getNodeVersion.test.js +++ b/__tests__/lib/getNodeVersion.test.ts @@ -1,11 +1,11 @@ -const getNodeVersion = require('../../src/lib/getNodeVersion'); -const pkg = require('../../package.json'); -const semver = require('semver'); +import getNodeVersion from '../../src/lib/getNodeVersion'; +import pkg from '../../package.json'; +import semver from 'semver'; describe('#getNodeVersion()', () => { it('should extract version that matches range in package.json', () => { const version = parseInt(getNodeVersion(), 10); const cleanedVersion = semver.valid(semver.coerce(version)); - expect(semver.satisfies(cleanedVersion, pkg.engines.node)).toBe(true); + expect(semver.satisfies(cleanedVersion as string, pkg.engines.node)).toBe(true); }); }); diff --git a/__tests__/lib/isSupportedNodeVersion.test.js b/__tests__/lib/isSupportedNodeVersion.test.ts similarity index 85% rename from __tests__/lib/isSupportedNodeVersion.test.js rename to __tests__/lib/isSupportedNodeVersion.test.ts index a7ae72c83..80f0e79a4 100644 --- a/__tests__/lib/isSupportedNodeVersion.test.js +++ b/__tests__/lib/isSupportedNodeVersion.test.ts @@ -1,4 +1,4 @@ -const isSupportedNodeVersion = require('../../src/lib/isSupportedNodeVersion'); +import isSupportedNodeVersion from '../../src/lib/isSupportedNodeVersion'; describe('#isSupportedNodeVersion()', () => { it('should return true for a supported version of node', () => { diff --git a/__tests__/tsconfig.json b/__tests__/tsconfig.json new file mode 100644 index 000000000..f2edeb969 --- /dev/null +++ b/__tests__/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "es6", + "moduleResolution": "node", + "noImplicitAny": false + } +} diff --git a/bin/rdme b/bin/rdme index e8c573a24..b1b75c68a 100755 --- a/bin/rdme +++ b/bin/rdme @@ -1,53 +1,2 @@ -#! /usr/bin/env node -const chalk = require('chalk'); -const core = require('@actions/core'); - -const updateNotifier = require('update-notifier'); -const pkg = require('../package.json'); - -const isGHA = require('../src/lib/isGitHub'); -const isSupportedNodeVersion = require('../src/lib/isSupportedNodeVersion'); - -updateNotifier({ pkg }).notify(); - -/** - * We use optional chaining throughout the library, which doesn't work on Node 12, so to curb - * support questions about why rdme is throwing an "Unexpected token '.'" error we should hard - * stop if we're being run with any Node version that we don't explicitly support. - */ -if (!isSupportedNodeVersion(process.version)) { - const message = `We're sorry, this release of rdme does not support Node.js ${process.version}. We support the following versions: ${pkg.engines.node}`; - // eslint-disable-next-line no-console - console.error(chalk.red(`\n${message}\n`)); - process.exit(1); -} - -require('../src')(process.argv.slice(2)) - .then(msg => { - // eslint-disable-next-line no-console - if (msg) console.log(msg); - return process.exit(0); - }) - .catch(err => { - let message = `Yikes, something went wrong! Please try again and if the problem persists, get in touch with our support team at ${chalk.underline( - 'support@readme.io' - )}.`; - - if (err.message) { - message = err.message; - } - - /** - * If we're in a GitHub Actions environment, log errors with that formatting instead. - * - * @see {@link https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message} - * @see {@link https://github.com/actions/toolkit/tree/main/packages/core#annotations} - */ - if (isGHA()) { - return core.setFailed(message); - } - - // eslint-disable-next-line no-console - console.error(chalk.red(`\n${message}\n`)); - return process.exit(1); - }); +#!/usr/bin/env node +require('../dist/bin'); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..76c1c2876 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,23 @@ +module.exports = { + coveragePathIgnorePatterns: ['/dist', '/node_modules'], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 90, + statements: 90, + }, + }, + globals: { + 'ts-jest': { + tsconfig: '__tests__/tsconfig.json', + }, + }, + modulePaths: [''], + preset: 'ts-jest/presets/js-with-ts', + roots: [''], + setupFiles: ['./__tests__/set-node-env'], + testPathIgnorePatterns: ['dist/', './__tests__/get-api-nock', './__tests__/set-node-env'], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js?|ts?)$', + transform: {}, +}; diff --git a/package-lock.json b/package-lock.json index cb3937c7d..c1d4cae93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,11 +40,26 @@ "devDependencies": { "@readme/eslint-config": "^9.0.0", "@readme/oas-examples": "^5.3.0", + "@types/cli-table": "^0.3.0", + "@types/command-line-args": "^5.2.0", + "@types/command-line-usage": "^5.0.2", + "@types/config": "^3.3.0", + "@types/jest": "^28.1.6", + "@types/mime-types": "^2.1.1", + "@types/node-fetch": "^2.6.2", + "@types/parse-link-header": "^2.0.0", + "@types/read": "^0.0.29", + "@types/semver": "^7.3.10", + "@types/update-notifier": "^6.0.1", "alex": "^10.0.0", "eslint": "^8.21.0", "jest": "^28.1.1", "nock": "^13.2.7", - "prettier": "^2.7.1" + "prettier": "^2.7.1", + "ts-jest": "^28.0.7", + "ts-node": "^10.9.1", + "type-fest": "^2.18.0", + "typescript": "^4.7.4" }, "engines": { "node": ">=14" @@ -665,6 +680,28 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@es-joy/jsdoccomment": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.28.0.tgz", @@ -1516,6 +1553,30 @@ "node": ">=6.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", + "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", + "dev": true + }, "node_modules/@types/acorn": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz", @@ -1572,6 +1633,24 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/cli-table": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@types/cli-table/-/cli-table-0.3.0.tgz", + "integrity": "sha512-QnZUISJJXyhyD6L1e5QwXDV/A5i2W1/gl6D6YMc8u0ncPepbv/B4w3S+izVvtAg60m6h+JP09+Y/0zF2mojlFQ==", + "dev": true + }, + "node_modules/@types/command-line-args": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.0.tgz", + "integrity": "sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==", + "dev": true + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.2.tgz", + "integrity": "sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==", + "dev": true + }, "node_modules/@types/concat-stream": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-1.6.1.tgz", @@ -1581,6 +1660,18 @@ "@types/node": "*" } }, + "node_modules/@types/config": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@types/config/-/config-3.3.0.tgz", + "integrity": "sha512-9kZSbl3/X3TVNowLCu5HFQdQmD+4287Om55avknEYkuo6R2dDrsp/EXEHUFvfYeG7m1eJ0WYGj+cbcUIhARJAQ==", + "dev": true + }, + "node_modules/@types/configstore": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/configstore/-/configstore-6.0.0.tgz", + "integrity": "sha512-GUvNiia85zTDDIx0iPrtF3pI8dwrQkfuokEqxqPDE55qxH0U5SZz4awVZjiJLWN2ZZRkXCUqgsMUbygXY+kytA==", + "dev": true + }, "node_modules/@types/debug": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", @@ -1653,6 +1744,49 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "28.1.6", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-28.1.6.tgz", + "integrity": "sha512-0RbGAFMfcBJKOmqRazM8L98uokwuwD5F8rHrv/ZMbrZBwVOWZUyPG6VFNscjYr/vjM3Vu4fRrCPbOs42AfemaQ==", + "dev": true, + "dependencies": { + "jest-matcher-utils": "^28.0.0", + "pretty-format": "^28.0.0" + } + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", + "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "dev": true, + "dependencies": { + "@jest/schemas": "^28.1.3", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, "node_modules/@types/js-yaml": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", @@ -1679,6 +1813,12 @@ "@types/unist": "*" } }, + "node_modules/@types/mime-types": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz", + "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==", + "dev": true + }, "node_modules/@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -1706,12 +1846,42 @@ "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==", "dev": true }, + "node_modules/@types/node-fetch": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz", + "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^3.0.0" + } + }, + "node_modules/@types/node-fetch/node_modules/form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, + "node_modules/@types/parse-link-header": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-link-header/-/parse-link-header-2.0.0.tgz", + "integrity": "sha512-KbqcQLdRaawDOfXnwqr6nvhe1MV+Uv/Ww+ViSx7Ujgw9X5qCgObLP52B1ZSJqZD8FK1y/4o+bJQTUrZOynegcg==", + "dev": true + }, "node_modules/@types/parse5": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", @@ -1724,6 +1894,18 @@ "integrity": "sha512-fOwvpvQYStpb/zHMx0Cauwywu9yLDmzWiiQBC7gJyq5tYLUXFZvDG7VK1B7WBxxjBJNKFOZ0zLoOQn8vmATbhw==", "dev": true }, + "node_modules/@types/read": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/read/-/read-0.0.29.tgz", + "integrity": "sha512-TisW3O3OhpP8/ZwaiqV7kewh9gnoH7PfqHd4hkCM9ogiqWEagu43WXpHWqgPbltXhembYJDpYB3cVwUIOweHXg==", + "dev": true + }, + "node_modules/@types/semver": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.10.tgz", + "integrity": "sha512-zsv3fsC7S84NN6nPK06u79oWgrPVd0NvOyqgghV1haPaFcVxIrP4DLomRwGAXk0ui4HZA7mOcSFL98sMVW9viw==", + "dev": true + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -1742,6 +1924,168 @@ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==", "dev": true }, + "node_modules/@types/update-notifier": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/update-notifier/-/update-notifier-6.0.1.tgz", + "integrity": "sha512-J6x9qtPDKgJGLdTjiswhhiL5QJoZv7eQb44t90mWb4Cf3tnuDRMvg8je9PoxDJZiGog0bfoFVcVHBq4RcItRZg==", + "dev": true, + "dependencies": { + "@types/configstore": "*", + "boxen": "^7.0.0" + } + }, + "node_modules/@types/update-notifier/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@types/update-notifier/node_modules/ansi-styles": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.1.0.tgz", + "integrity": "sha512-VbqNsoz55SYGczauuup0MFUyXNQviSpFTj1RQtFzmQLk18qbVSpTFFGMT293rmDaQuKCT6InmbuEyUne4mTuxQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/update-notifier/node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/update-notifier/node_modules/camelcase": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.0.tgz", + "integrity": "sha512-JToIvOmz6nhGsUhAYScbo2d6Py5wojjNfoxoc2mEVLUdJ70gJK2gnd+ABY1Tc3sVMyK7QDPtN0T/XdlCQWITyQ==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/update-notifier/node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@types/update-notifier/node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/update-notifier/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/@types/update-notifier/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/update-notifier/node_modules/strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@types/update-notifier/node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/update-notifier/node_modules/wrap-ansi": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.0.1.tgz", + "integrity": "sha512-QFF+ufAqhoYHvoHdajT/Po7KoXVBPXS2bgjIam5isfWJPfIOnQZ50JtUiVvCv/sjgacf3yRrt2ZKUZ/V4itN4g==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@types/yargs": { "version": "17.0.10", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz", @@ -1962,6 +2306,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2090,6 +2443,12 @@ "node": ">= 8" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "node_modules/argparse": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.9.tgz", @@ -2434,6 +2793,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/boxen/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", @@ -2483,6 +2853,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -3156,6 +3538,12 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -5066,6 +5454,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globalyzer": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", @@ -7362,6 +7762,12 @@ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=" }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7452,6 +7858,12 @@ "semver": "bin/semver" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -11254,26 +11666,130 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/trim-newlines": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.0.2.tgz", - "integrity": "sha512-GJtWyq9InR/2HRiLZgpIKv+ufIKrVrvjQWEj7PxAXNc5dwbNJkqhAUoAGgzRmULAnoOM5EIpveYd3J2VeSAIew==", + "node_modules/trim-newlines": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.0.2.tgz", + "integrity": "sha512-GJtWyq9InR/2HRiLZgpIKv+ufIKrVrvjQWEj7PxAXNc5dwbNJkqhAUoAGgzRmULAnoOM5EIpveYd3J2VeSAIew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/trough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.1.0.tgz", + "integrity": "sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-jest": { + "version": "28.0.7", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.7.tgz", + "integrity": "sha512-wWXCSmTwBVmdvWrOpYhal79bDpioDy4rTT+0vyUnE3ZzM7LOAAGG9NXwzkEL/a516rQEgnMmS/WKP9jBPCVJyA==", + "dev": true, + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^28.0.0", + "json5": "^2.2.1", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "7.x", + "yargs-parser": "^21.0.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/types": "^28.0.0", + "babel-jest": "^28.0.0", + "jest": "^28.0.0", + "typescript": ">=4.3" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "engines": { "node": ">=12" + } + }, + "node_modules/ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } } }, - "node_modules/trough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.1.0.tgz", - "integrity": "sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==", + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">=0.3.1" } }, "node_modules/tsconfig-paths": { @@ -11339,11 +11855,12 @@ } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.18.0.tgz", + "integrity": "sha512-pRS+/yrW5TjPPHNOvxhbNZexr2bS63WjrMU8a+VzEBhUi9Tz1pZeD+vQz3ut0svZ46P+SRqMEPnJmk2XnvNzTw==", + "dev": true, "engines": { - "node": ">=10" + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -11364,11 +11881,10 @@ } }, "node_modules/typescript": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz", - "integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==", + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11840,6 +12356,12 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "node_modules/v8-to-istanbul": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", @@ -12221,6 +12743,15 @@ "node": ">=12" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -12716,6 +13247,27 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "dependencies": { + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + } + } + }, "@es-joy/jsdoccomment": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.28.0.tgz", @@ -13395,6 +13947,30 @@ } } }, + "@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "@tsconfig/node16": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", + "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", + "dev": true + }, "@types/acorn": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz", @@ -13451,6 +14027,24 @@ "@babel/types": "^7.3.0" } }, + "@types/cli-table": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@types/cli-table/-/cli-table-0.3.0.tgz", + "integrity": "sha512-QnZUISJJXyhyD6L1e5QwXDV/A5i2W1/gl6D6YMc8u0ncPepbv/B4w3S+izVvtAg60m6h+JP09+Y/0zF2mojlFQ==", + "dev": true + }, + "@types/command-line-args": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.0.tgz", + "integrity": "sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==", + "dev": true + }, + "@types/command-line-usage": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.2.tgz", + "integrity": "sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==", + "dev": true + }, "@types/concat-stream": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-1.6.1.tgz", @@ -13460,6 +14054,18 @@ "@types/node": "*" } }, + "@types/config": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@types/config/-/config-3.3.0.tgz", + "integrity": "sha512-9kZSbl3/X3TVNowLCu5HFQdQmD+4287Om55avknEYkuo6R2dDrsp/EXEHUFvfYeG7m1eJ0WYGj+cbcUIhARJAQ==", + "dev": true + }, + "@types/configstore": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/configstore/-/configstore-6.0.0.tgz", + "integrity": "sha512-GUvNiia85zTDDIx0iPrtF3pI8dwrQkfuokEqxqPDE55qxH0U5SZz4awVZjiJLWN2ZZRkXCUqgsMUbygXY+kytA==", + "dev": true + }, "@types/debug": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", @@ -13532,6 +14138,42 @@ "@types/istanbul-lib-report": "*" } }, + "@types/jest": { + "version": "28.1.6", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-28.1.6.tgz", + "integrity": "sha512-0RbGAFMfcBJKOmqRazM8L98uokwuwD5F8rHrv/ZMbrZBwVOWZUyPG6VFNscjYr/vjM3Vu4fRrCPbOs42AfemaQ==", + "dev": true, + "requires": { + "jest-matcher-utils": "^28.0.0", + "pretty-format": "^28.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "pretty-format": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", + "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "dev": true, + "requires": { + "@jest/schemas": "^28.1.3", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + } + }, + "react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + } + } + }, "@types/js-yaml": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.5.tgz", @@ -13558,6 +14200,12 @@ "@types/unist": "*" } }, + "@types/mime-types": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz", + "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==", + "dev": true + }, "@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -13585,12 +14233,41 @@ "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==", "dev": true }, + "@types/node-fetch": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz", + "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==", + "dev": true, + "requires": { + "@types/node": "*", + "form-data": "^3.0.0" + }, + "dependencies": { + "form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + } + } + }, "@types/normalize-package-data": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, + "@types/parse-link-header": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-link-header/-/parse-link-header-2.0.0.tgz", + "integrity": "sha512-KbqcQLdRaawDOfXnwqr6nvhe1MV+Uv/Ww+ViSx7Ujgw9X5qCgObLP52B1ZSJqZD8FK1y/4o+bJQTUrZOynegcg==", + "dev": true + }, "@types/parse5": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", @@ -13603,6 +14280,18 @@ "integrity": "sha512-fOwvpvQYStpb/zHMx0Cauwywu9yLDmzWiiQBC7gJyq5tYLUXFZvDG7VK1B7WBxxjBJNKFOZ0zLoOQn8vmATbhw==", "dev": true }, + "@types/read": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/read/-/read-0.0.29.tgz", + "integrity": "sha512-TisW3O3OhpP8/ZwaiqV7kewh9gnoH7PfqHd4hkCM9ogiqWEagu43WXpHWqgPbltXhembYJDpYB3cVwUIOweHXg==", + "dev": true + }, + "@types/semver": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.10.tgz", + "integrity": "sha512-zsv3fsC7S84NN6nPK06u79oWgrPVd0NvOyqgghV1haPaFcVxIrP4DLomRwGAXk0ui4HZA7mOcSFL98sMVW9viw==", + "dev": true + }, "@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -13621,6 +14310,110 @@ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==", "dev": true }, + "@types/update-notifier": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/update-notifier/-/update-notifier-6.0.1.tgz", + "integrity": "sha512-J6x9qtPDKgJGLdTjiswhhiL5QJoZv7eQb44t90mWb4Cf3tnuDRMvg8je9PoxDJZiGog0bfoFVcVHBq4RcItRZg==", + "dev": true, + "requires": { + "@types/configstore": "*", + "boxen": "^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true + }, + "ansi-styles": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.1.0.tgz", + "integrity": "sha512-VbqNsoz55SYGczauuup0MFUyXNQviSpFTj1RQtFzmQLk18qbVSpTFFGMT293rmDaQuKCT6InmbuEyUne4mTuxQ==", + "dev": true + }, + "boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "requires": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + } + }, + "camelcase": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.0.tgz", + "integrity": "sha512-JToIvOmz6nhGsUhAYScbo2d6Py5wojjNfoxoc2mEVLUdJ70gJK2gnd+ABY1Tc3sVMyK7QDPtN0T/XdlCQWITyQ==", + "dev": true + }, + "chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true + }, + "cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true + }, + "emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "requires": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + } + }, + "strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "dev": true, + "requires": { + "ansi-regex": "^6.0.1" + } + }, + "widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "requires": { + "string-width": "^5.0.1" + } + }, + "wrap-ansi": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.0.1.tgz", + "integrity": "sha512-QFF+ufAqhoYHvoHdajT/Po7KoXVBPXS2bgjIam5isfWJPfIOnQZ50JtUiVvCv/sjgacf3yRrt2ZKUZ/V4itN4g==", + "dev": true, + "requires": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + } + } + } + }, "@types/yargs": { "version": "17.0.10", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz", @@ -13744,6 +14537,12 @@ "dev": true, "requires": {} }, + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true + }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -13837,6 +14636,12 @@ "picomatch": "^2.0.4" } }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "argparse": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.9.tgz", @@ -14077,6 +14882,11 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==" } } }, @@ -14110,6 +14920,15 @@ "update-browserslist-db": "^1.0.5" } }, + "bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "requires": { + "fast-json-stable-stringify": "2.x" + } + }, "bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -14592,6 +15411,12 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -15988,6 +16813,14 @@ "dev": true, "requires": { "type-fest": "^0.20.2" + }, + "dependencies": { + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + } } }, "globalyzer": { @@ -17676,6 +18509,12 @@ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=" }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -17740,6 +18579,12 @@ } } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -20531,6 +21376,59 @@ "integrity": "sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==", "dev": true }, + "ts-jest": { + "version": "28.0.7", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.7.tgz", + "integrity": "sha512-wWXCSmTwBVmdvWrOpYhal79bDpioDy4rTT+0vyUnE3ZzM7LOAAGG9NXwzkEL/a516rQEgnMmS/WKP9jBPCVJyA==", + "dev": true, + "requires": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^28.0.0", + "json5": "^2.2.1", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "7.x", + "yargs-parser": "^21.0.1" + }, + "dependencies": { + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true + } + } + }, + "ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "requires": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "dependencies": { + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + } + } + }, "tsconfig-paths": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", @@ -20581,9 +21479,10 @@ "dev": true }, "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==" + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.18.0.tgz", + "integrity": "sha512-pRS+/yrW5TjPPHNOvxhbNZexr2bS63WjrMU8a+VzEBhUi9Tz1pZeD+vQz3ut0svZ46P+SRqMEPnJmk2XnvNzTw==", + "dev": true }, "typedarray": { "version": "0.0.6", @@ -20600,11 +21499,10 @@ } }, "typescript": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz", - "integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==", - "dev": true, - "peer": true + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "dev": true }, "typical": { "version": "5.2.0", @@ -20944,6 +21842,12 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "v8-to-istanbul": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", @@ -21225,6 +22129,12 @@ "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index a1926f699..18409103c 100644 --- a/package.json +++ b/package.json @@ -62,36 +62,38 @@ "devDependencies": { "@readme/eslint-config": "^9.0.0", "@readme/oas-examples": "^5.3.0", + "@types/cli-table": "^0.3.0", + "@types/command-line-args": "^5.2.0", + "@types/command-line-usage": "^5.0.2", + "@types/config": "^3.3.0", + "@types/jest": "^28.1.6", + "@types/mime-types": "^2.1.1", + "@types/node-fetch": "^2.6.2", + "@types/parse-link-header": "^2.0.0", + "@types/read": "^0.0.29", + "@types/semver": "^7.3.10", + "@types/update-notifier": "^6.0.1", "alex": "^10.0.0", "eslint": "^8.21.0", "jest": "^28.1.1", "nock": "^13.2.7", - "prettier": "^2.7.1" + "prettier": "^2.7.1", + "ts-jest": "^28.0.7", + "ts-node": "^10.9.1", + "type-fest": "^2.18.0", + "typescript": "^4.7.4" }, "scripts": { - "lint": "eslint . bin/rdme bin/set-version-output", + "build": "tsc", + "debug:bin": "node -r ts-node/register src/bin.ts", + "lint": "eslint . bin/rdme bin/set-version-output --ext .js,.ts", "lint-docs": "alex .", + "prebuild": "rm -rf dist/", + "prepack": "npm run build", "pretest": "npm run lint && npm run lint-docs", - "prettier": "prettier --list-different --write \"./**/**.js\"", + "prettier": "prettier --list-different --write \"./**/**.{js,ts}\"", "release": "npx conventional-changelog-cli -i CHANGELOG.md -s", "test": "jest --coverage" }, - "jest": { - "coverageThreshold": { - "global": { - "branches": 80, - "functions": 80, - "lines": 90, - "statements": 90 - } - }, - "setupFiles": [ - "./__tests__/set-node-env" - ], - "testPathIgnorePatterns": [ - "./__tests__/get-api-nock", - "./__tests__/set-node-env" - ] - }, "prettier": "@readme/eslint-config/prettier" } diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 000000000..bd3980cbd --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,55 @@ +#! /usr/bin/env node +import chalk from 'chalk'; +import core from '@actions/core'; + +import updateNotifier from 'update-notifier'; +import pkg from '../package.json'; + +import isGHA from './lib/isGitHub'; +import isSupportedNodeVersion from './lib/isSupportedNodeVersion'; + +import rdme from '.'; + +updateNotifier({ pkg }).notify(); + +/** + * We use optional chaining throughout the library, which doesn't work on Node 12, so to curb + * support questions about why rdme is throwing an "Unexpected token '.'" error we should hard + * stop if we're being run with any Node version that we don't explicitly support. + */ +if (!isSupportedNodeVersion(process.version)) { + const message = `We're sorry, this release of rdme does not support Node.js ${process.version}. We support the following versions: ${pkg.engines.node}`; + // eslint-disable-next-line no-console + console.error(chalk.red(`\n${message}\n`)); + process.exit(1); +} + +rdme(process.argv.slice(2)) + .then((msg: string) => { + // eslint-disable-next-line no-console + if (msg) console.log(msg); + return process.exit(0); + }) + .catch((err: Error) => { + let message = `Yikes, something went wrong! Please try again and if the problem persists, get in touch with our support team at ${chalk.underline( + 'support@readme.io' + )}.`; + + if (err.message) { + message = err.message; + } + + /** + * If we're in a GitHub Actions environment, log errors with that formatting instead. + * + * @see {@link https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message} + * @see {@link https://github.com/actions/toolkit/tree/main/packages/core#annotations} + */ + if (isGHA()) { + return core.setFailed(message); + } + + // eslint-disable-next-line no-console + console.error(chalk.red(`\n${message}\n`)); + return process.exit(1); + }); diff --git a/src/cmds/categories/create.js b/src/cmds/categories/create.ts similarity index 75% rename from src/cmds/categories/create.js rename to src/cmds/categories/create.ts index 4d01594d2..0c600dd83 100644 --- a/src/cmds/categories/create.js +++ b/src/cmds/categories/create.ts @@ -1,20 +1,36 @@ -const chalk = require('chalk'); -const { cleanHeaders, handleRes } = require('../../lib/fetch'); -const config = require('config'); -const { debug } = require('../../lib/logger'); -const fetch = require('../../lib/fetch'); -const getCategories = require('../../lib/getCategories'); -const { getProjectVersion } = require('../../lib/versionSelect'); - -module.exports = class CategoriesCreateCommand { +import type { CommandOptions } from '../../lib/baseCommand'; + +import chalk from 'chalk'; +import config from 'config'; + +import Command, { CommandCategories } from '../../lib/baseCommand'; +import fetch, { cleanHeaders, handleRes } from '../../lib/fetch'; +import getCategories from '../../lib/getCategories'; +import { debug } from '../../lib/logger'; +import { getProjectVersion } from '../../lib/versionSelect'; + +interface Category { + title: string; + type: string; +} + +export type Options = { + categoryType: 'guide' | 'reference'; + title: string; + preventDuplicates: boolean; +}; + +export default class CategoriesCreateCommand extends Command { constructor() { + super(); + this.command = 'categories:create'; this.usage = 'categories:create [options]'; this.description = 'Create a category with the specified title and guide in your ReadMe project'; - this.cmdCategory = 'categories'; + this.cmdCategory = CommandCategories.CATEGORIES; this.position = 2; - this.hiddenargs = ['title']; + this.hiddenArgs = ['title']; this.args = [ { name: 'key', @@ -45,14 +61,10 @@ module.exports = class CategoriesCreateCommand { ]; } - async run(opts) { - const { categoryType, title, key, version, preventDuplicates } = opts; - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); + async run(opts: CommandOptions<Options>) { + super.run(opts, true); - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } + const { categoryType, title, key, version, preventDuplicates } = opts; if (!title) { return Promise.reject(new Error(`No title provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); @@ -69,7 +81,7 @@ module.exports = class CategoriesCreateCommand { async function matchCategory() { const allCategories = await getCategories(key, selectedVersion); - return allCategories.find(category => { + return allCategories.find((category: Category) => { return category.title.trim().toLowerCase() === title.trim().toLowerCase() && category.type === categoryType; }); } @@ -104,4 +116,4 @@ module.exports = class CategoriesCreateCommand { return Promise.resolve(createdCategory); } -}; +} diff --git a/src/cmds/categories/index.js b/src/cmds/categories/index.ts similarity index 60% rename from src/cmds/categories/index.js rename to src/cmds/categories/index.ts index 116b1cd54..3a8176e7a 100644 --- a/src/cmds/categories/index.js +++ b/src/cmds/categories/index.ts @@ -1,13 +1,18 @@ -const { debug } = require('../../lib/logger'); -const { getProjectVersion } = require('../../lib/versionSelect'); -const getCategories = require('../../lib/getCategories'); +import type { CommandOptions } from '../../lib/baseCommand'; -module.exports = class CategoriesCommand { +import Command, { CommandCategories } from '../../lib/baseCommand'; +import getCategories from '../../lib/getCategories'; +import { debug } from '../../lib/logger'; +import { getProjectVersion } from '../../lib/versionSelect'; + +export default class CategoriesCommand extends Command { constructor() { + super(); + this.command = 'categories'; this.usage = 'categories [options]'; this.description = 'Get all categories in your ReadMe project'; - this.cmdCategory = 'categories'; + this.cmdCategory = CommandCategories.CATEGORIES; this.position = 1; this.args = [ @@ -24,15 +29,11 @@ module.exports = class CategoriesCommand { ]; } - async run(opts) { - const { key, version } = opts; + async run(opts: CommandOptions<{}>) { + super.run(opts, true); - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); + const { key, version } = opts; - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } const selectedVersion = await getProjectVersion(version, key, true); debug(`selectedVersion: ${selectedVersion}`); @@ -41,4 +42,4 @@ module.exports = class CategoriesCommand { return Promise.resolve(JSON.stringify(allCategories, null, 2)); } -}; +} diff --git a/src/cmds/changelogs/index.js b/src/cmds/changelogs/index.ts similarity index 72% rename from src/cmds/changelogs/index.js rename to src/cmds/changelogs/index.ts index df61fe01d..bb963e51c 100644 --- a/src/cmds/changelogs/index.js +++ b/src/cmds/changelogs/index.ts @@ -1,16 +1,25 @@ -const chalk = require('chalk'); -const config = require('config'); +import type { CommandOptions } from '../../lib/baseCommand'; -const { debug } = require('../../lib/logger'); -const pushDoc = require('../../lib/pushDoc'); -const { readdirRecursive } = require('../../lib/pushDoc'); +import chalk from 'chalk'; +import config from 'config'; -module.exports = class ChangelogsCommand { +import Command, { CommandCategories } from '../../lib/baseCommand'; +import { debug } from '../../lib/logger'; +import pushDoc, { readdirRecursive } from '../../lib/pushDoc'; + +export type Options = { + dryRun: boolean; + folder: string; +}; + +export default class ChangelogsCommand extends Command { constructor() { + super(); + this.command = 'changelogs'; this.usage = 'changelogs <folder> [options]'; this.description = 'Sync a folder of Markdown files to your ReadMe project as Changelog posts.'; - this.cmdCategory = 'changelogs'; + this.cmdCategory = CommandCategories.CHANGELOGS; this.position = 1; this.hiddenArgs = ['folder']; @@ -33,15 +42,10 @@ module.exports = class ChangelogsCommand { ]; } - async run(opts) { - const { dryRun, folder, key } = opts; + async run(opts: CommandOptions<Options>) { + super.run(opts, true); - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); - - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } + const { dryRun, folder, key } = opts; if (!folder) { return Promise.reject(new Error(`No folder provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); @@ -66,4 +70,4 @@ module.exports = class ChangelogsCommand { return chalk.green(updatedDocs.join('\n')); } -}; +} diff --git a/src/cmds/changelogs/single.js b/src/cmds/changelogs/single.ts similarity index 70% rename from src/cmds/changelogs/single.js rename to src/cmds/changelogs/single.ts index b13f63d9c..b6a27156e 100644 --- a/src/cmds/changelogs/single.js +++ b/src/cmds/changelogs/single.ts @@ -1,15 +1,24 @@ -const chalk = require('chalk'); -const config = require('config'); +import type { CommandOptions } from '../../lib/baseCommand'; -const { debug } = require('../../lib/logger'); -const pushDoc = require('../../lib/pushDoc'); +import chalk from 'chalk'; +import config from 'config'; -module.exports = class SingleChangelogCommand { +import Command, { CommandCategories } from '../../lib/baseCommand'; +import pushDoc from '../../lib/pushDoc'; + +export type Options = { + dryRun: boolean; + filePath: string; +}; + +export default class SingleChangelogCommand extends Command { constructor() { + super(); + this.command = 'changelogs:single'; this.usage = 'changelogs:single <file> [options]'; this.description = 'Sync a single Markdown file to your ReadMe project as a Changelog post.'; - this.cmdCategory = 'changelogs'; + this.cmdCategory = CommandCategories.CHANGELOGS; this.position = 2; this.hiddenArgs = ['filePath']; @@ -32,14 +41,10 @@ module.exports = class SingleChangelogCommand { ]; } - async run(opts) { - const { dryRun, filePath, key } = opts; - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); + async run(opts: CommandOptions<Options>) { + super.run(opts, true); - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } + const { dryRun, filePath, key } = opts; if (!filePath) { return Promise.reject(new Error(`No file path provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); @@ -53,4 +58,4 @@ module.exports = class SingleChangelogCommand { return chalk.green(createdDoc); } -}; +} diff --git a/src/cmds/custompages/index.js b/src/cmds/custompages/index.ts similarity index 76% rename from src/cmds/custompages/index.js rename to src/cmds/custompages/index.ts index 36fe4f27f..ff803d40e 100644 --- a/src/cmds/custompages/index.js +++ b/src/cmds/custompages/index.ts @@ -1,16 +1,25 @@ -const chalk = require('chalk'); -const config = require('config'); +import type { CommandOptions } from '../../lib/baseCommand'; -const { debug } = require('../../lib/logger'); -const pushDoc = require('../../lib/pushDoc'); -const { readdirRecursive } = require('../../lib/pushDoc'); +import chalk from 'chalk'; +import config from 'config'; -module.exports = class CustomPagesCommand { +import Command, { CommandCategories } from '../../lib/baseCommand'; +import { debug } from '../../lib/logger'; +import pushDoc, { readdirRecursive } from '../../lib/pushDoc'; + +export type Options = { + dryRun: boolean; + folder: string; +}; + +export default class CustomPagesCommand extends Command { constructor() { + super(); + this.command = 'custompages'; this.usage = 'custompages <folder> [options]'; this.description = 'Sync a folder of Markdown files to your ReadMe project as Custom Pages.'; - this.cmdCategory = 'custompages'; + this.cmdCategory = CommandCategories.CUSTOM_PAGES; this.position = 1; this.hiddenArgs = ['folder']; @@ -33,7 +42,7 @@ module.exports = class CustomPagesCommand { ]; } - async run(opts) { + async run(opts: CommandOptions<Options>) { const { dryRun, folder, key } = opts; debug(`command: ${this.command}`); @@ -69,4 +78,4 @@ module.exports = class CustomPagesCommand { return chalk.green(updatedDocs.join('\n')); } -}; +} diff --git a/src/cmds/custompages/single.js b/src/cmds/custompages/single.ts similarity index 72% rename from src/cmds/custompages/single.js rename to src/cmds/custompages/single.ts index 39392054f..1aaa7bb94 100644 --- a/src/cmds/custompages/single.js +++ b/src/cmds/custompages/single.ts @@ -1,15 +1,23 @@ -const chalk = require('chalk'); -const config = require('config'); +import type { CommandOptions } from '../../lib/baseCommand'; -const { debug } = require('../../lib/logger'); -const pushDoc = require('../../lib/pushDoc'); +import chalk from 'chalk'; +import config from 'config'; -module.exports = class SingleCustomPageCommand { +import Command, { CommandCategories } from '../../lib/baseCommand'; +import pushDoc from '../../lib/pushDoc'; + +export type Options = { + dryRun: boolean; + filePath: string; +}; + +export default class SingleCustomPageCommand extends Command { constructor() { + super(); this.command = 'custompages:single'; this.usage = 'custompages:single <file> [options]'; this.description = 'Sync a single Markdown file to your ReadMe project as a Custom Page.'; - this.cmdCategory = 'custompages'; + this.cmdCategory = CommandCategories.CUSTOM_PAGES; this.position = 2; this.hiddenArgs = ['filePath']; @@ -32,14 +40,10 @@ module.exports = class SingleCustomPageCommand { ]; } - async run(opts) { - const { dryRun, filePath, key } = opts; - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); + async run(opts: CommandOptions<Options>) { + super.run(opts, true); - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } + const { dryRun, filePath, key } = opts; if (!filePath) { return Promise.reject(new Error(`No file path provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); @@ -59,4 +63,4 @@ module.exports = class SingleCustomPageCommand { return chalk.green(createdDoc); } -}; +} diff --git a/src/cmds/docs/edit.js b/src/cmds/docs/edit.ts similarity index 78% rename from src/cmds/docs/edit.js rename to src/cmds/docs/edit.ts index 2fccae025..8630e4430 100644 --- a/src/cmds/docs/edit.js +++ b/src/cmds/docs/edit.ts @@ -1,23 +1,34 @@ -const config = require('config'); -const fs = require('fs'); -const editor = require('editor'); -const { promisify } = require('util'); -const APIError = require('../../lib/apiError'); -const { getProjectVersion } = require('../../lib/versionSelect'); -const fetch = require('../../lib/fetch'); -const { cleanHeaders, handleRes } = require('../../lib/fetch'); -const { debug, info } = require('../../lib/logger'); +import type { CommandOptions } from '../../lib/baseCommand'; + +import fs from 'fs'; +import { promisify } from 'util'; + +import config from 'config'; +import editor from 'editor'; + +import APIError from '../../lib/apiError'; +import Command, { CommandCategories } from '../../lib/baseCommand'; +import fetch, { cleanHeaders, handleRes } from '../../lib/fetch'; +import { debug, info } from '../../lib/logger'; +import { getProjectVersion } from '../../lib/versionSelect'; const writeFile = promisify(fs.writeFile); const readFile = promisify(fs.readFile); const unlink = promisify(fs.unlink); -module.exports = class EditDocsCommand { +export type Options = { + mockEditor?: boolean; + slug: string; +}; + +export default class EditDocsCommand extends Command { constructor() { + super(); + this.command = 'docs:edit'; this.usage = 'docs:edit <slug> [options]'; this.description = 'Edit a single file from your ReadMe project without saving locally.'; - this.cmdCategory = 'docs'; + this.cmdCategory = CommandCategories.DOCS; this.position = 2; this.hiddenArgs = ['slug']; @@ -40,15 +51,10 @@ module.exports = class EditDocsCommand { ]; } - async run(opts) { - const { slug, key, version } = opts; - - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); + async run(opts: CommandOptions<Options>) { + super.run(opts, true); - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } + const { slug, key, version } = opts; if (!slug) { return Promise.reject(new Error(`No slug provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); @@ -73,7 +79,7 @@ module.exports = class EditDocsCommand { debug(`wrote to local file: ${filename}, opening editor`); return new Promise((resolve, reject) => { - (opts.mockEditor || editor)(filename, async code => { + (opts.mockEditor || editor)(filename, async (code: number) => { debug(`editor closed with code ${code}`); if (code !== 0) return reject(new Error('Non zero exit code from $EDITOR')); const updatedDoc = await readFile(filename, 'utf8'); @@ -107,9 +113,9 @@ module.exports = class EditDocsCommand { // Normally we should resolve with a value that is logged to the console, // but since we need to wait for the temporary file to be removed, // it's okay to resolve the promise with no value. - return resolve(); + return resolve(true); }); }); }); } -}; +} diff --git a/src/cmds/docs/index.js b/src/cmds/docs/index.ts similarity index 75% rename from src/cmds/docs/index.js rename to src/cmds/docs/index.ts index 3ddd47d56..10c02277d 100644 --- a/src/cmds/docs/index.js +++ b/src/cmds/docs/index.ts @@ -1,17 +1,26 @@ -const chalk = require('chalk'); -const config = require('config'); +import type { CommandOptions } from '../../lib/baseCommand'; -const { getProjectVersion } = require('../../lib/versionSelect'); -const { debug } = require('../../lib/logger'); -const pushDoc = require('../../lib/pushDoc'); -const { readdirRecursive } = require('../../lib/pushDoc'); +import chalk from 'chalk'; +import config from 'config'; -module.exports = class DocsCommand { +import Command, { CommandCategories } from '../../lib/baseCommand'; +import { debug } from '../../lib/logger'; +import pushDoc, { readdirRecursive } from '../../lib/pushDoc'; +import { getProjectVersion } from '../../lib/versionSelect'; + +export type Options = { + dryRun: boolean; + folder: string; +}; + +export default class DocsCommand extends Command { constructor() { + super(); + this.command = 'docs'; this.usage = 'docs <folder> [options]'; this.description = 'Sync a folder of Markdown files to your ReadMe project.'; - this.cmdCategory = 'docs'; + this.cmdCategory = CommandCategories.DOCS; this.position = 1; this.hiddenArgs = ['folder']; @@ -39,15 +48,10 @@ module.exports = class DocsCommand { ]; } - async run(opts) { - const { dryRun, folder, key, version } = opts; + async run(opts: CommandOptions<Options>) { + super.run(opts, true); - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); - - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } + const { dryRun, folder, key, version } = opts; if (!folder) { return Promise.reject(new Error(`No folder provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); @@ -79,4 +83,4 @@ module.exports = class DocsCommand { return chalk.green(updatedDocs.join('\n')); } -}; +} diff --git a/src/cmds/docs/single.js b/src/cmds/docs/single.ts similarity index 73% rename from src/cmds/docs/single.js rename to src/cmds/docs/single.ts index 5949bd688..8b71109bf 100644 --- a/src/cmds/docs/single.js +++ b/src/cmds/docs/single.ts @@ -1,16 +1,26 @@ -const chalk = require('chalk'); -const config = require('config'); +import type { CommandOptions } from '../../lib/baseCommand'; -const { debug } = require('../../lib/logger'); -const { getProjectVersion } = require('../../lib/versionSelect'); -const pushDoc = require('../../lib/pushDoc'); +import chalk from 'chalk'; +import config from 'config'; -module.exports = class SingleDocCommand { +import Command, { CommandCategories } from '../../lib/baseCommand'; +import { debug } from '../../lib/logger'; +import pushDoc from '../../lib/pushDoc'; +import { getProjectVersion } from '../../lib/versionSelect'; + +export type Options = { + dryRun: boolean; + filePath: string; +}; + +export default class SingleDocCommand extends Command { constructor() { + super(); + this.command = 'docs:single'; this.usage = 'docs:single <file> [options]'; this.description = 'Sync a single Markdown file to your ReadMe project.'; - this.cmdCategory = 'docs'; + this.cmdCategory = CommandCategories.DOCS; this.position = 3; this.hiddenArgs = ['filePath']; @@ -38,14 +48,10 @@ module.exports = class SingleDocCommand { ]; } - async run(opts) { - const { dryRun, filePath, key, version } = opts; - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); + async run(opts: CommandOptions<Options>) { + super.run(opts, true); - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } + const { dryRun, filePath, key, version } = opts; if (!filePath) { return Promise.reject(new Error(`No file path provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); @@ -66,4 +72,4 @@ module.exports = class SingleDocCommand { return chalk.green(createdDoc); } -}; +} diff --git a/src/cmds/login.js b/src/cmds/login.ts similarity index 73% rename from src/cmds/login.js rename to src/cmds/login.ts index 7e4ca9b30..5a97c16b8 100644 --- a/src/cmds/login.js +++ b/src/cmds/login.ts @@ -1,21 +1,36 @@ -const chalk = require('chalk'); -const config = require('config'); -const { validate: isEmail } = require('isemail'); -const { promisify } = require('util'); -const read = promisify(require('read')); -const configStore = require('../lib/configstore'); -const fetch = require('../lib/fetch'); -const { handleRes } = require('../lib/fetch'); -const { debug } = require('../lib/logger'); +import type { CommandOptions } from '../lib/baseCommand'; + +import { promisify } from 'util'; + +import chalk from 'chalk'; +import config from 'config'; +import { validate as isEmail } from 'isemail'; +import readPkg from 'read'; + +import Command, { CommandCategories } from '../lib/baseCommand'; +import configStore from '../lib/configstore'; +import fetch, { handleRes } from '../lib/fetch'; + +const read = promisify(readPkg); const testing = process.env.NODE_ENV === 'testing'; -module.exports = class LoginCommand { +export type Options = { + '2fa': string; + email: string; + password: string; + project: string; + token: string; +}; + +export default class LoginCommand extends Command { constructor() { + super(); + this.command = 'login'; this.usage = 'login [options]'; this.description = 'Login to a ReadMe project.'; - this.cmdCategory = 'admin'; + this.cmdCategory = CommandCategories.ADMIN; this.position = 1; this.args = [ @@ -32,11 +47,10 @@ module.exports = class LoginCommand { ]; } - async run(opts) { - let { email, password, project, token } = opts; + async run(opts: CommandOptions<Options>) { + super.run(opts); - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); + let { email, password, project, token } = opts; /* istanbul ignore next */ async function getCredentials() { @@ -81,4 +95,4 @@ module.exports = class LoginCommand { return `Successfully logged in as ${chalk.green(email)} to the ${chalk.blue(project)} project.`; }); } -}; +} diff --git a/src/cmds/logout.js b/src/cmds/logout.ts similarity index 53% rename from src/cmds/logout.js rename to src/cmds/logout.ts index a7f5f9bf5..ebe0448f5 100644 --- a/src/cmds/logout.js +++ b/src/cmds/logout.ts @@ -1,20 +1,25 @@ -const config = require('config'); -const configStore = require('../lib/configstore'); -const { debug } = require('../lib/logger'); +import type { CommandOptions } from '../lib/baseCommand'; -module.exports = class LogoutCommand { +import config from 'config'; + +import Command, { CommandCategories } from '../lib/baseCommand'; +import configStore from '../lib/configstore'; + +export default class LogoutCommand extends Command { constructor() { + super(); + this.command = 'logout'; this.usage = 'logout'; this.description = 'Logs the currently authenticated user out of ReadMe.'; - this.cmdCategory = 'admin'; + this.cmdCategory = CommandCategories.ADMIN; this.position = 2; this.args = []; } - async run() { - debug(`command: ${this.command}`); + async run(opts: CommandOptions<{}>) { + super.run(opts); if (configStore.has('email') && configStore.has('project')) { configStore.clear(); @@ -22,4 +27,4 @@ module.exports = class LogoutCommand { return Promise.resolve(`You have logged out of ReadMe. Please use \`${config.get('cli')} login\` to login again.`); } -}; +} diff --git a/src/cmds/oas.js b/src/cmds/oas.ts similarity index 63% rename from src/cmds/oas.js rename to src/cmds/oas.ts index 664092e7d..0f0534907 100644 --- a/src/cmds/oas.js +++ b/src/cmds/oas.ts @@ -1,23 +1,29 @@ -const { debug } = require('../lib/logger'); +import type { CommandOptions } from '../lib/baseCommand'; -module.exports = class OASCommand { +import Command, { CommandCategories } from '../lib/baseCommand'; + +export default class OASCommand extends Command { constructor() { + super(); + this.command = 'oas'; this.usage = 'oas'; this.description = 'Helpful OpenAPI generation tooling. [inactive]'; - this.cmdCategory = 'utilities'; + this.cmdCategory = CommandCategories.UTILITIES; this.position = 1; this.args = []; } - async run() { - debug(`command: ${this.command}`); + async run(opts: CommandOptions<{}>) { + super.run(opts); + const message = [ 'This `oas` integration is now inactive.', "If you're looking to use the `oas` CLI directly, head over to https://npm.im/oas.", "If you're looking to create an OpenAPI definition, we recommend https://npm.im/swagger-inline", ]; + return Promise.reject(new Error(message.join('\n\n'))); } -}; +} diff --git a/src/cmds/open.js b/src/cmds/open.js deleted file mode 100644 index d6cf20bf1..000000000 --- a/src/cmds/open.js +++ /dev/null @@ -1,36 +0,0 @@ -const chalk = require('chalk'); -const config = require('config'); -const open = require('open'); -const configStore = require('../lib/configstore'); -const { debug } = require('../lib/logger'); - -module.exports = class OpenCommand { - constructor() { - this.command = 'open'; - this.usage = 'open'; - this.description = 'Open your current ReadMe project in the browser.'; - this.cmdCategory = 'utilities'; - this.position = 2; - - this.args = []; - } - - async run(opts) { - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); - - const project = configStore.get('project'); - debug(`project: ${project}`); - - if (!project) { - return Promise.reject(new Error(`Please login using \`${config.get('cli')} login\`.`)); - } - - const url = config.get('hub').replace('{project}', project); - - return (opts.mockOpen || open)(url, { - wait: false, - url: true, - }).then(() => Promise.resolve(`Opening ${chalk.green(url)} in your browser...`)); - } -}; diff --git a/src/cmds/open.ts b/src/cmds/open.ts new file mode 100644 index 000000000..3fbbe03b5 --- /dev/null +++ b/src/cmds/open.ts @@ -0,0 +1,45 @@ +import type { CommandOptions } from '../lib/baseCommand'; + +import chalk from 'chalk'; +import config from 'config'; +import open from 'open'; + +import Command, { CommandCategories } from '../lib/baseCommand'; +import configStore from '../lib/configstore'; +import { debug } from '../lib/logger'; + +export type Options = { + mockOpen?: typeof open; +}; + +export default class OpenCommand extends Command { + constructor() { + super(); + + this.command = 'open'; + this.usage = 'open'; + this.description = 'Open your current ReadMe project in the browser.'; + this.cmdCategory = CommandCategories.UTILITIES; + this.position = 2; + + this.args = []; + } + + async run(opts: CommandOptions<Options>) { + super.run(opts); + + const project = configStore.get('project'); + debug(`project: ${project}`); + + if (!project) { + return Promise.reject(new Error(`Please login using \`${config.get('cli')} login\`.`)); + } + + const hubURL: string = config.get('hub'); + const url = hubURL.replace('{project}', project); + + return (opts.mockOpen || open)(url, { + wait: false, + }).then(() => Promise.resolve(`Opening ${chalk.green(url)} in your browser...`)); + } +} diff --git a/src/cmds/openapi.js b/src/cmds/openapi.ts similarity index 78% rename from src/cmds/openapi.js rename to src/cmds/openapi.ts index 6eedde65b..6e316eeef 100644 --- a/src/cmds/openapi.js +++ b/src/cmds/openapi.ts @@ -1,24 +1,36 @@ -const APIError = require('../lib/apiError'); -const chalk = require('chalk'); -const { cleanHeaders } = require('../lib/fetch'); -const config = require('config'); -const { debug, warn, oraOptions } = require('../lib/logger'); -const fetch = require('../lib/fetch'); -const { handleRes } = require('../lib/fetch'); -const { getProjectVersion } = require('../lib/versionSelect'); -const ora = require('ora'); -const parse = require('parse-link-header'); -const prepareOas = require('../lib/prepareOas'); -const { prompt } = require('enquirer'); -const promptOpts = require('../lib/prompts'); -const streamSpecToRegistry = require('../lib/streamSpecToRegistry'); - -module.exports = class OpenAPICommand { +import type { CommandOptions } from '../lib/baseCommand'; +import type { RequestInit, Response } from 'node-fetch'; + +import chalk from 'chalk'; +import config from 'config'; +import { prompt } from 'enquirer'; +import ora from 'ora'; +import parse from 'parse-link-header'; + +import APIError from '../lib/apiError'; +import Command, { CommandCategories } from '../lib/baseCommand'; +import fetch, { cleanHeaders, handleRes } from '../lib/fetch'; +import { debug, warn, oraOptions } from '../lib/logger'; +import prepareOas from '../lib/prepareOas'; +import * as promptOpts from '../lib/prompts'; +import streamSpecToRegistry from '../lib/streamSpecToRegistry'; +import { getProjectVersion } from '../lib/versionSelect'; + +export type Options = { + id: string; + spec: string; + version: string; + workingDirectory: string; +}; + +export default class OpenAPICommand extends Command { constructor() { + super(); + this.command = 'openapi'; this.usage = 'openapi [file] [options]'; this.description = 'Upload, or resync, your OpenAPI/Swagger definition to ReadMe.'; - this.cmdCategory = 'apis'; + this.cmdCategory = CommandCategories.APIS; this.position = 1; this.hiddenArgs = ['spec']; @@ -52,14 +64,13 @@ module.exports = class OpenAPICommand { ]; } - async run(opts) { + async run(opts: CommandOptions<Options>) { + super.run(opts, true); const { key, id, spec, version, workingDirectory } = opts; - let selectedVersion; - let isUpdate; - const spinner = ora({ ...oraOptions() }); - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); + let selectedVersion: string; + let isUpdate: boolean; + const spinner = ora({ ...oraOptions() }); if (workingDirectory) { process.chdir(workingDirectory); @@ -69,10 +80,6 @@ module.exports = class OpenAPICommand { warn("We'll be using the version associated with the `--id` option, so the `--version` option will be ignored."); } - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } - if (!id) { selectedVersion = await getProjectVersion(version, key, true); } @@ -83,7 +90,7 @@ module.exports = class OpenAPICommand { // relies on this and we don't want to use `swagger` in this function const { bundledSpec, specPath, specType } = await prepareOas(spec, 'openapi'); - async function success(data) { + async function success(data: Response) { const message = !isUpdate ? `You've successfully uploaded a new ${specType} file to your ReadMe project!` : `You've successfully updated an existing ${specType} file on your ReadMe project!`; @@ -106,7 +113,7 @@ module.exports = class OpenAPICommand { ); } - async function error(res) { + async function error(res: Response) { return handleRes(res).catch(err => { // If we receive an APIError, no changes needed! Throw it as is. if (err instanceof APIError) { @@ -133,7 +140,7 @@ module.exports = class OpenAPICommand { const registryUUID = await streamSpecToRegistry(bundledSpec); - const options = { + const options: RequestInit = { headers: cleanHeaders(key, { Accept: 'application/json', 'Content-Type': 'application/json', @@ -155,7 +162,7 @@ module.exports = class OpenAPICommand { }); } - function updateSpec(specId) { + function updateSpec(specId: string) { isUpdate = true; options.method = 'put'; spinner.start('Updating your API docs in ReadMe...'); @@ -177,7 +184,7 @@ module.exports = class OpenAPICommand { - If found, prompt user to either create a new spec or update an existing one */ - function getSpecs(url) { + function getSpecs(url: string) { return fetch(`${config.get('host')}${url}`, { method: 'get', headers: cleanHeaders(key, { @@ -190,7 +197,7 @@ module.exports = class OpenAPICommand { debug('no id parameter, retrieving list of API specs'); const apiSettings = await getSpecs('/api/v1/api-specification'); - const totalPages = Math.ceil(apiSettings.headers.get('x-total-count') / 10); + const totalPages = Math.ceil(parseInt(apiSettings.headers.get('x-total-count'), 10) / 10); const parsedDocs = parse(apiSettings.headers.get('link')); debug(`total pages: ${totalPages}`); debug(`pagination result: ${JSON.stringify(parsedDocs)}`); @@ -199,7 +206,11 @@ module.exports = class OpenAPICommand { debug(`api settings list response payload: ${JSON.stringify(apiSettingsBody)}`); if (!apiSettingsBody.length) return createSpec(); - const { option } = await prompt(promptOpts.createOasPrompt(apiSettingsBody, parsedDocs, totalPages, getSpecs)); + const { option }: { option: 'create' | 'update' } = await prompt( + // @ts-expect-error `getSpecs` is getting double type in as `Promise<Promise<Response>>` for some reason. + promptOpts.createOasPrompt(apiSettingsBody, parsedDocs, totalPages, getSpecs) + ); + debug(`selection result: ${option}`); if (!option) return null; return option === 'create' ? createSpec() : updateSpec(option); @@ -211,4 +222,4 @@ module.exports = class OpenAPICommand { */ return updateSpec(id); } -}; +} diff --git a/src/cmds/swagger.js b/src/cmds/swagger.ts similarity index 52% rename from src/cmds/swagger.js rename to src/cmds/swagger.ts index e25053698..01379fc66 100644 --- a/src/cmds/swagger.js +++ b/src/cmds/swagger.ts @@ -1,7 +1,11 @@ -const OpenAPICommand = require('./openapi'); -const { debug, warn } = require('../lib/logger'); +import type { CommandOptions } from '../lib/baseCommand'; +import type { Options } from './openapi'; -module.exports = class SwaggerCommand extends OpenAPICommand { +import { warn } from '../lib/logger'; + +import OpenAPICommand from './openapi'; + +export default class SwaggerCommand extends OpenAPICommand { constructor() { super(); @@ -11,11 +15,8 @@ module.exports = class SwaggerCommand extends OpenAPICommand { this.position += 1; } - async run(opts) { - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); - + async run(opts: CommandOptions<Options>) { warn('`rdme swagger` has been deprecated. Please use `rdme openapi` instead.'); return super.run(opts); } -}; +} diff --git a/src/cmds/validate.js b/src/cmds/validate.ts similarity index 58% rename from src/cmds/validate.js rename to src/cmds/validate.ts index 0d3a0b07e..8d9501c7f 100644 --- a/src/cmds/validate.js +++ b/src/cmds/validate.ts @@ -1,13 +1,23 @@ -const chalk = require('chalk'); -const { debug } = require('../lib/logger'); -const prepareOas = require('../lib/prepareOas'); +import type { CommandOptions } from '../lib/baseCommand'; -module.exports = class ValidateCommand { +import chalk from 'chalk'; + +import Command, { CommandCategories } from '../lib/baseCommand'; +import prepareOas from '../lib/prepareOas'; + +export type Options = { + spec: string; + workingDirectory: string; +}; + +export default class ValidateCommand extends Command { constructor() { + super(); + this.command = 'validate'; this.usage = 'validate [file] [options]'; this.description = 'Validate your OpenAPI/Swagger definition.'; - this.cmdCategory = 'apis'; + this.cmdCategory = CommandCategories.APIS; this.position = 2; this.hiddenArgs = ['spec']; @@ -25,17 +35,16 @@ module.exports = class ValidateCommand { ]; } - async run(opts) { + async run(opts: CommandOptions<Options>) { + super.run(opts); + const { spec, workingDirectory } = opts; if (workingDirectory) { process.chdir(workingDirectory); } - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); - - const { specPath, specType } = await prepareOas(spec, this.command); + const { specPath, specType } = await prepareOas(spec, 'validate'); return Promise.resolve(chalk.green(`${specPath} is a valid ${specType} API definition!`)); } -}; +} diff --git a/src/cmds/versions/create.js b/src/cmds/versions/create.ts similarity index 74% rename from src/cmds/versions/create.js rename to src/cmds/versions/create.ts index 250ee8bec..b93bbb83e 100644 --- a/src/cmds/versions/create.js +++ b/src/cmds/versions/create.ts @@ -1,17 +1,29 @@ -const config = require('config'); -const semver = require('semver'); -const { prompt } = require('enquirer'); -const promptOpts = require('../../lib/prompts'); -const fetch = require('../../lib/fetch'); -const { cleanHeaders, handleRes } = require('../../lib/fetch'); -const { debug } = require('../../lib/logger'); +import type { CommandOptions } from '../../lib/baseCommand'; -module.exports = class CreateVersionCommand { +import config from 'config'; +import { prompt } from 'enquirer'; +import semver from 'semver'; + +import Command, { CommandCategories } from '../../lib/baseCommand'; +import fetch, { cleanHeaders, handleRes } from '../../lib/fetch'; +import * as promptOpts from '../../lib/prompts'; + +export type Options = { + fork: string; + codename: string; + main: string; + beta: string; + isPublic: string; +}; + +export default class CreateVersionCommand extends Command { constructor() { + super(); + this.command = 'versions:create'; this.usage = 'versions:create --version=<version> [options]'; this.description = 'Create a new version for your project.'; - this.cmdCategory = 'versions'; + this.cmdCategory = CommandCategories.VERSIONS; this.position = 2; this.hiddenArgs = ['version']; @@ -54,17 +66,12 @@ module.exports = class CreateVersionCommand { ]; } - async run(opts) { + async run(opts: CommandOptions<Options>) { + super.run(opts, true); + let versionList; const { key, version, codename, fork, main, beta, isPublic } = opts; - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); - - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } - if (!version || !semver.valid(semver.coerce(version))) { return Promise.reject( new Error(`Please specify a semantic version. See \`${config.get('cli')} help ${this.command}\` for help.`) @@ -83,7 +90,10 @@ module.exports = class CreateVersionCommand { ...opts, }); - const promptResponse = await prompt(versionPrompt); + const promptResponse: { from: string; is_beta: string; is_hidden: string; is_stable: string } = await prompt( + // @ts-expect-error Seems like our version prompts aren't what Enquirer actually expects. + versionPrompt + ); return fetch(`${config.get('host')}/api/v1/version`, { method: 'post', @@ -105,4 +115,4 @@ module.exports = class CreateVersionCommand { return Promise.resolve(`Version ${version} created successfully.`); }); } -}; +} diff --git a/src/cmds/versions/delete.js b/src/cmds/versions/delete.ts similarity index 64% rename from src/cmds/versions/delete.js rename to src/cmds/versions/delete.ts index 8721a011f..77416cc7b 100644 --- a/src/cmds/versions/delete.js +++ b/src/cmds/versions/delete.ts @@ -1,15 +1,20 @@ -const config = require('config'); -const { getProjectVersion } = require('../../lib/versionSelect'); -const fetch = require('../../lib/fetch'); -const { cleanHeaders, handleRes } = require('../../lib/fetch'); -const { debug } = require('../../lib/logger'); +import type { CommandOptions } from '../../lib/baseCommand'; -module.exports = class DeleteVersionCommand { +import config from 'config'; + +import Command, { CommandCategories } from '../../lib/baseCommand'; +import fetch, { cleanHeaders, handleRes } from '../../lib/fetch'; +import { debug } from '../../lib/logger'; +import { getProjectVersion } from '../../lib/versionSelect'; + +export default class DeleteVersionCommand extends Command { constructor() { + super(); + this.command = 'versions:delete'; this.usage = 'versions:delete --version=<version> [options]'; this.description = 'Delete a version associated with your ReadMe project.'; - this.cmdCategory = 'versions'; + this.cmdCategory = CommandCategories.VERSIONS; this.position = 4; this.hiddenArgs = ['version']; @@ -27,15 +32,10 @@ module.exports = class DeleteVersionCommand { ]; } - async run(opts) { - const { key, version } = opts; - - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); + async run(opts: CommandOptions<{}>) { + super.run(opts, true); - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } + const { key, version } = opts; const selectedVersion = await getProjectVersion(version, key, false).catch(e => { return Promise.reject(e); @@ -52,4 +52,4 @@ module.exports = class DeleteVersionCommand { return Promise.resolve(`Version ${selectedVersion} deleted successfully.`); }); } -}; +} diff --git a/src/cmds/versions/index.js b/src/cmds/versions/index.ts similarity index 77% rename from src/cmds/versions/index.js rename to src/cmds/versions/index.ts index a199bfa22..335de5388 100644 --- a/src/cmds/versions/index.js +++ b/src/cmds/versions/index.ts @@ -1,17 +1,37 @@ -const chalk = require('chalk'); -const Table = require('cli-table'); -const config = require('config'); -const CreateVersionCmd = require('./create'); -const fetch = require('../../lib/fetch'); -const { cleanHeaders, handleRes } = require('../../lib/fetch'); -const { debug } = require('../../lib/logger'); - -module.exports = class VersionsCommand { +import type { CommandOptions } from '../../lib/baseCommand'; + +import chalk from 'chalk'; +import Table from 'cli-table'; +import config from 'config'; + +import Command, { CommandCategories } from '../../lib/baseCommand'; +import fetch, { cleanHeaders, handleRes } from '../../lib/fetch'; + +import CreateVersionCmd from './create'; + +interface Version { + codename?: string; + createdAt: string; + is_beta: boolean; + is_deprecated: boolean; + is_hidden: boolean; + is_stable: boolean; + releaseDate: string; + version: string; +} + +export type Options = { + raw: boolean; +}; + +export default class VersionsCommand extends Command { constructor() { + super(); + this.command = 'versions'; this.usage = 'versions [options]'; this.description = 'List versions available in your project or get a version by SemVer (https://semver.org/).'; - this.cmdCategory = 'versions'; + this.cmdCategory = CommandCategories.VERSIONS; this.position = 1; this.args = [ @@ -33,7 +53,7 @@ module.exports = class VersionsCommand { ]; } - static getVersionsAsTable(versions) { + static getVersionsAsTable(versions: Version[]) { const table = new Table({ head: [ chalk.bold('Version'), @@ -61,7 +81,7 @@ module.exports = class VersionsCommand { return table.toString(); } - static getVersionFormatted(version) { + static getVersionFormatted(version: Version) { const output = [ `${chalk.bold('Version:')} ${version.version}`, `${chalk.bold('Codename:')} ${version.codename || 'None'}`, @@ -85,16 +105,10 @@ module.exports = class VersionsCommand { return output.join('\n'); } - async run(opts) { + async run(opts: CommandOptions<Options>) { + super.run(opts, true); const { key, version, raw } = opts; - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); - - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } - const uri = version ? `${config.get('host')}/api/v1/version/${version}` : `${config.get('host')}/api/v1/version`; return fetch(uri, { @@ -129,4 +143,4 @@ module.exports = class VersionsCommand { return Promise.resolve(VersionsCommand.getVersionFormatted(versions[0])); }); } -}; +} diff --git a/src/cmds/versions/update.js b/src/cmds/versions/update.ts similarity index 67% rename from src/cmds/versions/update.js rename to src/cmds/versions/update.ts index 613d0f1fc..78de4a46a 100644 --- a/src/cmds/versions/update.js +++ b/src/cmds/versions/update.ts @@ -1,17 +1,31 @@ -const config = require('config'); -const { prompt } = require('enquirer'); -const promptOpts = require('../../lib/prompts'); -const { getProjectVersion } = require('../../lib/versionSelect'); -const fetch = require('../../lib/fetch'); -const { cleanHeaders, handleRes } = require('../../lib/fetch'); -const { debug } = require('../../lib/logger'); +import type { CommandOptions } from '../../lib/baseCommand'; -module.exports = class UpdateVersionCommand { +import config from 'config'; +import { prompt } from 'enquirer'; + +import Command, { CommandCategories } from '../../lib/baseCommand'; +import fetch, { cleanHeaders, handleRes } from '../../lib/fetch'; +import { debug } from '../../lib/logger'; +import * as promptOpts from '../../lib/prompts'; +import { getProjectVersion } from '../../lib/versionSelect'; + +export type Options = { + beta: string; + codename: string; + deprecated: string; + isPublic: string; + main: string; + newVersion: string; +}; + +export default class UpdateVersionCommand extends Command { constructor() { + super(); + this.command = 'versions:update'; this.usage = 'versions:update --version=<version> [options]'; this.description = 'Update an existing version for your project.'; - this.cmdCategory = 'versions'; + this.cmdCategory = CommandCategories.VERSIONS; this.position = 3; this.args = [ @@ -48,15 +62,10 @@ module.exports = class UpdateVersionCommand { ]; } - async run(opts) { - const { key, version, codename, newVersion, main, beta, isPublic, deprecated } = opts; - - debug(`command: ${this.command}`); - debug(`opts: ${JSON.stringify(opts)}`); + async run(opts: CommandOptions<Options>) { + super.run(opts, true); - if (!key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } + const { key, version, codename, newVersion, main, beta, isPublic, deprecated } = opts; const selectedVersion = await getProjectVersion(version, key, false).catch(e => { return Promise.reject(e); @@ -69,7 +78,16 @@ module.exports = class UpdateVersionCommand { headers: cleanHeaders(key), }).then(res => handleRes(res)); - const promptResponse = await prompt(promptOpts.createVersionPrompt([{}], opts, foundVersion)); + const promptResponse: { + is_beta: string; + is_deprecated: string; + is_hidden: string; + is_stable: string; + newVersion: string; + } = await prompt( + // @ts-expect-error Seems like our version prompts aren't what Enquirer actually expects. + promptOpts.createVersionPrompt([{}], opts, foundVersion) + ); return fetch(`${config.get('host')}/api/v1/version/${selectedVersion}`, { method: 'put', @@ -91,4 +109,4 @@ module.exports = class UpdateVersionCommand { return Promise.resolve(`Version ${selectedVersion} updated successfully.`); }); } -}; +} diff --git a/src/cmds/whoami.js b/src/cmds/whoami.ts similarity index 58% rename from src/cmds/whoami.js rename to src/cmds/whoami.ts index f03b9aaa2..fec952000 100644 --- a/src/cmds/whoami.js +++ b/src/cmds/whoami.ts @@ -1,21 +1,26 @@ -const chalk = require('chalk'); -const config = require('config'); -const configStore = require('../lib/configstore'); -const { debug } = require('../lib/logger'); +import type { CommandOptions } from '../lib/baseCommand'; -module.exports = class WhoAmICommand { +import chalk from 'chalk'; +import config from 'config'; + +import Command, { CommandCategories } from '../lib/baseCommand'; +import configStore from '../lib/configstore'; + +export default class WhoAmICommand extends Command { constructor() { + super(); + this.command = 'whoami'; this.usage = 'whoami'; this.description = 'Displays the current user and project authenticated with ReadMe.'; - this.cmdCategory = 'admin'; + this.cmdCategory = CommandCategories.ADMIN; this.position = 3; this.args = []; } - async run() { - debug(`command: ${this.command}`); + async run(opts: CommandOptions<{}>) { + super.run(opts); if (!configStore.has('email') || !configStore.has('project')) { return Promise.reject(new Error(`Please login using \`${config.get('cli')} login\`.`)); @@ -27,4 +32,4 @@ module.exports = class WhoAmICommand { )} project.` ); } -}; +} diff --git a/src/index.js b/src/index.ts similarity index 88% rename from src/index.js rename to src/index.ts index 7bb0d4094..6b3974ae5 100644 --- a/src/index.js +++ b/src/index.ts @@ -1,7 +1,9 @@ -/* eslint-disable no-underscore-dangle */ -const chalk = require('chalk'); -const cliArgs = require('command-line-args'); -const path = require('path'); +/* eslint-disable import/first, import/order, no-underscore-dangle */ +import path from 'path'; + +import chalk from 'chalk'; +import cliArgs from 'command-line-args'; + // We have to do this otherwise `require('config')` loads // from the cwd where the user is running `rdme` which // wont be what we want @@ -12,22 +14,23 @@ const path = require('path'); const configDir = process.env.NODE_CONFIG_DIR; process.env.NODE_CONFIG_DIR = path.join(__dirname, '../config'); -const config = require('config'); +import config from 'config'; process.env.NODE_CONFIG_DIR = configDir; -const { version } = require('../package.json'); -const configStore = require('./lib/configstore'); -const help = require('./lib/help'); -const commands = require('./lib/commands'); -const { debug } = require('./lib/logger'); +import { version } from '../package.json'; + +import * as commands from './lib/commands'; +import configStore from './lib/configstore'; +import * as help from './lib/help'; +import { debug } from './lib/logger'; /** * @param {Array} processArgv - An array of arguments from the current process. Can be used to mock * fake CLI calls. * @return {Promise} */ -module.exports = processArgv => { +export default function rdme(processArgv: NodeJS.Process['argv']) { const mainArgs = [ { name: 'help', alias: 'h', type: Boolean, description: 'Display this usage guide' }, { @@ -123,4 +126,4 @@ module.exports = processArgv => { return Promise.reject(e); } -}; +} diff --git a/src/lib/apiError.js b/src/lib/apiError.js deleted file mode 100644 index 4f41b48b1..000000000 --- a/src/lib/apiError.js +++ /dev/null @@ -1,35 +0,0 @@ -module.exports = class extends Error { - constructor(res) { - let err; - - // Special handling to for fetch `res` arguments where `res.error` will contain our API error response. - if (typeof res === 'object') { - if (typeof res?.error === 'object') { - err = res.error; - } else { - err = res; - } - } else { - err = res; - } - - super(err); - - if (typeof err === 'object') { - this.code = err.error; - - // If we returned help info in the API, show it otherwise don't render out multiple empty lines as we sometimes - // throw `Error('non-api custom error message')` instances and catch them with this class. - if (err?.help) { - this.message = [err.message, '', err.help].join('\n'); - } else { - this.message = err.message; - } - - this.name = 'APIError'; - } else { - this.code = err; - this.message = err; - } - } -}; diff --git a/src/lib/apiError.ts b/src/lib/apiError.ts new file mode 100644 index 000000000..106b870d6 --- /dev/null +++ b/src/lib/apiError.ts @@ -0,0 +1,50 @@ +type APIErrorResponse = { + error: string; + message: string; + suggestion: string; + docs: string; + help: string; + poem: string[]; +}; + +export default class APIError extends Error { + code: string; + + constructor(res: string | APIErrorResponse | { error: APIErrorResponse }) { + let err: string | APIErrorResponse; + + // Special handling to for fetch `res` arguments where `res.error` will contain our API error + // response. + if (typeof res === 'object') { + if (typeof res?.error === 'object') { + err = res.error; + } else { + err = res as APIErrorResponse; + } + } else { + err = res; + } + + super(err as unknown as string); + + this.name = 'APIError'; + + if (typeof err === 'object') { + this.code = err.error; + + // If we returned help info in the API, show it otherwise don't render out multiple empty + // lines as we sometimes throw `Error('non-api custom error message')` instances and catch + // them with this class. + if (err?.help) { + this.message = [err.message, '', err.help].join('\n'); + } else { + this.message = err.message; + } + + this.name = 'APIError'; + } else { + this.code = err; + this.message = err; + } + } +} diff --git a/src/lib/baseCommand.ts b/src/lib/baseCommand.ts new file mode 100644 index 000000000..88d5e088b --- /dev/null +++ b/src/lib/baseCommand.ts @@ -0,0 +1,51 @@ +import { debug } from './logger'; + +export type CommandOptions<T> = T & { + key: string; + version: string; +}; + +export enum CommandCategories { + ADMIN = 'admin', + APIS = 'apis', + CATEGORIES = 'categories', + CHANGELOGS = 'changelogs', + CUSTOM_PAGES = 'custompages', + DOCS = 'docs', + UTILITIES = 'utilities', + VERSIONS = 'versions', +} + +export default class Command { + command: string; + + usage: string; + + description: string; + + cmdCategory: CommandCategories; + + position: number; + + hiddenArgs: string[] = []; + + args: { + name: string; + alias?: string; + type: BooleanConstructor | StringConstructor; + description?: string; + defaultOption?: boolean; + }[]; + + // eslint-disable-next-line consistent-return + async run(opts: CommandOptions<{}>, requiresAuth?: boolean): Promise<any> { + debug(`command: ${this.command}`); + debug(`opts: ${JSON.stringify(opts)}`); + + if (requiresAuth) { + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + } + } +} diff --git a/src/lib/commands.js b/src/lib/commands.ts similarity index 62% rename from src/lib/commands.js rename to src/lib/commands.ts index 6f30e6d1f..19e9d887b 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.ts @@ -1,72 +1,19 @@ -const fs = require('fs'); -const path = require('path'); +import type Command from './baseCommand'; +import type { CommandCategories } from './baseCommand'; +import fs from 'fs'; +import path from 'path'; -exports.load = cmd => { - let command = cmd; - let subcommand = ''; - if (cmd.includes(':')) { - [command, subcommand] = cmd.split(':'); - } - - const file = path.join(__dirname, '../cmds', command, subcommand); - try { - // eslint-disable-next-line global-require, import/no-dynamic-require - const Command = require(file); - return new Command(); - } catch (e) { - throw new Error('Command not found.'); +export function getCategories(): Record< + string, + { + description: string; + commands: { + name: string; + description: string; + position: number; + }[]; } -}; - -exports.listByCategory = () => { - const categories = exports.getCategories(); - const cmds = exports.list(); - cmds.forEach(c => { - categories[c.command.cmdCategory].commands.push({ - name: c.command.command, - description: c.command.description, - position: c.command.position, - }); - }); - - return categories; -}; - -exports.getSimilar = (cmdCategory, excludeCommand) => { - const categories = exports.listByCategory(); - return categories[cmdCategory].commands.filter(cmd => cmd.name !== excludeCommand); -}; - -exports.list = () => { - const commands = []; - const cmdDir = `${__dirname}/../cmds`; - const files = fs - .readdirSync(cmdDir) - .map(file => { - const stats = fs.statSync(path.join(cmdDir, file)); - if (stats.isDirectory()) { - return fs.readdirSync(path.join(cmdDir, file)).map(f => path.join(file, f)); - } - return [file]; - }) - .reduce((a, b) => a.concat(b), []) - .filter(file => file.endsWith('.js')) - .map(file => path.join(cmdDir, file)); - - files.forEach(file => { - // eslint-disable-next-line global-require, import/no-dynamic-require - const Command = require(file); - - commands.push({ - file, - command: new Command(), - }); - }); - - return commands; -}; - -exports.getCategories = () => { +> { return { apis: { description: 'Upload OpenAPI/Swagger definitions', @@ -101,4 +48,69 @@ exports.getCategories = () => { commands: [], }, }; -}; +} + +export function list() { + const commands: { file: string; command: Command }[] = []; + const cmdDir = `${__dirname}/../cmds`; + const files = fs + .readdirSync(cmdDir) + .map(file => { + const stats = fs.statSync(path.join(cmdDir, file)); + if (stats.isDirectory()) { + return fs.readdirSync(path.join(cmdDir, file)).map(f => path.join(file, f)); + } + return [file]; + }) + .reduce((a, b) => a.concat(b), []) + .filter(file => file.endsWith('.js')) + .map(file => path.join(cmdDir, file)); + + files.forEach(file => { + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-dynamic-require + const CommandClass = require(file); + + commands.push({ + file, + command: new CommandClass(), + }); + }); + + return commands; +} + +export function load(cmd: string) { + let command = cmd; + let subcommand = ''; + if (cmd.includes(':')) { + [command, subcommand] = cmd.split(':'); + } + + const file = path.join(__dirname, '../cmds', command, subcommand); + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-dynamic-require + const CommandClass = require(file); + return new CommandClass(); + } catch (e) { + throw new Error('Command not found.'); + } +} + +export function listByCategory() { + const categories = getCategories(); + const cmds = list(); + cmds.forEach(c => { + categories[c.command.cmdCategory].commands.push({ + name: c.command.command, + description: c.command.description, + position: c.command.position, + }); + }); + + return categories; +} + +export function getSimilar(cmdCategory: CommandCategories, excludeCommand: string) { + const categories = listByCategory(); + return categories[cmdCategory].commands.filter(cmd => cmd.name !== excludeCommand); +} diff --git a/src/lib/configstore.js b/src/lib/configstore.js deleted file mode 100644 index 645fc4160..000000000 --- a/src/lib/configstore.js +++ /dev/null @@ -1,4 +0,0 @@ -const Configstore = require('configstore'); -const pkg = require('../../package.json'); - -module.exports = new Configstore(`${pkg.name}-${process.env.NODE_ENV || 'production'}`); diff --git a/src/lib/configstore.ts b/src/lib/configstore.ts new file mode 100644 index 000000000..20fd8890d --- /dev/null +++ b/src/lib/configstore.ts @@ -0,0 +1,4 @@ +import Configstore from 'configstore'; +import pkg from '../../package.json'; + +export default new Configstore(`${pkg.name}-${process.env.NODE_ENV || 'production'}`); diff --git a/src/lib/fetch.js b/src/lib/fetch.ts similarity index 77% rename from src/lib/fetch.js rename to src/lib/fetch.ts index 3bab01792..44f946f6d 100644 --- a/src/lib/fetch.js +++ b/src/lib/fetch.ts @@ -1,19 +1,35 @@ /* eslint-disable no-param-reassign */ -const { debug } = require('./logger'); -const fetch = require('node-fetch'); -const isGHA = require('./isGitHub'); -const mime = require('mime-types'); -const pkg = require('../../package.json'); -const APIError = require('./apiError'); +import type { Headers } from 'form-data'; +import type { BodyInit, Response } from 'node-fetch'; + +import { debug } from './logger'; +import nodeFetch from 'node-fetch'; +import isGHA from './isGitHub'; +import mime from 'mime-types'; +import pkg from '../../package.json'; +import APIError from './apiError'; + +/** + * Getter function for a string to be used in the user-agent header + * based on the current environment. + * + */ +function getUserAgent() { + const gh = isGHA() ? '-github' : ''; + return `rdme${gh}/${pkg.version}`; +} /** * Wrapper for the `fetch` API so we can add rdme-specific headers to all API requests. * */ -module.exports = (url, options = { headers: {} }) => { +export default function fetch( + url: string, + options: { body?: BodyInit; headers?: Headers; method?: string } = { headers: {} } +) { let source = 'cli'; - options.headers['User-Agent'] = module.exports.getUserAgent(); + options.headers['User-Agent'] = getUserAgent(); if (isGHA()) { source = 'cli-gh'; @@ -28,18 +44,8 @@ module.exports = (url, options = { headers: {} }) => { debug(`making ${(options.method || 'get').toUpperCase()} request to ${url}`); - return fetch(url, options); -}; - -/** - * Getter function for a string to be used in the user-agent header - * based on the current environment. - * - */ -module.exports.getUserAgent = function getUserAgent() { - const gh = isGHA() ? '-github' : ''; - return `rdme${gh}/${pkg.version}`; -}; + return nodeFetch(url, options); +} /** * Small handler for handling responses from our API. @@ -50,7 +56,7 @@ module.exports.getUserAgent = function getUserAgent() { * * @param {Response} res */ -module.exports.handleRes = async function handleRes(res) { +async function handleRes(res: Response) { const contentType = res.headers.get('content-type'); const extension = mime.extension(contentType); if (extension === 'json') { @@ -66,7 +72,7 @@ module.exports.handleRes = async function handleRes(res) { const body = await res.text(); debug(`received status code ${res.status} from ${res.url} with non-JSON response: ${body}`); return Promise.reject(body); -}; +} /** * Returns the basic auth header and any other defined headers for use in node-fetch API calls. @@ -75,9 +81,9 @@ module.exports.handleRes = async function handleRes(res) { * @param {Object} inputHeaders Any additional headers to be cleaned * @returns An object with cleaned request headers for usage in the node-fetch requests to the ReadMe API. */ -module.exports.cleanHeaders = function cleanHeaders(key, inputHeaders = {}) { +function cleanHeaders(key: string, inputHeaders: Headers = {}) { const encodedKey = Buffer.from(`${key}:`).toString('base64'); - const headers = { + const headers: Record<string, string> = { Authorization: `Basic ${encodedKey}`, }; @@ -89,4 +95,6 @@ module.exports.cleanHeaders = function cleanHeaders(key, inputHeaders = {}) { }); return headers; -}; +} + +export { cleanHeaders, getUserAgent, handleRes }; diff --git a/src/lib/getCategories.js b/src/lib/getCategories.ts similarity index 82% rename from src/lib/getCategories.js rename to src/lib/getCategories.ts index a76e3e472..8adefb104 100644 --- a/src/lib/getCategories.js +++ b/src/lib/getCategories.ts @@ -1,6 +1,5 @@ -const config = require('config'); -const fetch = require('./fetch'); -const { cleanHeaders, handleRes } = require('./fetch'); +import config from 'config'; +import fetch, { cleanHeaders, handleRes } from './fetch'; /** * Returns all categories for a given project and version @@ -9,7 +8,7 @@ const { cleanHeaders, handleRes } = require('./fetch'); * @param {String} selectedVersion project version * @returns An array of category objects */ -module.exports = async function getCategories(key, selectedVersion) { +export default async function getCategories(key: string, selectedVersion: string) { function getNumberOfPages() { let totalCount = 0; return fetch(`${config.get('host')}/api/v1/categories?perPage=20&page=1`, { @@ -20,7 +19,7 @@ module.exports = async function getCategories(key, selectedVersion) { }), }) .then(res => { - totalCount = Math.ceil(res.headers.get('x-total-count') / 20); + totalCount = Math.ceil(parseInt(res.headers.get('x-total-count'), 10) / 20); return handleRes(res); }) .then(res => { @@ -46,4 +45,4 @@ module.exports = async function getCategories(key, selectedVersion) { ); return allCategories; -}; +} diff --git a/src/lib/getNodeVersion.js b/src/lib/getNodeVersion.js deleted file mode 100644 index 6d13c9bec..000000000 --- a/src/lib/getNodeVersion.js +++ /dev/null @@ -1,10 +0,0 @@ -const pkg = require('../../package.json'); - -/** - * @example 14 - * @returns {String} The major Node.js version specified in the package.json - */ -module.exports = function getNodeVersion() { - const { node } = pkg.engines; - return Array.from(node.matchAll(/\d+/g)).pop(); -}; diff --git a/src/lib/getNodeVersion.ts b/src/lib/getNodeVersion.ts new file mode 100644 index 000000000..190cea307 --- /dev/null +++ b/src/lib/getNodeVersion.ts @@ -0,0 +1,11 @@ +import pkg from '../../package.json'; + +/** + * Return the major Node.js version specified in our `package.json` config. + * + * @example 14 + */ +export default function getNodeVersion() { + const { node } = pkg.engines; + return Array.from(node.matchAll(/\d+/g)).pop().toString(); +} diff --git a/src/lib/help.js b/src/lib/help.ts similarity index 77% rename from src/lib/help.js rename to src/lib/help.ts index 422d17932..cc53a7727 100644 --- a/src/lib/help.js +++ b/src/lib/help.ts @@ -1,9 +1,10 @@ -const chalk = require('chalk'); -const config = require('config'); -const usage = require('command-line-usage'); -const commands = require('./commands'); +import type Command from './baseCommand'; +import chalk from 'chalk'; +import config from 'config'; +import usage from 'command-line-usage'; +import * as commands from './commands'; -function formatCommands(cmds) { +function formatCommands(cmds: { name: string; description: string; position: number }[]) { return cmds .sort((a, b) => (a.position > b.position ? 1 : -1)) .map(command => { @@ -43,8 +44,23 @@ const owlbert = () => { * /*`; }; -exports.commandUsage = cmd => { - const helpContent = [ +/* : { + content?: string; + header?: string; + optionList?: Command.args[]; + raw?: boolean; + }[] */ + +type Usage = { + content?: any; // TODO give this a better type + header?: string; + hide?: string[]; + optionList?: Command['args']; + raw?: boolean; +}[]; + +function commandUsage(cmd: Command) { + const helpContent: Usage = [ { content: cmd.description, raw: true, @@ -76,10 +92,10 @@ exports.commandUsage = cmd => { } return usage(helpContent); -}; +} -exports.globalUsage = async args => { - const helpContent = [ +async function globalUsage(args: Command['args']) { + const helpContent: Usage = [ { content: owlbert(), raw: true, @@ -97,7 +113,7 @@ exports.globalUsage = async args => { const categories = commands.listByCategory(); - Object.keys(categories).forEach(key => { + Object.keys(categories).forEach((key: keyof typeof categories) => { const category = categories[key]; helpContent.push({ @@ -118,4 +134,6 @@ exports.globalUsage = async args => { ); return usage(helpContent); -}; +} + +export { commandUsage, globalUsage }; diff --git a/src/lib/isGitHub.js b/src/lib/isGitHub.js deleted file mode 100644 index c12e4435c..000000000 --- a/src/lib/isGitHub.js +++ /dev/null @@ -1,9 +0,0 @@ -const ciDetect = require('@npmcli/ci-detect'); - -/** - * Small env check to determine if we're in a GitHub Actions environment - * @link https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables - */ -module.exports = function isGHA() { - return ciDetect() === 'github-actions'; -}; diff --git a/src/lib/isGitHub.ts b/src/lib/isGitHub.ts new file mode 100644 index 000000000..78c6f8931 --- /dev/null +++ b/src/lib/isGitHub.ts @@ -0,0 +1,10 @@ +import ciDetect from '@npmcli/ci-detect'; + +/** + * Small env check to determine if we're in a GitHub Actions environment. + * + * @see {@link https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables} + */ +export default function isGHA() { + return ciDetect() === 'github-actions'; +} diff --git a/src/lib/isSupportedNodeVersion.js b/src/lib/isSupportedNodeVersion.ts similarity index 53% rename from src/lib/isSupportedNodeVersion.js rename to src/lib/isSupportedNodeVersion.ts index 84f93c84a..976f94846 100644 --- a/src/lib/isSupportedNodeVersion.js +++ b/src/lib/isSupportedNodeVersion.ts @@ -1,10 +1,10 @@ -const semver = require('semver'); -const pkg = require('../../package.json'); +import semver from 'semver'; +import pkg from '../../package.json'; /** * Determine if the current version of Node is one that we explicitly support. * */ -module.exports = function isSupportedNodeVersion(version) { +export default function isSupportedNodeVersion(version: string) { return semver.satisfies(semver.coerce(version), pkg.engines.node); -}; +} diff --git a/src/lib/logger.js b/src/lib/logger.ts similarity index 63% rename from src/lib/logger.js rename to src/lib/logger.ts index 6b888f10a..e67937ef6 100644 --- a/src/lib/logger.js +++ b/src/lib/logger.ts @@ -1,46 +1,54 @@ -const chalk = require('chalk'); -const config = require('config'); -const core = require('@actions/core'); -const debugPackage = require('debug')(config.get('cli')); -const isGHA = require('./isGitHub'); +import type { Writable } from 'type-fest'; +import type { Options as OraOptions } from 'ora'; + +import chalk from 'chalk'; +import config from 'config'; +import core from '@actions/core'; +import debugModule from 'debug'; +import isGHA from './isGitHub'; + +const debugPackage = debugModule(config.get('cli')); /** * Wrapper for debug statements. * @param {String} input */ -module.exports.debug = function debug(input) { +function debug(input: string) { /* istanbul ignore next */ if (isGHA() && process.env.NODE_ENV !== 'testing') core.debug(`rdme: ${input}`); return debugPackage(input); -}; +} /** * Wrapper for warn statements. * @param {String} input */ -module.exports.warn = function warn(input) { +function warn(input: string) { /* istanbul ignore next */ if (isGHA() && process.env.NODE_ENV !== 'testing') return core.warning(input); // eslint-disable-next-line no-console return console.warn(chalk.yellow(`⚠️ Warning! ${input}`)); -}; +} /** * Wrapper for info/notice statements. * @param {String} input */ -module.exports.info = function info(input) { +function info(input: string) { /* istanbul ignore next */ if (isGHA() && process.env.NODE_ENV !== 'testing') return core.notice(input); // eslint-disable-next-line no-console return console.info(input); -}; +} -module.exports.oraOptions = function oraOptions() { +function oraOptions() { // Disables spinner in tests so it doesn't pollute test output - const opts = { isSilent: process.env.NODE_ENV === 'testing' }; + const opts: Writable<OraOptions> = { isSilent: process.env.NODE_ENV === 'testing' }; + // Cleans up ora output so it prints nicely alongside debug logs /* istanbul ignore next */ if (debugPackage.enabled) opts.isEnabled = false; return opts; -}; +} + +export { debug, warn, info, oraOptions }; diff --git a/src/lib/prepareOas.js b/src/lib/prepareOas.ts similarity index 84% rename from src/lib/prepareOas.js rename to src/lib/prepareOas.ts index 339e35afd..00d431bee 100644 --- a/src/lib/prepareOas.js +++ b/src/lib/prepareOas.ts @@ -1,9 +1,9 @@ -const chalk = require('chalk'); -const fs = require('fs'); -const OASNormalize = require('oas-normalize'); -const ora = require('ora'); +import chalk from 'chalk'; +import fs from 'fs'; +import OASNormalize from 'oas-normalize'; +import ora from 'ora'; -const { debug, info, oraOptions } = require('./logger'); +import { debug, info, oraOptions } from './logger'; /** * Normalizes, validates, and (optionally) bundles an OpenAPI definition. @@ -13,7 +13,7 @@ const { debug, info, oraOptions } = require('./logger'); * @param {('openapi'|'validate')} command string to distinguish if it's being run in * an 'openapi' or 'validate' context */ -module.exports = async function prepare(path, command) { +export default async function prepareOas(path: string, command: 'openapi' | 'validate') { let specPath = path; if (!specPath) { @@ -47,7 +47,7 @@ module.exports = async function prepare(path, command) { const oas = new OASNormalize(specPath, { colorizeErrors: true, enablePaths: true }); debug('spec normalized'); - const api = await oas.validate(false).catch(err => { + const api = await oas.validate(false).catch((err: Error) => { spinner.fail(); debug(`raw validation error object: ${JSON.stringify(err)}`); throw err; @@ -64,7 +64,7 @@ module.exports = async function prepare(path, command) { let bundledSpec = ''; if (command === 'openapi') { - bundledSpec = await oas.bundle().then(res => { + bundledSpec = await oas.bundle().then((res: Record<string, unknown>) => { return JSON.stringify(res); }); @@ -72,4 +72,4 @@ module.exports = async function prepare(path, command) { } return { bundledSpec, specPath, specType }; -}; +} diff --git a/src/lib/prompts.js b/src/lib/prompts.js deleted file mode 100644 index d5c1ead64..000000000 --- a/src/lib/prompts.js +++ /dev/null @@ -1,176 +0,0 @@ -const semver = require('semver'); -const { prompt } = require('enquirer'); -const parse = require('parse-link-header'); - -exports.generatePrompts = (versionList, selectOnly = false) => [ - { - type: 'select', - name: 'option', - message: 'Would you like to use an existing project version or create a new one?', - skip() { - return selectOnly; - }, - choices: [ - { message: 'Use existing', value: 'update' }, - { message: 'Create a new version', value: 'create' }, - ], - }, - { - type: 'select', - name: 'versionSelection', - message: 'Select your desired version', - skip() { - return selectOnly ? false : this.enquirer.answers.option !== 'update'; - }, - choices: versionList.map(v => { - return { - message: v.version, - value: v.version, - }; - }), - }, - { - type: 'input', - name: 'newVersion', - message: "What's your new version?", - skip() { - return selectOnly ? true : this.enquirer.answers.option === 'update'; - }, - hint: '1.0.0', - }, -]; - -function specOptions(specList, parsedDocs, currPage, totalPages) { - const specs = specList.map(s => { - return { - message: s.title, - value: s._id, // eslint-disable-line no-underscore-dangle - }; - }); - if (parsedDocs.prev.page) specs.push({ message: `< Prev (page ${currPage - 1} of ${totalPages})`, value: 'prev' }); - if (parsedDocs.next.page) { - specs.push({ message: `Next (page ${currPage + 1} of ${totalPages}) >`, value: 'next' }); - } - return specs; -} - -const updateOasPrompt = (specList, parsedDocs, currPage, totalPages, getSpecs) => [ - { - type: 'select', - name: 'specId', - message: 'Select your desired file to update', - choices: specOptions(specList, parsedDocs, currPage, totalPages), - async result(spec) { - if (spec === 'prev') { - try { - const newSpecs = await getSpecs(`${parsedDocs.prev.url}`); - const newParsedDocs = parse(newSpecs.headers.get('link')); - const newSpecList = await newSpecs.json(); - const { specId } = await prompt( - updateOasPrompt(newSpecList, newParsedDocs, currPage - 1, totalPages, getSpecs) - ); - return specId; - } catch (e) { - return null; - } - } - if (spec === 'next') { - try { - const newSpecs = await getSpecs(`${parsedDocs.next.url}`); - const newParsedDocs = parse(newSpecs.headers.get('link')); - const newSpecList = await newSpecs.json(); - const { specId } = await prompt( - updateOasPrompt(newSpecList, newParsedDocs, currPage + 1, totalPages, getSpecs) - ); - return specId; - } catch (e) { - return null; - } - } - return spec; - }, - }, -]; - -exports.createOasPrompt = (specList, parsedDocs, totalPages, getSpecs) => [ - { - type: 'select', - name: 'option', - message: 'Would you like to update an existing OAS file or create a new one?', - choices: [ - { message: 'Update existing', value: 'update' }, - { message: 'Create a new spec', value: 'create' }, - ], - async result(picked) { - if (picked === 'update') { - try { - const { specId } = await prompt(updateOasPrompt(specList, parsedDocs, 1, totalPages, getSpecs)); - return specId; - } catch (e) { - return null; - } - } - return picked; - }, - }, -]; - -exports.createVersionPrompt = (versionList, opts, isUpdate) => [ - { - type: 'select', - name: 'from', - message: 'Which version would you like to fork from?', - skip() { - return opts.fork || isUpdate; - }, - choices: versionList.map(v => { - return { - message: v.version, - value: v.version, - }; - }), - }, - { - type: 'input', - name: 'newVersion', - message: "What's your new version?", - initial: opts.newVersion || false, - skip() { - return opts.newVersion || !isUpdate; - }, - hint: '1.0.0', - validate(val) { - return semver.valid(semver.coerce(val)) ? true : this.styles.danger('Please specify a semantic version.'); - }, - }, - { - type: 'confirm', - name: 'is_stable', - message: 'Would you like to make this version the main version for this project?', - skip() { - return opts.main || isUpdate?.is_stable; - }, - }, - { - type: 'confirm', - name: 'is_beta', - message: 'Should this version be in beta?', - skip: () => opts.beta, - }, - { - type: 'confirm', - name: 'is_hidden', - message: 'Would you like to make this version public?', - skip() { - return opts.isPublic || opts.main || this.enquirer.answers.is_stable; - }, - }, - { - type: 'confirm', - name: 'is_deprecated', - message: 'Would you like to deprecate this version?', - skip() { - return opts.deprecated || opts.main || !isUpdate || this.enquirer.answers.is_stable; - }, - }, -]; diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts new file mode 100644 index 000000000..fe9ccecd4 --- /dev/null +++ b/src/lib/prompts.ts @@ -0,0 +1,231 @@ +import type fetch from './fetch'; + +import { prompt } from 'enquirer'; +import parse from 'parse-link-header'; +import semver from 'semver'; + +type SpecList = { + _id: string; + title: string; +}[]; + +type VersionList = { + version: string; +}[]; + +type ParsedDocs = { + next?: { + page: number; + url: string; + }; + prev?: { + page: number; + url: string; + }; +}; + +export function generatePrompts(versionList: VersionList, selectOnly = false) { + return [ + { + type: 'select', + name: 'option', + message: 'Would you like to use an existing project version or create a new one?', + skip() { + return selectOnly; + }, + choices: [ + { message: 'Use existing', value: 'update' }, + { message: 'Create a new version', value: 'create' }, + ], + }, + { + type: 'select', + name: 'versionSelection', + message: 'Select your desired version', + skip() { + return selectOnly ? false : this.enquirer.answers.option !== 'update'; + }, + choices: versionList.map(v => { + return { + message: v.version, + value: v.version, + }; + }), + }, + { + type: 'input', + name: 'newVersion', + message: "What's your new version?", + skip() { + return selectOnly ? true : this.enquirer.answers.option === 'update'; + }, + hint: '1.0.0', + }, + ]; +} + +function specOptions(specList: SpecList, parsedDocs: ParsedDocs, currPage: number, totalPages: number) { + const specs = specList.map(s => { + return { + message: s.title, + value: s._id, // eslint-disable-line no-underscore-dangle + }; + }); + if (parsedDocs.prev.page) specs.push({ message: `< Prev (page ${currPage - 1} of ${totalPages})`, value: 'prev' }); + if (parsedDocs.next.page) { + specs.push({ message: `Next (page ${currPage + 1} of ${totalPages}) >`, value: 'next' }); + } + return specs; +} + +const updateOasPrompt = ( + specList: SpecList, + parsedDocs: ParsedDocs, + currPage: number, + totalPages: number, + getSpecs: (url: string) => Promise<ReturnType<typeof fetch>> +) => [ + { + type: 'select', + name: 'specId', + message: 'Select your desired file to update', + choices: specOptions(specList, parsedDocs, currPage, totalPages), + async result(spec: string) { + if (spec === 'prev') { + try { + const newSpecs = await getSpecs(`${parsedDocs.prev.url}`); + const newParsedDocs = parse(newSpecs.headers.get('link')); + const newSpecList = await newSpecs.json(); + const { specId }: { specId: string } = await prompt( + updateOasPrompt(newSpecList, newParsedDocs, currPage - 1, totalPages, getSpecs) + ); + return specId; + } catch (e) { + return null; + } + } else if (spec === 'next') { + try { + const newSpecs = await getSpecs(`${parsedDocs.next.url}`); + const newParsedDocs = parse(newSpecs.headers.get('link')); + const newSpecList = await newSpecs.json(); + const { specId }: { specId: string } = await prompt( + updateOasPrompt(newSpecList, newParsedDocs, currPage + 1, totalPages, getSpecs) + ); + return specId; + } catch (e) { + return null; + } + } + + return spec; + }, + }, +]; + +export function createOasPrompt( + specList: SpecList, + parsedDocs: ParsedDocs, + totalPages: number, + getSpecs: (url: string) => Promise<ReturnType<typeof fetch>> +) { + return [ + { + type: 'select', + name: 'option', + message: 'Would you like to update an existing OAS file or create a new one?', + choices: [ + { message: 'Update existing', value: 'update' }, + { message: 'Create a new spec', value: 'create' }, + ], + async result(picked: string) { + if (picked === 'update') { + try { + const { specId }: { specId: string } = await prompt( + updateOasPrompt(specList, parsedDocs, 1, totalPages, getSpecs) + ); + return specId; + } catch (e) { + return null; + } + } + + return picked; + }, + }, + ]; +} + +export function createVersionPrompt( + versionList: VersionList, + opts: { + beta?: string; + deprecated?: string; + fork?: string; + isPublic?: string; + main?: string; + newVersion?: string; + }, + isUpdate?: { + is_stable: string; + } +) { + return [ + { + type: 'select', + name: 'from', + message: 'Which version would you like to fork from?', + skip() { + return opts.fork || isUpdate; + }, + choices: versionList.map(v => { + return { + message: v.version, + value: v.version, + }; + }), + }, + { + type: 'input', + name: 'newVersion', + message: "What's your new version?", + initial: opts.newVersion || false, + skip() { + return opts.newVersion || !isUpdate; + }, + hint: '1.0.0', + validate(val: string) { + return semver.valid(semver.coerce(val)) ? true : this.styles.danger('Please specify a semantic version.'); + }, + }, + { + type: 'confirm', + name: 'is_stable', + message: 'Would you like to make this version the main version for this project?', + skip() { + return opts.main || isUpdate?.is_stable; + }, + }, + { + type: 'confirm', + name: 'is_beta', + message: 'Should this version be in beta?', + skip: () => opts.beta, + }, + { + type: 'confirm', + name: 'is_hidden', + message: 'Would you like to make this version public?', + skip() { + return opts.isPublic || opts.main || this.enquirer.answers.is_stable; + }, + }, + { + type: 'confirm', + name: 'is_deprecated', + message: 'Would you like to deprecate this version?', + skip() { + return opts.deprecated || opts.main || !isUpdate || this.enquirer.answers.is_stable; + }, + }, + ]; +} diff --git a/src/lib/pushDoc.js b/src/lib/pushDoc.ts similarity index 82% rename from src/lib/pushDoc.js rename to src/lib/pushDoc.ts index 5c973a444..3bb3246cb 100644 --- a/src/lib/pushDoc.js +++ b/src/lib/pushDoc.ts @@ -1,14 +1,14 @@ -const chalk = require('chalk'); -const config = require('config'); -const crypto = require('crypto'); -const fs = require('fs'); -const grayMatter = require('gray-matter'); -const path = require('path'); +import chalk from 'chalk'; +import config from 'config'; +import crypto from 'crypto'; +import fs from 'fs'; +import grayMatter from 'gray-matter'; +import path from 'path'; -const APIError = require('./apiError'); -const { cleanHeaders, handleRes } = require('./fetch'); -const fetch = require('./fetch'); -const { debug } = require('./logger'); +import APIError from './apiError'; +import { CommandCategories } from './baseCommand'; +import fetch, { cleanHeaders, handleRes } from './fetch'; +import { debug } from './logger'; /** * Reads the contents of the specified Markdown or HTML file @@ -22,7 +22,13 @@ const { debug } = require('./logger'); * @param {String} type module within ReadMe to update (e.g. docs, changelogs, etc.) * @returns {Promise<String>} a string containing the result */ -module.exports = async function pushDoc(key, selectedVersion, dryRun, filepath, type) { +export default async function pushDoc( + key: string, + selectedVersion: string, + dryRun: boolean, + filepath: string, + type: CommandCategories +) { debug(`reading file ${filepath}`); const file = fs.readFileSync(filepath, 'utf8'); const matter = grayMatter(file); @@ -32,9 +38,14 @@ module.exports = async function pushDoc(key, selectedVersion, dryRun, filepath, const slug = matter.data.slug || path.basename(filepath).replace(path.extname(filepath), '').toLowerCase(); const hash = crypto.createHash('sha1').update(file).digest('hex'); - let data = { body: matter.content, ...matter.data, lastUpdatedHash: hash }; + let data: { + body?: string; + html?: string; + htmlmode?: boolean; + lastUpdatedHash: string; + } = { body: matter.content, ...matter.data, lastUpdatedHash: hash }; - if (type === 'custompages') { + if (type === CommandCategories.CUSTOM_PAGES) { if (filepath.endsWith('.html')) { data = { html: matter.content, htmlmode: true, ...matter.data, lastUpdatedHash: hash }; } else { @@ -64,7 +75,7 @@ module.exports = async function pushDoc(key, selectedVersion, dryRun, filepath, .then(res => `🌱 successfully created '${res.slug}' with contents from ${filepath}`); } - function updateDoc(existingDoc) { + function updateDoc(existingDoc: typeof data) { if (hash === existingDoc.lastUpdatedHash) { return `${dryRun ? '🎭 dry run! ' : ''}\`${slug}\` ${ dryRun ? 'will not be' : 'was not' @@ -106,7 +117,7 @@ module.exports = async function pushDoc(key, selectedVersion, dryRun, filepath, if (!res.ok) { if (res.status !== 404) return Promise.reject(new APIError(body)); debug(`error retrieving data for ${slug}, creating doc`); - return createDoc(body); + return createDoc(); } debug(`data received for ${slug}, updating doc`); return updateDoc(body); @@ -116,7 +127,7 @@ module.exports = async function pushDoc(key, selectedVersion, dryRun, filepath, err.message = `Error uploading ${chalk.underline(filepath)}:\n\n${err.message}`; throw err; }); -}; +} /** * Recursively grabs all files within a given directory @@ -124,7 +135,7 @@ module.exports = async function pushDoc(key, selectedVersion, dryRun, filepath, * @param {String} folderToSearch path to directory * @returns {String[]} array of files */ -module.exports.readdirRecursive = function readdirRecursive(folderToSearch) { +export function readdirRecursive(folderToSearch: string): string[] { const filesInFolder = fs.readdirSync(folderToSearch, { withFileTypes: true }); const files = filesInFolder .filter(fileHandle => fileHandle.isFile()) @@ -134,4 +145,4 @@ module.exports.readdirRecursive = function readdirRecursive(folderToSearch) { ...folders.map(fileHandle => readdirRecursive(path.join(folderToSearch, fileHandle.name))) ); return [...files, ...subFiles]; -}; +} diff --git a/src/lib/streamSpecToRegistry.js b/src/lib/streamSpecToRegistry.ts similarity index 75% rename from src/lib/streamSpecToRegistry.js rename to src/lib/streamSpecToRegistry.ts index 1ff8c8ec1..b3ce75549 100644 --- a/src/lib/streamSpecToRegistry.js +++ b/src/lib/streamSpecToRegistry.ts @@ -1,11 +1,10 @@ -const { handleRes } = require('./fetch'); -const config = require('config'); -const { debug, oraOptions } = require('./logger'); -const fetch = require('./fetch'); -const FormData = require('form-data'); -const fs = require('fs'); -const ora = require('ora'); -const { file: tmpFile } = require('tmp-promise'); +import config from 'config'; +import { debug, oraOptions } from './logger'; +import fetch, { handleRes } from './fetch'; +import FormData from 'form-data'; +import fs from 'fs'; +import ora from 'ora'; +import { file as tmpFile } from 'tmp-promise'; /** * Uploads a spec to the API registry for usage in ReadMe @@ -13,7 +12,7 @@ const { file: tmpFile } = require('tmp-promise'); * @param {String} spec path to a bundled/validated spec file * @returns {String} a UUID in the API registry */ -module.exports = async function streamSpecToRegistry(spec) { +export default async function streamSpecToRegistry(spec: string) { const spinner = ora({ text: 'Staging your API definition for upload...', ...oraOptions() }).start(); // Create a temporary file to write the bundled spec to, // which we will then stream into the form data body @@ -44,4 +43,4 @@ module.exports = async function streamSpecToRegistry(spec) { spinner.fail(); throw e; }); -}; +} diff --git a/src/lib/versionSelect.js b/src/lib/versionSelect.ts similarity index 58% rename from src/lib/versionSelect.js rename to src/lib/versionSelect.ts index f33edcf61..021ad90db 100644 --- a/src/lib/versionSelect.js +++ b/src/lib/versionSelect.ts @@ -1,11 +1,11 @@ -const { prompt } = require('enquirer'); -const promptOpts = require('./prompts'); -const config = require('config'); -const APIError = require('./apiError'); -const fetch = require('./fetch'); -const { cleanHeaders, handleRes } = require('./fetch'); - -async function getProjectVersion(versionFlag, key, allowNewVersion) { +import config from 'config'; +import { prompt } from 'enquirer'; + +import APIError from './apiError'; +import fetch, { cleanHeaders, handleRes } from './fetch'; +import * as promptOpts from './prompts'; + +export async function getProjectVersion(versionFlag: string, key: string, allowNewVersion: boolean): Promise<string> { try { if (versionFlag) { return await fetch(`${config.get('host')}/api/v1/version/${versionFlag}`, { @@ -22,7 +22,13 @@ async function getProjectVersion(versionFlag, key, allowNewVersion) { }).then(res => handleRes(res)); if (allowNewVersion) { - const { option, versionSelection, newVersion } = await prompt(promptOpts.generatePrompts(versionList)); + const { + option, + versionSelection, + newVersion, + }: { option: 'update' | 'create'; versionSelection: string; newVersion: string } = await prompt( + promptOpts.generatePrompts(versionList) + ); if (option === 'update') return versionSelection; @@ -39,11 +45,12 @@ async function getProjectVersion(versionFlag, key, allowNewVersion) { return newVersion; } - const { versionSelection } = await prompt(promptOpts.generatePrompts(versionList, true)); + const { versionSelection }: { versionSelection: string } = await prompt( + promptOpts.generatePrompts(versionList, true) + ); + return versionSelection; } catch (err) { return Promise.reject(new APIError(err)); } } - -module.exports = { getProjectVersion }; diff --git a/src/typings.d.ts b/src/typings.d.ts new file mode 100644 index 000000000..a710d8f2a --- /dev/null +++ b/src/typings.d.ts @@ -0,0 +1,4 @@ +// These packges don't have any TS types so we need to declare a module in order to use them. +declare module '@npmcli/ci-detect'; +declare module 'editor'; +declare module 'oas-normalize'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..21df4230d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "baseUrl": "./src", + "declaration": true, + "downlevelIteration": true, + "esModuleInterop": true, + "lib": ["es2020"], + "noImplicitAny": true, + "outDir": "dist/", + "resolveJsonModule": true + }, + "include": ["./src/**/*"] +} From 456558fbdc54e067cabb32ebb9b580e542a44656 Mon Sep 17 00:00:00 2001 From: Jon Ursenbach <jon@ursenba.ch> Date: Mon, 8 Aug 2022 23:32:49 -0700 Subject: [PATCH 02/21] feat: moving all unit tests over to TS, fixing issues --- __tests__/{bin.test.js => bin.test.ts} | 7 +- ...{login.test.js.snap => login.test.ts.snap} | 0 ...napi.test.js.snap => openapi.test.ts.snap} | 0 ...ate.test.js.snap => validate.test.ts.snap} | 0 ...hoami.test.js.snap => whoami.test.ts.snap} | 0 .../create.test.ts} | 102 +- __tests__/cmds/categories/index.test.ts | 71 ++ .../index.test.ts} | 340 +------ __tests__/cmds/changelogs/single.test.ts | 307 ++++++ .../index.test.ts} | 378 +------- __tests__/cmds/custompages/single.test.ts | 341 +++++++ __tests__/cmds/docs.test.js | 899 ------------------ __tests__/cmds/docs/edit.test.ts | 136 +++ __tests__/cmds/docs/index.test.ts | 412 ++++++++ __tests__/cmds/docs/single.test.ts | 368 +++++++ .../cmds/{login.test.js => login.test.ts} | 11 +- .../cmds/{logout.test.js => logout.test.ts} | 7 +- __tests__/cmds/{open.test.js => open.test.ts} | 9 +- .../cmds/{openapi.test.js => openapi.test.ts} | 69 +- .../{validate.test.js => validate.test.ts} | 16 +- __tests__/cmds/versions.test.js | 250 ----- __tests__/cmds/versions/create.test.ts | 64 ++ __tests__/cmds/versions/delete.test.ts | 56 ++ __tests__/cmds/versions/index.test.ts | 88 ++ __tests__/cmds/versions/update.test.ts | 77 ++ .../cmds/{whoami.test.js => whoami.test.ts} | 7 +- __tests__/get-api-nock.js | 12 - __tests__/helpers/get-api-mock.ts | 19 + __tests__/helpers/hash-file-contents.ts | 5 + __tests__/lib/commands.test.js | 60 -- __tests__/lib/commands.test.ts | 72 ++ .../lib/{prompts.test.js => prompts.test.ts} | 35 +- jest.config.js | 1 + package-lock.json | 27 + package.json | 1 + src/cmds/categories/create.ts | 14 +- src/cmds/categories/index.ts | 8 +- src/cmds/changelogs/index.ts | 10 +- src/cmds/changelogs/single.ts | 10 +- src/cmds/custompages/index.ts | 4 +- src/cmds/custompages/single.ts | 10 +- src/cmds/docs/edit.ts | 12 +- src/cmds/docs/index.ts | 10 +- src/cmds/docs/single.ts | 10 +- src/cmds/login.ts | 10 +- src/cmds/open.ts | 2 +- src/cmds/openapi.ts | 20 +- src/cmds/validate.ts | 4 +- src/cmds/versions/create.ts | 20 +- src/cmds/versions/delete.ts | 6 +- src/cmds/versions/index.ts | 9 +- src/cmds/versions/update.ts | 22 +- src/lib/apiError.ts | 8 +- src/lib/baseCommand.ts | 13 +- src/lib/commands.ts | 4 +- src/lib/prompts.ts | 9 +- src/lib/versionSelect.ts | 6 +- 57 files changed, 2340 insertions(+), 2128 deletions(-) rename __tests__/{bin.test.js => bin.test.ts} (83%) rename __tests__/cmds/__snapshots__/{login.test.js.snap => login.test.ts.snap} (100%) rename __tests__/cmds/__snapshots__/{openapi.test.js.snap => openapi.test.ts.snap} (100%) rename __tests__/cmds/__snapshots__/{validate.test.js.snap => validate.test.ts.snap} (100%) rename __tests__/cmds/__snapshots__/{whoami.test.js.snap => whoami.test.ts.snap} (100%) rename __tests__/cmds/{categories.test.js => categories/create.test.ts} (63%) create mode 100644 __tests__/cmds/categories/index.test.ts rename __tests__/cmds/{changelogs.test.js => changelogs/index.test.ts} (51%) create mode 100644 __tests__/cmds/changelogs/single.test.ts rename __tests__/cmds/{custompages.test.js => custompages/index.test.ts} (51%) create mode 100644 __tests__/cmds/custompages/single.test.ts delete mode 100644 __tests__/cmds/docs.test.js create mode 100644 __tests__/cmds/docs/edit.test.ts create mode 100644 __tests__/cmds/docs/index.test.ts create mode 100644 __tests__/cmds/docs/single.test.ts rename __tests__/cmds/{login.test.js => login.test.ts} (92%) rename __tests__/cmds/{logout.test.js => logout.test.ts} (84%) rename __tests__/cmds/{open.test.js => open.test.ts} (79%) rename __tests__/cmds/{openapi.test.js => openapi.test.ts} (93%) rename __tests__/cmds/{validate.test.js => validate.test.ts} (90%) delete mode 100644 __tests__/cmds/versions.test.js create mode 100644 __tests__/cmds/versions/create.test.ts create mode 100644 __tests__/cmds/versions/delete.test.ts create mode 100644 __tests__/cmds/versions/index.test.ts create mode 100644 __tests__/cmds/versions/update.test.ts rename __tests__/cmds/{whoami.test.js => whoami.test.ts} (78%) delete mode 100644 __tests__/get-api-nock.js create mode 100644 __tests__/helpers/get-api-mock.ts create mode 100644 __tests__/helpers/hash-file-contents.ts delete mode 100644 __tests__/lib/commands.test.js create mode 100644 __tests__/lib/commands.test.ts rename __tests__/lib/{prompts.test.js => prompts.test.ts} (87%) diff --git a/__tests__/bin.test.js b/__tests__/bin.test.ts similarity index 83% rename from __tests__/bin.test.js rename to __tests__/bin.test.ts index 83d5c384a..6843af9b3 100644 --- a/__tests__/bin.test.js +++ b/__tests__/bin.test.ts @@ -1,6 +1,7 @@ -const { exec } = require('child_process'); -const isSupportedNodeVersion = require('../src/lib/isSupportedNodeVersion'); -const pkg = require('../package.json'); +import { exec } from 'child_process'; + +import pkg from '../package.json'; +import isSupportedNodeVersion from '../src/lib/isSupportedNodeVersion'; describe('bin', () => { if (isSupportedNodeVersion(process.version)) { diff --git a/__tests__/cmds/__snapshots__/login.test.js.snap b/__tests__/cmds/__snapshots__/login.test.ts.snap similarity index 100% rename from __tests__/cmds/__snapshots__/login.test.js.snap rename to __tests__/cmds/__snapshots__/login.test.ts.snap diff --git a/__tests__/cmds/__snapshots__/openapi.test.js.snap b/__tests__/cmds/__snapshots__/openapi.test.ts.snap similarity index 100% rename from __tests__/cmds/__snapshots__/openapi.test.js.snap rename to __tests__/cmds/__snapshots__/openapi.test.ts.snap diff --git a/__tests__/cmds/__snapshots__/validate.test.js.snap b/__tests__/cmds/__snapshots__/validate.test.ts.snap similarity index 100% rename from __tests__/cmds/__snapshots__/validate.test.js.snap rename to __tests__/cmds/__snapshots__/validate.test.ts.snap diff --git a/__tests__/cmds/__snapshots__/whoami.test.js.snap b/__tests__/cmds/__snapshots__/whoami.test.ts.snap similarity index 100% rename from __tests__/cmds/__snapshots__/whoami.test.js.snap rename to __tests__/cmds/__snapshots__/whoami.test.ts.snap diff --git a/__tests__/cmds/categories.test.js b/__tests__/cmds/categories/create.test.ts similarity index 63% rename from __tests__/cmds/categories.test.js rename to __tests__/cmds/categories/create.test.ts index 0c582ab9f..965d783ff 100644 --- a/__tests__/cmds/categories.test.js +++ b/__tests__/cmds/categories/create.test.ts @@ -1,84 +1,13 @@ -const nock = require('nock'); +import nock from 'nock'; -const getApiNock = require('../get-api-nock'); +import CategoriesCreateCommand from '../../../src/cmds/categories/create'; +import getAPIMock, { getAPIMockWithVersionHeader } from '../../helpers/get-api-mock'; -const CategoriesCommand = require('../../src/cmds/categories'); -const CategoriesCreateCommand = require('../../src/cmds/categories/create'); - -const categories = new CategoriesCommand(); const categoriesCreate = new CategoriesCreateCommand(); const key = 'API_KEY'; const version = '1.0.0'; -function getNockWithVersionHeader(v) { - return getApiNock({ - 'x-readme-version': v, - }); -} - -describe('rdme categories', () => { - beforeAll(() => nock.disableNetConnect()); - - afterEach(() => nock.cleanAll()); - - it('should error if no api key provided', () => { - return expect(categories.run({})).rejects.toStrictEqual( - new Error('No project API key provided. Please use `--key`.') - ); - }); - - it('should return all categories for a single page', async () => { - const getMock = getNockWithVersionHeader(version) - .persist() - .get('/api/v1/categories?perPage=20&page=1') - .basicAuth({ user: key }) - .reply(200, [{ title: 'One Category', slug: 'one-category', type: 'guide' }], { - 'x-total-count': '1', - }); - - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); - - await expect(categories.run({ key, version: '1.0.0' })).resolves.toBe( - JSON.stringify([{ title: 'One Category', slug: 'one-category', type: 'guide' }], null, 2) - ); - - getMock.done(); - versionMock.done(); - }); - - it('should return all categories for multiple pages', async () => { - const getMock = getNockWithVersionHeader(version) - .persist() - .get('/api/v1/categories?perPage=20&page=1') - .basicAuth({ user: key }) - .reply(200, [{ title: 'One Category', slug: 'one-category', type: 'guide' }], { - 'x-total-count': '21', - }) - .get('/api/v1/categories?perPage=20&page=2') - .basicAuth({ user: key }) - .reply(200, [{ title: 'Another Category', slug: 'another-category', type: 'guide' }], { - 'x-total-count': '21', - }); - - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); - - await expect(categories.run({ key, version: '1.0.0' })).resolves.toBe( - JSON.stringify( - [ - { title: 'One Category', slug: 'one-category', type: 'guide' }, - { title: 'Another Category', slug: 'another-category', type: 'guide' }, - ], - null, - 2 - ) - ); - - getMock.done(); - versionMock.done(); - }); -}); - describe('rdme categories:create', () => { beforeAll(() => nock.disableNetConnect()); @@ -104,12 +33,13 @@ describe('rdme categories:create', () => { it('should error if categoryType is not `guide` or `reference`', () => { return expect( + // @ts-expect-error Testing a CLI arg failure case. categoriesCreate.run({ key: '123', title: 'Test Title', categoryType: 'test' }) ).rejects.toStrictEqual(new Error('`categoryType` must be `guide` or `reference`.')); }); it('should create a new category if the title and type do not match and preventDuplicates=true', async () => { - const getMock = getNockWithVersionHeader(version) + const getMock = getAPIMockWithVersionHeader(version) .persist() .get('/api/v1/categories?perPage=20&page=1') .basicAuth({ user: key }) @@ -117,12 +47,12 @@ describe('rdme categories:create', () => { 'x-total-count': '1', }); - const postMock = getNockWithVersionHeader(version) + const postMock = getAPIMockWithVersionHeader(version) .post('/api/v1/categories') .basicAuth({ user: key }) .reply(201, { title: 'New Category', slug: 'new-category', type: 'guide', id: '123' }); - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); await expect( categoriesCreate.run({ @@ -140,7 +70,7 @@ describe('rdme categories:create', () => { }); it('should create a new category if the title matches but the type does not match and preventDuplicates=true', async () => { - const getMock = getNockWithVersionHeader(version) + const getMock = getAPIMockWithVersionHeader(version) .persist() .get('/api/v1/categories?perPage=20&page=1') .basicAuth({ user: key }) @@ -148,12 +78,12 @@ describe('rdme categories:create', () => { 'x-total-count': '1', }); - const postMock = getNockWithVersionHeader(version) + const postMock = getAPIMockWithVersionHeader(version) .post('/api/v1/categories') .basicAuth({ user: key }) .reply(201, { title: 'Category', slug: 'category', type: 'reference', id: '123' }); - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); await expect( categoriesCreate.run({ @@ -171,12 +101,12 @@ describe('rdme categories:create', () => { }); it('should create a new category if the title and type match and preventDuplicates=false', async () => { - const postMock = getNockWithVersionHeader(version) + const postMock = getAPIMockWithVersionHeader(version) .post('/api/v1/categories') .basicAuth({ user: key }) .reply(201, { title: 'Category', slug: 'category', type: 'reference', id: '123' }); - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); await expect( categoriesCreate.run({ @@ -192,7 +122,7 @@ describe('rdme categories:create', () => { }); it('should not create a new category if the title and type match and preventDuplicates=true', async () => { - const getMock = getNockWithVersionHeader(version) + const getMock = getAPIMockWithVersionHeader(version) .persist() .get('/api/v1/categories?perPage=20&page=1') .basicAuth({ user: key }) @@ -200,7 +130,7 @@ describe('rdme categories:create', () => { 'x-total-count': '1', }); - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); await expect( categoriesCreate.run({ @@ -221,7 +151,7 @@ describe('rdme categories:create', () => { }); it('should not create a new category if the non case sensitive title and type match and preventDuplicates=true', async () => { - const getMock = getNockWithVersionHeader(version) + const getMock = getAPIMockWithVersionHeader(version) .persist() .get('/api/v1/categories?perPage=20&page=1') .basicAuth({ user: key }) @@ -229,7 +159,7 @@ describe('rdme categories:create', () => { 'x-total-count': '1', }); - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); await expect( categoriesCreate.run({ diff --git a/__tests__/cmds/categories/index.test.ts b/__tests__/cmds/categories/index.test.ts new file mode 100644 index 000000000..d19a195a8 --- /dev/null +++ b/__tests__/cmds/categories/index.test.ts @@ -0,0 +1,71 @@ +import nock from 'nock'; + +import CategoriesCommand from '../../../src/cmds/categories'; +import getAPIMock, { getAPIMockWithVersionHeader } from '../../helpers/get-api-mock'; + +const categories = new CategoriesCommand(); + +const key = 'API_KEY'; +const version = '1.0.0'; + +describe('rdme categories', () => { + beforeAll(() => nock.disableNetConnect()); + + afterEach(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(categories.run({})).rejects.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); + }); + + it('should return all categories for a single page', async () => { + const getMock = getAPIMockWithVersionHeader(version) + .persist() + .get('/api/v1/categories?perPage=20&page=1') + .basicAuth({ user: key }) + .reply(200, [{ title: 'One Category', slug: 'one-category', type: 'guide' }], { + 'x-total-count': '1', + }); + + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect(categories.run({ key, version: '1.0.0' })).resolves.toBe( + JSON.stringify([{ title: 'One Category', slug: 'one-category', type: 'guide' }], null, 2) + ); + + getMock.done(); + versionMock.done(); + }); + + it('should return all categories for multiple pages', async () => { + const getMock = getAPIMockWithVersionHeader(version) + .persist() + .get('/api/v1/categories?perPage=20&page=1') + .basicAuth({ user: key }) + .reply(200, [{ title: 'One Category', slug: 'one-category', type: 'guide' }], { + 'x-total-count': '21', + }) + .get('/api/v1/categories?perPage=20&page=2') + .basicAuth({ user: key }) + .reply(200, [{ title: 'Another Category', slug: 'another-category', type: 'guide' }], { + 'x-total-count': '21', + }); + + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect(categories.run({ key, version: '1.0.0' })).resolves.toBe( + JSON.stringify( + [ + { title: 'One Category', slug: 'one-category', type: 'guide' }, + { title: 'Another Category', slug: 'another-category', type: 'guide' }, + ], + null, + 2 + ) + ); + + getMock.done(); + versionMock.done(); + }); +}); diff --git a/__tests__/cmds/changelogs.test.js b/__tests__/cmds/changelogs/index.test.ts similarity index 51% rename from __tests__/cmds/changelogs.test.js rename to __tests__/cmds/changelogs/index.test.ts index f146ef69d..e6d769325 100644 --- a/__tests__/cmds/changelogs.test.js +++ b/__tests__/cmds/changelogs/index.test.ts @@ -1,27 +1,21 @@ -const nock = require('nock'); -const chalk = require('chalk'); -const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); -const frontMatter = require('gray-matter'); +import fs from 'fs'; +import path from 'path'; -const APIError = require('../../src/lib/apiError'); -const getApiNock = require('../get-api-nock'); +import chalk from 'chalk'; +import frontMatter from 'gray-matter'; +import nock from 'nock'; -const ChangelogsCommand = require('../../src/cmds/changelogs'); -const SingleChangelogCommand = require('../../src/cmds/changelogs/single'); +import ChangelogsCommand from '../../../src/cmds/changelogs'; +import APIError from '../../../src/lib/apiError'; +import getAPIMock from '../../helpers/get-api-mock'; +import hashFileContents from '../../helpers/hash-file-contents'; const changelogs = new ChangelogsCommand(); -const changelogsSingle = new SingleChangelogCommand(); const fixturesBaseDir = '__fixtures__/changelogs'; -const fullFixturesDir = `${__dirname}./../${fixturesBaseDir}`; +const fullFixturesDir = `${__dirname}./../../${fixturesBaseDir}`; const key = 'API_KEY'; -function hashFileContents(contents) { - return crypto.createHash('sha1').update(contents).digest('hex'); -} - describe('rdme changelogs', () => { beforeAll(() => nock.disableNetConnect()); @@ -72,7 +66,7 @@ describe('rdme changelogs', () => { it('should fetch changelog and merge with what is returned', () => { expect.assertions(1); - const getMocks = getApiNock() + const getMocks = getAPIMock() .get('/api/v1/changelogs/simple-doc') .basicAuth({ user: key }) .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }) @@ -80,7 +74,7 @@ describe('rdme changelogs', () => { .basicAuth({ user: key }) .reply(200, { slug: anotherDoc.slug, lastUpdatedHash: 'anOldHash' }); - const updateMocks = getApiNock() + const updateMocks = getAPIMock() .put('/api/v1/changelogs/simple-doc', { slug: simpleDoc.slug, body: simpleDoc.doc.content, @@ -119,7 +113,7 @@ describe('rdme changelogs', () => { it('should return changelog update info for dry run', () => { expect.assertions(1); - const getMocks = getApiNock() + const getMocks = getAPIMock() .get('/api/v1/changelogs/simple-doc') .basicAuth({ user: key }) .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }) @@ -150,7 +144,7 @@ describe('rdme changelogs', () => { it('should not send requests for changelogs that have not changed', () => { expect.assertions(1); - const getMocks = getApiNock() + const getMocks = getAPIMock() .get('/api/v1/changelogs/simple-doc') .basicAuth({ user: key }) .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }) @@ -173,7 +167,7 @@ describe('rdme changelogs', () => { it('should adjust "no changes" message if in dry run', () => { expect.assertions(1); - const getMocks = getApiNock() + const getMocks = getAPIMock() .get('/api/v1/changelogs/simple-doc') .basicAuth({ user: key }) .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }) @@ -202,7 +196,7 @@ describe('rdme changelogs', () => { const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - const getMock = getApiNock() + const getMock = getAPIMock() .get(`/api/v1/changelogs/${slug}`) .basicAuth({ user: key }) .reply(404, { @@ -212,7 +206,7 @@ describe('rdme changelogs', () => { help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }); - const postMock = getApiNock() + const postMock = getAPIMock() .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) .basicAuth({ user: key }) .reply(201, { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); @@ -229,7 +223,7 @@ describe('rdme changelogs', () => { const slug = 'new-doc'; const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - const getMock = getApiNock() + const getMock = getAPIMock() .get(`/api/v1/changelogs/${slug}`) .basicAuth({ user: key }) .reply(404, { @@ -268,7 +262,7 @@ describe('rdme changelogs', () => { const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); const hashTwo = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slugTwo}.md`))); - const getMocks = getApiNock() + const getMocks = getAPIMock() .get(`/api/v1/changelogs/${slug}`) .basicAuth({ user: key }) .reply(404, { @@ -286,7 +280,7 @@ describe('rdme changelogs', () => { help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }); - const postMocks = getApiNock() + const postMocks = getAPIMock() .post('/api/v1/changelogs', { slug: slugTwo, body: docTwo.content, ...docTwo.data, lastUpdatedHash: hashTwo }) .basicAuth({ user: key }) .reply(201, { @@ -321,7 +315,7 @@ describe('rdme changelogs', () => { const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); - const getMock = getApiNock() + const getMock = getAPIMock() .get(`/api/v1/changelogs/${doc.data.slug}`) .basicAuth({ user: key }) .reply(404, { @@ -331,7 +325,7 @@ describe('rdme changelogs', () => { help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }); - const postMock = getApiNock() + const postMock = getAPIMock() .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) .basicAuth({ user: key }) .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); @@ -345,293 +339,3 @@ describe('rdme changelogs', () => { }); }); }); - -describe('rdme changelogs:single', () => { - beforeAll(() => nock.disableNetConnect()); - - afterAll(() => nock.cleanAll()); - - it('should error if no api key provided', () => { - return expect(changelogsSingle.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); - }); - - it('should error if no file path provided', () => { - return expect(changelogsSingle.run({ key })).rejects.toThrow( - 'No file path provided. Usage `rdme changelogs:single <file> [options]`.' - ); - }); - - it('should error if the argument is not a Markdown file', async () => { - await expect(changelogsSingle.run({ key, filePath: 'not-a-markdown-file' })).rejects.toThrow( - 'The file path specified is not a Markdown file.' - ); - }); - - it('should support .markdown files but error if file path cannot be found', async () => { - await expect(changelogsSingle.run({ key, filePath: 'non-existent-file.markdown' })).rejects.toThrow( - 'ENOENT: no such file or directory' - ); - }); - - describe('new changelogs', () => { - it('should create new changelog', async () => { - const slug = 'new-doc'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - - const getMock = getApiNock() - .get(`/api/v1/changelogs/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'CHANGELOG_NOTFOUND', - message: `The changelog with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMock = getApiNock() - .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(201, { slug, body: doc.content, ...doc.data }); - - await expect( - changelogsSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key }) - ).resolves.toBe( - `🌱 successfully created 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md` - ); - - getMock.done(); - postMock.done(); - }); - - it('should return creation info for dry run', async () => { - const slug = 'new-doc'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - - const getMock = getApiNock() - .get(`/api/v1/changelogs/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'CHANGELOG_NOTFOUND', - message: `The changelog with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - await expect( - changelogsSingle.run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key }) - ).resolves.toBe( - `🎭 dry run! This will create 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md with the following metadata: ${JSON.stringify( - doc.data - )}` - ); - - getMock.done(); - }); - - it('should fail if the changelog is invalid', async () => { - const folder = 'failure-docs'; - const slug = 'fail-doc'; - - const errorObject = { - error: 'CHANGELOG_INVALID', - message: "We couldn't save this changelog (Changelog title cannot be blank).", - suggestion: 'Make sure all the data is correct, and the body is valid Markdown.', - docs: 'fake-metrics-uuid', - help: "If you need help, email support@readme.io and include the following link to your API log: 'fake-metrics-uuid'.", - }; - - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); - - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); - - const getMock = getApiNock() - .get(`/api/v1/changelogs/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'CHANGELOG_NOTFOUND', - message: `The changelog with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMock = getApiNock() - .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(400, errorObject); - - const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; - - const formattedErrorObject = { - ...errorObject, - message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, - }; - - await expect(changelogsSingle.run({ filePath: `${filePath}`, key })).rejects.toStrictEqual( - new APIError(formattedErrorObject) - ); - - getMock.done(); - postMock.done(); - }); - - it('should fail if some other error when retrieving page slug', async () => { - const slug = 'fail-doc'; - - const errorObject = { - error: 'INTERNAL_ERROR', - message: 'Unknown error (yikes)', - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }; - - const getMock = getApiNock().get(`/api/v1/changelogs/${slug}`).basicAuth({ user: key }).reply(500, errorObject); - - const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; - - const formattedErrorObject = { - ...errorObject, - message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, - }; - - await expect(changelogsSingle.run({ filePath: `${filePath}`, key })).rejects.toStrictEqual( - new APIError(formattedErrorObject) - ); - - getMock.done(); - }); - }); - - describe('slug metadata', () => { - it('should use provided slug', async () => { - const slug = 'new-doc-slug'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); - - const getMock = getApiNock() - .get(`/api/v1/changelogs/${doc.data.slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'CHANGELOG_NOTFOUND', - message: `The changelog with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMock = getApiNock() - .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); - - await expect( - changelogsSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md`, key }) - ).resolves.toBe( - `🌱 successfully created 'marc-actually-wrote-a-test' with contents from ./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md` - ); - - getMock.done(); - postMock.done(); - }); - }); - - describe('existing changelogs', () => { - let simpleDoc; - - beforeEach(() => { - const fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); - simpleDoc = { - slug: 'simple-doc', - doc: frontMatter(fileContents), - hash: hashFileContents(fileContents), - }; - }); - - it('should fetch changelog and merge with what is returned', () => { - const getMock = getApiNock() - .get('/api/v1/changelogs/simple-doc') - .basicAuth({ user: key }) - .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); - - const updateMock = getApiNock() - .put('/api/v1/changelogs/simple-doc', { - slug: simpleDoc.slug, - body: simpleDoc.doc.content, - lastUpdatedHash: simpleDoc.hash, - ...simpleDoc.doc.data, - }) - .basicAuth({ user: key }) - .reply(200, { - slug: simpleDoc.slug, - body: simpleDoc.doc.content, - }); - - return changelogsSingle - .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) - .then(updatedDocs => { - expect(updatedDocs).toBe( - `✏️ successfully updated 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md` - ); - - getMock.done(); - updateMock.done(); - }); - }); - - it('should return changelog update info for dry run', () => { - expect.assertions(1); - - const getMock = getApiNock() - .get('/api/v1/changelogs/simple-doc') - .basicAuth({ user: key }) - .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); - - return changelogsSingle - .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) - .then(updatedDocs => { - // All changelogs should have been updated because their hashes from the GET request were different from what they - // are currently. - expect(updatedDocs).toBe( - [ - `🎭 dry run! This will update 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md with the following metadata: ${JSON.stringify( - simpleDoc.doc.data - )}`, - ].join('\n') - ); - - getMock.done(); - }); - }); - - it('should not send requests for changelogs that have not changed', () => { - expect.assertions(1); - - const getMock = getApiNock() - .get('/api/v1/changelogs/simple-doc') - .basicAuth({ user: key }) - .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); - - return changelogsSingle - .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) - .then(skippedDocs => { - expect(skippedDocs).toBe('`simple-doc` was not updated because there were no changes.'); - - getMock.done(); - }); - }); - - it('should adjust "no changes" message if in dry run', () => { - const getMock = getApiNock() - .get('/api/v1/changelogs/simple-doc') - .basicAuth({ user: key }) - .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); - - return changelogsSingle - .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) - .then(skippedDocs => { - expect(skippedDocs).toBe('🎭 dry run! `simple-doc` will not be updated because there were no changes.'); - - getMock.done(); - }); - }); - }); -}); diff --git a/__tests__/cmds/changelogs/single.test.ts b/__tests__/cmds/changelogs/single.test.ts new file mode 100644 index 000000000..b205e09c6 --- /dev/null +++ b/__tests__/cmds/changelogs/single.test.ts @@ -0,0 +1,307 @@ +import fs from 'fs'; +import path from 'path'; + +import chalk from 'chalk'; +import frontMatter from 'gray-matter'; +import nock from 'nock'; + +import SingleChangelogCommand from '../../../src/cmds/changelogs/single'; +import APIError from '../../../src/lib/apiError'; +import getAPIMock from '../../helpers/get-api-mock'; +import hashFileContents from '../../helpers/hash-file-contents'; + +const changelogsSingle = new SingleChangelogCommand(); + +const fixturesBaseDir = '__fixtures__/changelogs'; +const fullFixturesDir = `${__dirname}./../../${fixturesBaseDir}`; +const key = 'API_KEY'; + +describe('rdme changelogs:single', () => { + beforeAll(() => nock.disableNetConnect()); + + afterAll(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(changelogsSingle.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); + }); + + it('should error if no file path provided', () => { + return expect(changelogsSingle.run({ key })).rejects.toThrow( + 'No file path provided. Usage `rdme changelogs:single <file> [options]`.' + ); + }); + + it('should error if the argument is not a Markdown file', async () => { + await expect(changelogsSingle.run({ key, filePath: 'not-a-markdown-file' })).rejects.toThrow( + 'The file path specified is not a Markdown file.' + ); + }); + + it('should support .markdown files but error if file path cannot be found', async () => { + await expect(changelogsSingle.run({ key, filePath: 'non-existent-file.markdown' })).rejects.toThrow( + 'ENOENT: no such file or directory' + ); + }); + + describe('new changelogs', () => { + it('should create new changelog', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getAPIMock() + .get(`/api/v1/changelogs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMock() + .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, body: doc.content, ...doc.data }); + + await expect( + changelogsSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key }) + ).resolves.toBe( + `🌱 successfully created 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md` + ); + + getMock.done(); + postMock.done(); + }); + + it('should return creation info for dry run', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getAPIMock() + .get(`/api/v1/changelogs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + await expect( + changelogsSingle.run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key }) + ).resolves.toBe( + `🎭 dry run! This will create 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md with the following metadata: ${JSON.stringify( + doc.data + )}` + ); + + getMock.done(); + }); + + it('should fail if the changelog is invalid', async () => { + const folder = 'failure-docs'; + const slug = 'fail-doc'; + + const errorObject = { + error: 'CHANGELOG_INVALID', + message: "We couldn't save this changelog (Changelog title cannot be blank).", + suggestion: 'Make sure all the data is correct, and the body is valid Markdown.', + docs: 'fake-metrics-uuid', + help: "If you need help, email support@readme.io and include the following link to your API log: 'fake-metrics-uuid'.", + }; + + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + + const getMock = getAPIMock() + .get(`/api/v1/changelogs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMock() + .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(400, errorObject); + + const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, + }; + + await expect(changelogsSingle.run({ filePath: `${filePath}`, key })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMock.done(); + postMock.done(); + }); + + it('should fail if some other error when retrieving page slug', async () => { + const slug = 'fail-doc'; + + const errorObject = { + error: 'INTERNAL_ERROR', + message: 'Unknown error (yikes)', + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }; + + const getMock = getAPIMock().get(`/api/v1/changelogs/${slug}`).basicAuth({ user: key }).reply(500, errorObject); + + const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, + }; + + await expect(changelogsSingle.run({ filePath: `${filePath}`, key })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMock.done(); + }); + }); + + describe('slug metadata', () => { + it('should use provided slug', async () => { + const slug = 'new-doc-slug'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + + const getMock = getAPIMock() + .get(`/api/v1/changelogs/${doc.data.slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMock() + .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + await expect( + changelogsSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md`, key }) + ).resolves.toBe( + `🌱 successfully created 'marc-actually-wrote-a-test' with contents from ./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md` + ); + + getMock.done(); + postMock.done(); + }); + }); + + describe('existing changelogs', () => { + let simpleDoc; + + beforeEach(() => { + const fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); + simpleDoc = { + slug: 'simple-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + }); + + it('should fetch changelog and merge with what is returned', () => { + const getMock = getAPIMock() + .get('/api/v1/changelogs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); + + const updateMock = getAPIMock() + .put('/api/v1/changelogs/simple-doc', { + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + lastUpdatedHash: simpleDoc.hash, + ...simpleDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + }); + + return changelogsSingle + .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(updatedDocs => { + expect(updatedDocs).toBe( + `✏️ successfully updated 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md` + ); + + getMock.done(); + updateMock.done(); + }); + }); + + it('should return changelog update info for dry run', () => { + expect.assertions(1); + + const getMock = getAPIMock() + .get('/api/v1/changelogs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); + + return changelogsSingle + .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(updatedDocs => { + // All changelogs should have been updated because their hashes from the GET request were different from what they + // are currently. + expect(updatedDocs).toBe( + [ + `🎭 dry run! This will update 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md with the following metadata: ${JSON.stringify( + simpleDoc.doc.data + )}`, + ].join('\n') + ); + + getMock.done(); + }); + }); + + it('should not send requests for changelogs that have not changed', () => { + expect.assertions(1); + + const getMock = getAPIMock() + .get('/api/v1/changelogs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); + + return changelogsSingle + .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(skippedDocs => { + expect(skippedDocs).toBe('`simple-doc` was not updated because there were no changes.'); + + getMock.done(); + }); + }); + + it('should adjust "no changes" message if in dry run', () => { + const getMock = getAPIMock() + .get('/api/v1/changelogs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); + + return changelogsSingle + .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(skippedDocs => { + expect(skippedDocs).toBe('🎭 dry run! `simple-doc` will not be updated because there were no changes.'); + + getMock.done(); + }); + }); + }); +}); diff --git a/__tests__/cmds/custompages.test.js b/__tests__/cmds/custompages/index.test.ts similarity index 51% rename from __tests__/cmds/custompages.test.js rename to __tests__/cmds/custompages/index.test.ts index f638a6f35..3069d3888 100644 --- a/__tests__/cmds/custompages.test.js +++ b/__tests__/cmds/custompages/index.test.ts @@ -1,27 +1,21 @@ -const nock = require('nock'); -const chalk = require('chalk'); -const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); -const frontMatter = require('gray-matter'); +import fs from 'fs'; +import path from 'path'; -const APIError = require('../../src/lib/apiError'); -const getApiNock = require('../get-api-nock'); +import chalk from 'chalk'; +import frontMatter from 'gray-matter'; +import nock from 'nock'; -const CustomPagesCommand = require('../../src/cmds/custompages'); -const SingleCustomPageCommand = require('../../src/cmds/custompages/single'); +import CustomPagesCommand from '../../../src/cmds/custompages'; +import APIError from '../../../src/lib/apiError'; +import getAPIMock from '../../helpers/get-api-mock'; +import hashFileContents from '../../helpers/hash-file-contents'; const custompages = new CustomPagesCommand(); -const customPagesSingle = new SingleCustomPageCommand(); const fixturesBaseDir = '__fixtures__/custompages'; -const fullFixturesDir = `${__dirname}./../${fixturesBaseDir}`; +const fullFixturesDir = `${__dirname}./../../${fixturesBaseDir}`; const key = 'API_KEY'; -function hashFileContents(contents) { - return crypto.createHash('sha1').update(contents).digest('hex'); -} - describe('rdme custompages', () => { beforeAll(() => nock.disableNetConnect()); @@ -74,7 +68,7 @@ describe('rdme custompages', () => { it('should fetch custom page and merge with what is returned', () => { expect.assertions(1); - const getMocks = getApiNock() + const getMocks = getAPIMock() .get('/api/v1/custompages/simple-doc') .basicAuth({ user: key }) .reply(200, { slug: simpleDoc.slug, htmlmode: false, lastUpdatedHash: 'anOldHash' }) @@ -82,7 +76,7 @@ describe('rdme custompages', () => { .basicAuth({ user: key }) .reply(200, { slug: anotherDoc.slug, htmlmode: false, lastUpdatedHash: 'anOldHash' }); - const updateMocks = getApiNock() + const updateMocks = getAPIMock() .put('/api/v1/custompages/simple-doc', { slug: simpleDoc.slug, body: simpleDoc.doc.content, @@ -124,7 +118,7 @@ describe('rdme custompages', () => { it('should return custom page update info for dry run', () => { expect.assertions(1); - const getMocks = getApiNock() + const getMocks = getAPIMock() .get('/api/v1/custompages/simple-doc') .basicAuth({ user: key }) .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }) @@ -155,7 +149,7 @@ describe('rdme custompages', () => { it('should not send requests for custompages that have not changed', () => { expect.assertions(1); - const getMocks = getApiNock() + const getMocks = getAPIMock() .get('/api/v1/custompages/simple-doc') .basicAuth({ user: key }) .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }) @@ -178,7 +172,7 @@ describe('rdme custompages', () => { it('should adjust "no changes" message if in dry run', () => { expect.assertions(1); - const getMocks = getApiNock() + const getMocks = getAPIMock() .get('/api/v1/custompages/simple-doc') .basicAuth({ user: key }) .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }) @@ -207,7 +201,7 @@ describe('rdme custompages', () => { const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - const getMock = getApiNock() + const getMock = getAPIMock() .get(`/api/v1/custompages/${slug}`) .basicAuth({ user: key }) .reply(404, { @@ -217,7 +211,7 @@ describe('rdme custompages', () => { help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }); - const postMock = getApiNock() + const postMock = getAPIMock() .post('/api/v1/custompages', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) .basicAuth({ user: key }) .reply(201, { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); @@ -235,7 +229,7 @@ describe('rdme custompages', () => { const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs-html/${slug}.html`))); const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs-html/${slug}.html`))); - const getMock = getApiNock() + const getMock = getAPIMock() .get(`/api/v1/custompages/${slug}`) .basicAuth({ user: key }) .reply(404, { @@ -245,7 +239,7 @@ describe('rdme custompages', () => { help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }); - const postMock = getApiNock() + const postMock = getAPIMock() .post('/api/v1/custompages', { slug, html: doc.content, htmlmode: true, ...doc.data, lastUpdatedHash: hash }) .basicAuth({ user: key }) .reply(201, { slug, html: doc.content, htmlmode: true, ...doc.data, lastUpdatedHash: hash }); @@ -262,7 +256,7 @@ describe('rdme custompages', () => { const slug = 'new-doc'; const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - const getMock = getApiNock() + const getMock = getAPIMock() .get(`/api/v1/custompages/${slug}`) .basicAuth({ user: key }) .reply(404, { @@ -301,7 +295,7 @@ describe('rdme custompages', () => { const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); const hashTwo = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slugTwo}.md`))); - const getMocks = getApiNock() + const getMocks = getAPIMock() .get(`/api/v1/custompages/${slug}`) .basicAuth({ user: key }) .reply(404, { @@ -319,7 +313,7 @@ describe('rdme custompages', () => { help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }); - const postMocks = getApiNock() + const postMocks = getAPIMock() .post('/api/v1/custompages', { slug: slugTwo, body: docTwo.content, @@ -360,7 +354,7 @@ describe('rdme custompages', () => { const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); - const getMock = getApiNock() + const getMock = getAPIMock() .get(`/api/v1/custompages/${doc.data.slug}`) .basicAuth({ user: key }) .reply(404, { @@ -370,7 +364,7 @@ describe('rdme custompages', () => { help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }); - const postMock = getApiNock() + const postMock = getAPIMock() .post('/api/v1/custompages', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) .basicAuth({ user: key }) .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); @@ -384,327 +378,3 @@ describe('rdme custompages', () => { }); }); }); - -describe('rdme custompages:single', () => { - beforeAll(() => nock.disableNetConnect()); - - afterAll(() => nock.cleanAll()); - - it('should error if no api key provided', () => { - return expect(customPagesSingle.run({})).rejects.toStrictEqual( - new Error('No project API key provided. Please use `--key`.') - ); - }); - - it('should error if no file path provided', () => { - return expect(customPagesSingle.run({ key })).rejects.toStrictEqual( - new Error('No file path provided. Usage `rdme custompages:single <file> [options]`.') - ); - }); - - it('should error if the argument is not a Markdown file', async () => { - await expect(customPagesSingle.run({ key, filePath: 'not-a-markdown-file' })).rejects.toStrictEqual( - new Error('The file path specified is not a Markdown or HTML file.') - ); - }); - - it('should error if file path cannot be found', async () => { - await expect( - customPagesSingle.run({ key, version: '1.0.0', filePath: 'non-existent-file.markdown' }) - ).rejects.toThrow('ENOENT: no such file or directory'); - }); - - describe('new custompages', () => { - it('should create new custom page', async () => { - const slug = 'new-doc'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - - const getMock = getApiNock() - .get(`/api/v1/custompages/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'CUSTOMPAGE_NOTFOUND', - message: `The custom page with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMock = getApiNock() - .post('/api/v1/custompages', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(201, { slug, body: doc.content, ...doc.data }); - - await expect( - customPagesSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key }) - ).resolves.toBe( - `🌱 successfully created 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md` - ); - - getMock.done(); - postMock.done(); - }); - - it('should create new HTML custom page', async () => { - const slug = 'new-doc'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs-html/${slug}.html`))); - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs-html/${slug}.html`))); - - const getMock = getApiNock() - .get(`/api/v1/custompages/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'CUSTOMPAGE_NOTFOUND', - message: `The custom page with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMock = getApiNock() - .post('/api/v1/custompages', { slug, html: doc.content, htmlmode: true, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(201, { slug, html: doc.content, htmlmode: true, ...doc.data }); - - await expect( - customPagesSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs-html/new-doc.html`, key }) - ).resolves.toBe( - `🌱 successfully created 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs-html/new-doc.html` - ); - - getMock.done(); - postMock.done(); - }); - - it('should return creation info for dry run', async () => { - const slug = 'new-doc'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - - const getMock = getApiNock() - .get(`/api/v1/custompages/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'CUSTOMPAGE_NOTFOUND', - message: `The custom page with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - await expect( - customPagesSingle.run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key }) - ).resolves.toBe( - `🎭 dry run! This will create 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md with the following metadata: ${JSON.stringify( - doc.data - )}` - ); - - getMock.done(); - }); - - it('should fail if the custom page is invalid', async () => { - const folder = 'failure-docs'; - const slug = 'fail-doc'; - - const errorObject = { - error: 'CUSTOMPAGE_INVALID', - message: "We couldn't save this page (Custom page title cannot be blank).", - suggestion: 'Make sure all the data is correct, and the body is valid Markdown or HTML.', - docs: 'fake-metrics-uuid', - help: "If you need help, email support@readme.io and include the following link to your API log: 'fake-metrics-uuid'.", - }; - - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); - - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); - - const getMock = getApiNock() - .get(`/api/v1/custompages/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'CUSTOMPAGE_NOTFOUND', - message: `The custom page with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMock = getApiNock() - .post('/api/v1/custompages', { slug, body: doc.content, htmlmode: false, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(400, errorObject); - - const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; - - const formattedErrorObject = { - ...errorObject, - message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, - }; - - await expect(customPagesSingle.run({ filePath: `${filePath}`, key })).rejects.toStrictEqual( - new APIError(formattedErrorObject) - ); - - getMock.done(); - postMock.done(); - }); - - it('should fail if some other error when retrieving page slug', async () => { - const slug = 'fail-doc'; - - const errorObject = { - error: 'INTERNAL_ERROR', - message: 'Unknown error (yikes)', - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }; - - const getMock = getApiNock().get(`/api/v1/custompages/${slug}`).basicAuth({ user: key }).reply(500, errorObject); - - const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; - - const formattedErrorObject = { - ...errorObject, - message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, - }; - - await expect(customPagesSingle.run({ filePath: `${filePath}`, key })).rejects.toStrictEqual( - new APIError(formattedErrorObject) - ); - - getMock.done(); - }); - }); - - describe('slug metadata', () => { - it('should use provided slug', async () => { - const slug = 'new-doc-slug'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); - - const getMock = getApiNock() - .get(`/api/v1/custompages/${doc.data.slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'CUSTOMPAGE_NOTFOUND', - message: `The custom page with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMock = getApiNock() - .post('/api/v1/custompages', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); - - await expect( - customPagesSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md`, key }) - ).resolves.toBe( - `🌱 successfully created 'marc-actually-wrote-a-test' with contents from ./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md` - ); - - getMock.done(); - postMock.done(); - }); - }); - - describe('existing custompages', () => { - let simpleDoc; - - beforeEach(() => { - const fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); - simpleDoc = { - slug: 'simple-doc', - doc: frontMatter(fileContents), - hash: hashFileContents(fileContents), - }; - }); - - it('should fetch custom page and merge with what is returned', () => { - const getMock = getApiNock() - .get('/api/v1/custompages/simple-doc') - .basicAuth({ user: key }) - .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); - - const updateMock = getApiNock() - .put('/api/v1/custompages/simple-doc', { - slug: simpleDoc.slug, - body: simpleDoc.doc.content, - htmlmode: false, - lastUpdatedHash: simpleDoc.hash, - ...simpleDoc.doc.data, - }) - .basicAuth({ user: key }) - .reply(200, { - slug: simpleDoc.slug, - body: simpleDoc.doc.content, - htmlmode: false, - }); - - return customPagesSingle - .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) - .then(updatedDocs => { - expect(updatedDocs).toBe( - `✏️ successfully updated 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md` - ); - - getMock.done(); - updateMock.done(); - }); - }); - - it('should return custom page update info for dry run', () => { - expect.assertions(1); - - const getMock = getApiNock() - .get('/api/v1/custompages/simple-doc') - .basicAuth({ user: key }) - .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); - - return customPagesSingle - .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) - .then(updatedDocs => { - // All custompages should have been updated because their hashes from the GET request were different from what they - // are currently. - expect(updatedDocs).toBe( - [ - `🎭 dry run! This will update 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md with the following metadata: ${JSON.stringify( - simpleDoc.doc.data - )}`, - ].join('\n') - ); - - getMock.done(); - }); - }); - - it('should not send requests for custompages that have not changed', () => { - expect.assertions(1); - - const getMock = getApiNock() - .get('/api/v1/custompages/simple-doc') - .basicAuth({ user: key }) - .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); - - return customPagesSingle - .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) - .then(skippedDocs => { - expect(skippedDocs).toBe('`simple-doc` was not updated because there were no changes.'); - - getMock.done(); - }); - }); - - it('should adjust "no changes" message if in dry run', () => { - const getMock = getApiNock() - .get('/api/v1/custompages/simple-doc') - .basicAuth({ user: key }) - .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); - - return customPagesSingle - .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) - .then(skippedDocs => { - expect(skippedDocs).toBe('🎭 dry run! `simple-doc` will not be updated because there were no changes.'); - - getMock.done(); - }); - }); - }); -}); diff --git a/__tests__/cmds/custompages/single.test.ts b/__tests__/cmds/custompages/single.test.ts new file mode 100644 index 000000000..22aaff248 --- /dev/null +++ b/__tests__/cmds/custompages/single.test.ts @@ -0,0 +1,341 @@ +import fs from 'fs'; +import path from 'path'; + +import chalk from 'chalk'; +import frontMatter from 'gray-matter'; +import nock from 'nock'; + +import SingleCustomPageCommand from '../../../src/cmds/custompages/single'; +import APIError from '../../../src/lib/apiError'; +import getAPIMock from '../../helpers/get-api-mock'; +import hashFileContents from '../../helpers/hash-file-contents'; + +const customPagesSingle = new SingleCustomPageCommand(); + +const fixturesBaseDir = '__fixtures__/custompages'; +const fullFixturesDir = `${__dirname}./../../${fixturesBaseDir}`; +const key = 'API_KEY'; + +describe('rdme custompages:single', () => { + beforeAll(() => nock.disableNetConnect()); + + afterAll(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(customPagesSingle.run({})).rejects.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); + }); + + it('should error if no file path provided', () => { + return expect(customPagesSingle.run({ key })).rejects.toStrictEqual( + new Error('No file path provided. Usage `rdme custompages:single <file> [options]`.') + ); + }); + + it('should error if the argument is not a Markdown file', async () => { + await expect(customPagesSingle.run({ key, filePath: 'not-a-markdown-file' })).rejects.toStrictEqual( + new Error('The file path specified is not a Markdown or HTML file.') + ); + }); + + it('should error if file path cannot be found', async () => { + await expect( + customPagesSingle.run({ key, version: '1.0.0', filePath: 'non-existent-file.markdown' }) + ).rejects.toThrow('ENOENT: no such file or directory'); + }); + + describe('new custompages', () => { + it('should create new custom page', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getAPIMock() + .get(`/api/v1/custompages/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMock() + .post('/api/v1/custompages', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, body: doc.content, ...doc.data }); + + await expect( + customPagesSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key }) + ).resolves.toBe( + `🌱 successfully created 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md` + ); + + getMock.done(); + postMock.done(); + }); + + it('should create new HTML custom page', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs-html/${slug}.html`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs-html/${slug}.html`))); + + const getMock = getAPIMock() + .get(`/api/v1/custompages/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMock() + .post('/api/v1/custompages', { slug, html: doc.content, htmlmode: true, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, html: doc.content, htmlmode: true, ...doc.data }); + + await expect( + customPagesSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs-html/new-doc.html`, key }) + ).resolves.toBe( + `🌱 successfully created 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs-html/new-doc.html` + ); + + getMock.done(); + postMock.done(); + }); + + it('should return creation info for dry run', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getAPIMock() + .get(`/api/v1/custompages/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + await expect( + customPagesSingle.run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key }) + ).resolves.toBe( + `🎭 dry run! This will create 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md with the following metadata: ${JSON.stringify( + doc.data + )}` + ); + + getMock.done(); + }); + + it('should fail if the custom page is invalid', async () => { + const folder = 'failure-docs'; + const slug = 'fail-doc'; + + const errorObject = { + error: 'CUSTOMPAGE_INVALID', + message: "We couldn't save this page (Custom page title cannot be blank).", + suggestion: 'Make sure all the data is correct, and the body is valid Markdown or HTML.', + docs: 'fake-metrics-uuid', + help: "If you need help, email support@readme.io and include the following link to your API log: 'fake-metrics-uuid'.", + }; + + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + + const getMock = getAPIMock() + .get(`/api/v1/custompages/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMock() + .post('/api/v1/custompages', { slug, body: doc.content, htmlmode: false, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(400, errorObject); + + const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, + }; + + await expect(customPagesSingle.run({ filePath: `${filePath}`, key })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMock.done(); + postMock.done(); + }); + + it('should fail if some other error when retrieving page slug', async () => { + const slug = 'fail-doc'; + + const errorObject = { + error: 'INTERNAL_ERROR', + message: 'Unknown error (yikes)', + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }; + + const getMock = getAPIMock().get(`/api/v1/custompages/${slug}`).basicAuth({ user: key }).reply(500, errorObject); + + const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, + }; + + await expect(customPagesSingle.run({ filePath: `${filePath}`, key })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMock.done(); + }); + }); + + describe('slug metadata', () => { + it('should use provided slug', async () => { + const slug = 'new-doc-slug'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + + const getMock = getAPIMock() + .get(`/api/v1/custompages/${doc.data.slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMock() + .post('/api/v1/custompages', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + await expect( + customPagesSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md`, key }) + ).resolves.toBe( + `🌱 successfully created 'marc-actually-wrote-a-test' with contents from ./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md` + ); + + getMock.done(); + postMock.done(); + }); + }); + + describe('existing custompages', () => { + let simpleDoc; + + beforeEach(() => { + const fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); + simpleDoc = { + slug: 'simple-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + }); + + it('should fetch custom page and merge with what is returned', () => { + const getMock = getAPIMock() + .get('/api/v1/custompages/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); + + const updateMock = getAPIMock() + .put('/api/v1/custompages/simple-doc', { + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + htmlmode: false, + lastUpdatedHash: simpleDoc.hash, + ...simpleDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + htmlmode: false, + }); + + return customPagesSingle + .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(updatedDocs => { + expect(updatedDocs).toBe( + `✏️ successfully updated 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md` + ); + + getMock.done(); + updateMock.done(); + }); + }); + + it('should return custom page update info for dry run', () => { + expect.assertions(1); + + const getMock = getAPIMock() + .get('/api/v1/custompages/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); + + return customPagesSingle + .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(updatedDocs => { + // All custompages should have been updated because their hashes from the GET request were different from what they + // are currently. + expect(updatedDocs).toBe( + [ + `🎭 dry run! This will update 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md with the following metadata: ${JSON.stringify( + simpleDoc.doc.data + )}`, + ].join('\n') + ); + + getMock.done(); + }); + }); + + it('should not send requests for custompages that have not changed', () => { + expect.assertions(1); + + const getMock = getAPIMock() + .get('/api/v1/custompages/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); + + return customPagesSingle + .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(skippedDocs => { + expect(skippedDocs).toBe('`simple-doc` was not updated because there were no changes.'); + + getMock.done(); + }); + }); + + it('should adjust "no changes" message if in dry run', () => { + const getMock = getAPIMock() + .get('/api/v1/custompages/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); + + return customPagesSingle + .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(skippedDocs => { + expect(skippedDocs).toBe('🎭 dry run! `simple-doc` will not be updated because there were no changes.'); + + getMock.done(); + }); + }); + }); +}); diff --git a/__tests__/cmds/docs.test.js b/__tests__/cmds/docs.test.js deleted file mode 100644 index fd2cb3640..000000000 --- a/__tests__/cmds/docs.test.js +++ /dev/null @@ -1,899 +0,0 @@ -/* eslint-disable no-console */ -const nock = require('nock'); -const chalk = require('chalk'); -const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); -const frontMatter = require('gray-matter'); - -const APIError = require('../../src/lib/apiError'); -const getApiNock = require('../get-api-nock'); - -const DocsCommand = require('../../src/cmds/docs'); -const DocsEditCommand = require('../../src/cmds/docs/edit'); -const DocsSingleCommand = require('../../src/cmds/docs/single'); - -const docs = new DocsCommand(); -const docsEdit = new DocsEditCommand(); -const docsSingle = new DocsSingleCommand(); - -const fixturesBaseDir = '__fixtures__/docs'; -const fullFixturesDir = `${__dirname}./../${fixturesBaseDir}`; - -const key = 'API_KEY'; -const version = '1.0.0'; -const category = 'CATEGORY_ID'; -const apiSetting = 'API_SETTING_ID'; - -function getNockWithVersionHeader(v) { - return getApiNock({ - 'x-readme-version': v, - }); -} - -function hashFileContents(contents) { - return crypto.createHash('sha1').update(contents).digest('hex'); -} - -describe('rdme docs', () => { - beforeAll(() => nock.disableNetConnect()); - - afterAll(() => nock.cleanAll()); - - it('should error if no api key provided', () => { - return expect(docs.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); - }); - - it('should error if no folder provided', () => { - return expect(docs.run({ key, version: '1.0.0' })).rejects.toThrow( - 'No folder provided. Usage `rdme docs <folder> [options]`.' - ); - }); - - it('should error if the argument is not a folder', async () => { - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); - - await expect(docs.run({ key, version: '1.0.0', folder: 'not-a-folder' })).rejects.toThrow( - "ENOENT: no such file or directory, scandir 'not-a-folder'" - ); - - versionMock.done(); - }); - - it('should error if the folder contains no markdown files', async () => { - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); - - await expect(docs.run({ key, version: '1.0.0', folder: '.github/workflows' })).rejects.toThrow( - 'We were unable to locate Markdown files in .github/workflows.' - ); - - versionMock.done(); - }); - - describe('existing docs', () => { - let simpleDoc; - let anotherDoc; - - beforeEach(() => { - let fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); - simpleDoc = { - slug: 'simple-doc', - doc: frontMatter(fileContents), - hash: hashFileContents(fileContents), - }; - - fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/subdir/another-doc.md')); - anotherDoc = { - slug: 'another-doc', - doc: frontMatter(fileContents), - hash: hashFileContents(fileContents), - }; - }); - - it('should fetch doc and merge with what is returned', () => { - expect.assertions(1); - - const getMocks = getNockWithVersionHeader(version) - .get('/api/v1/docs/simple-doc') - .basicAuth({ user: key }) - .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }) - .get('/api/v1/docs/another-doc') - .basicAuth({ user: key }) - .reply(200, { category, slug: anotherDoc.slug, lastUpdatedHash: 'anOldHash' }); - - const updateMocks = getNockWithVersionHeader(version) - .put('/api/v1/docs/simple-doc', { - category, - slug: simpleDoc.slug, - body: simpleDoc.doc.content, - lastUpdatedHash: simpleDoc.hash, - ...simpleDoc.doc.data, - }) - .basicAuth({ user: key }) - .reply(200, { - category, - slug: simpleDoc.slug, - body: simpleDoc.doc.content, - }) - .put('/api/v1/docs/another-doc', { - category, - slug: anotherDoc.slug, - body: anotherDoc.doc.content, - lastUpdatedHash: anotherDoc.hash, - ...anotherDoc.doc.data, - }) - .basicAuth({ user: key }) - .reply(200, { category, slug: anotherDoc.slug, body: anotherDoc.doc.content }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - return docs.run({ folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key, version }).then(updatedDocs => { - // All docs should have been updated because their hashes from the GET request were different from what they - // are currently. - expect(updatedDocs).toBe( - [ - `✏️ successfully updated 'simple-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, - `✏️ successfully updated 'another-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/subdir/another-doc.md`, - ].join('\n') - ); - - getMocks.done(); - updateMocks.done(); - versionMock.done(); - }); - }); - - it('should return doc update info for dry run', () => { - expect.assertions(1); - - const getMocks = getNockWithVersionHeader(version) - .get('/api/v1/docs/simple-doc') - .basicAuth({ user: key }) - .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }) - .get('/api/v1/docs/another-doc') - .basicAuth({ user: key }) - .reply(200, { category, slug: anotherDoc.slug, lastUpdatedHash: 'anOldHash' }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - return docs - .run({ dryRun: true, folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key, version }) - .then(updatedDocs => { - // All docs should have been updated because their hashes from the GET request were different from what they - // are currently. - expect(updatedDocs).toBe( - [ - `🎭 dry run! This will update 'simple-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/simple-doc.md with the following metadata: ${JSON.stringify( - simpleDoc.doc.data - )}`, - `🎭 dry run! This will update 'another-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/subdir/another-doc.md with the following metadata: ${JSON.stringify( - anotherDoc.doc.data - )}`, - ].join('\n') - ); - - getMocks.done(); - versionMock.done(); - }); - }); - - it('should not send requests for docs that have not changed', () => { - expect.assertions(1); - - const getMocks = getNockWithVersionHeader(version) - .get('/api/v1/docs/simple-doc') - .basicAuth({ user: key }) - .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }) - .get('/api/v1/docs/another-doc') - .basicAuth({ user: key }) - .reply(200, { category, slug: anotherDoc.slug, lastUpdatedHash: anotherDoc.hash }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - return docs.run({ folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key, version }).then(skippedDocs => { - expect(skippedDocs).toBe( - [ - '`simple-doc` was not updated because there were no changes.', - '`another-doc` was not updated because there were no changes.', - ].join('\n') - ); - - getMocks.done(); - versionMock.done(); - }); - }); - - it('should adjust "no changes" message if in dry run', () => { - expect.assertions(1); - - const getMocks = getNockWithVersionHeader(version) - .get('/api/v1/docs/simple-doc') - .basicAuth({ user: key }) - .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }) - .get('/api/v1/docs/another-doc') - .basicAuth({ user: key }) - .reply(200, { category, slug: anotherDoc.slug, lastUpdatedHash: anotherDoc.hash }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - return docs - .run({ dryRun: true, folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key, version }) - .then(skippedDocs => { - expect(skippedDocs).toBe( - [ - '🎭 dry run! `simple-doc` will not be updated because there were no changes.', - '🎭 dry run! `another-doc` will not be updated because there were no changes.', - ].join('\n') - ); - - getMocks.done(); - versionMock.done(); - }); - }); - }); - - describe('new docs', () => { - it('should create new doc', async () => { - const slug = 'new-doc'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - - const getMock = getNockWithVersionHeader(version) - .get(`/api/v1/docs/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'DOC_NOTFOUND', - message: `The doc with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMock = getNockWithVersionHeader(version) - .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(201, { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - await expect(docs.run({ folder: `./__tests__/${fixturesBaseDir}/new-docs`, key, version })).resolves.toBe( - `🌱 successfully created 'new-doc' with contents from __tests__/${fixturesBaseDir}/new-docs/new-doc.md` - ); - - getMock.done(); - postMock.done(); - versionMock.done(); - }); - - it('should return creation info for dry run', async () => { - const slug = 'new-doc'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - - const getMock = getNockWithVersionHeader(version) - .get(`/api/v1/docs/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'DOC_NOTFOUND', - message: `The doc with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - await expect( - docs.run({ dryRun: true, folder: `./__tests__/${fixturesBaseDir}/new-docs`, key, version }) - ).resolves.toBe( - `🎭 dry run! This will create 'new-doc' with contents from __tests__/${fixturesBaseDir}/new-docs/new-doc.md with the following metadata: ${JSON.stringify( - doc.data - )}` - ); - - getMock.done(); - versionMock.done(); - }); - - it('should fail if any docs are invalid', async () => { - const folder = 'failure-docs'; - const slug = 'fail-doc'; - const slugTwo = 'new-doc'; - - const errorObject = { - error: 'DOC_INVALID', - message: "We couldn't save this doc (Path `category` is required.).", - }; - - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); - const docTwo = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slugTwo}.md`))); - - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); - const hashTwo = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slugTwo}.md`))); - - const getMocks = getNockWithVersionHeader(version) - .get(`/api/v1/docs/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'DOC_NOTFOUND', - message: `The doc with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }) - .get(`/api/v1/docs/${slugTwo}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'DOC_NOTFOUND', - message: `The doc with the slug '${slugTwo}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMocks = getNockWithVersionHeader(version) - .post('/api/v1/docs', { slug: slugTwo, body: docTwo.content, ...docTwo.data, lastUpdatedHash: hashTwo }) - .basicAuth({ user: key }) - .reply(201, { - metadata: { image: [], title: '', description: '' }, - api: { - method: 'post', - url: '', - auth: 'required', - params: [], - apiSetting, - }, - title: 'This is the document title', - updates: [], - type: 'endpoint', - slug: slugTwo, - body: 'Body', - category, - }) - .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(400, errorObject); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - const fullDirectory = `__tests__/${fixturesBaseDir}/${folder}`; - - const formattedErrorObject = { - ...errorObject, - message: `Error uploading ${chalk.underline(`${fullDirectory}/${slug}.md`)}:\n\n${errorObject.message}`, - }; - - await expect(docs.run({ folder: `./${fullDirectory}`, key, version })).rejects.toStrictEqual( - new APIError(formattedErrorObject) - ); - - getMocks.done(); - postMocks.done(); - versionMock.done(); - }); - }); - - describe('slug metadata', () => { - it('should use provided slug', async () => { - const slug = 'new-doc-slug'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); - - const getMock = getApiNock() - .get(`/api/v1/docs/${doc.data.slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'DOC_NOTFOUND', - message: `The doc with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMock = getApiNock() - .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - await expect(docs.run({ folder: `./__tests__/${fixturesBaseDir}/slug-docs`, key, version })).resolves.toBe( - `🌱 successfully created 'marc-actually-wrote-a-test' with contents from __tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md` - ); - - getMock.done(); - postMock.done(); - versionMock.done(); - }); - }); -}); - -describe('rdme docs:edit', () => { - it('should error if no api key provided', () => { - return expect(docsEdit.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); - }); - - it('should error if no slug provided', () => { - return expect(docsEdit.run({ key, version: '1.0.0' })).rejects.toThrow( - 'No slug provided. Usage `rdme docs:edit <slug> [options]`.' - ); - }); - - it('should fetch the doc from the api', async () => { - expect.assertions(5); - console.info = jest.fn(); - const slug = 'getting-started'; - const body = 'abcdef'; - const edits = 'ghijkl'; - - const getMock = getNockWithVersionHeader(version) - .get(`/api/v1/docs/${slug}`) - .basicAuth({ user: key }) - .reply(200, { category, slug, body }); - - const putMock = getNockWithVersionHeader(version) - .put(`/api/v1/docs/${slug}`, { - category, - slug, - body: `${body}${edits}`, - }) - .basicAuth({ user: key }) - .reply(200, { category, slug }); - - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); - - function mockEditor(filename, cb) { - expect(filename).toBe(`${slug}.md`); - expect(fs.existsSync(filename)).toBe(true); - fs.appendFile(filename, edits, cb.bind(null, 0)); - } - - await expect(docsEdit.run({ slug, key, version: '1.0.0', mockEditor })).resolves.toBeUndefined(); - - getMock.done(); - putMock.done(); - versionMock.done(); - - expect(fs.existsSync(`${slug}.md`)).toBe(false); - expect(console.info).toHaveBeenCalledWith('Doc successfully updated. Cleaning up local file.'); - return console.info.mockRestore(); - }); - - it('should error if remote doc does not exist', async () => { - const slug = 'no-such-doc'; - - const errorObject = { - error: 'DOC_NOTFOUND', - message: `The doc with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }; - - const getMock = getApiNock().get(`/api/v1/docs/${slug}`).reply(404, errorObject); - - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); - - await expect(docsEdit.run({ slug, key, version: '1.0.0' })).rejects.toThrow(new APIError(errorObject)); - - getMock.done(); - return versionMock.done(); - }); - - it('should error if doc fails validation', async () => { - const slug = 'getting-started'; - const body = 'abcdef'; - - const errorObject = { - error: 'DOC_INVALID', - message: `We couldn't save this doc (${slug})`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }; - - const getMock = getApiNock().get(`/api/v1/docs/${slug}`).reply(200, { body }); - const putMock = getApiNock().put(`/api/v1/docs/${slug}`).reply(400, errorObject); - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); - - function mockEditor(filename, cb) { - return cb(0); - } - - await expect(docsEdit.run({ slug, key, version: '1.0.0', mockEditor })).rejects.toThrow(new APIError(errorObject)); - - getMock.done(); - putMock.done(); - versionMock.done(); - - expect(fs.existsSync(`${slug}.md`)).toBe(true); - fs.unlinkSync(`${slug}.md`); - }); - - it('should handle error if $EDITOR fails', async () => { - const slug = 'getting-started'; - const body = 'abcdef'; - - const getMock = getApiNock() - .get(`/api/v1/docs/${slug}`) - .reply(200, { body }) - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - function mockEditor(filename, cb) { - return cb(1); - } - - await expect(docsEdit.run({ slug, key, version: '1.0.0', mockEditor })).rejects.toThrow( - new Error('Non zero exit code from $EDITOR') - ); - - getMock.done(); - fs.unlinkSync(`${slug}.md`); - }); -}); - -describe('rdme docs:single', () => { - beforeAll(() => nock.disableNetConnect()); - - afterAll(() => nock.cleanAll()); - - it('should error if no api key provided', () => { - return expect(docsSingle.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); - }); - - it('should error if no file path provided', () => { - return expect(docsSingle.run({ key, version: '1.0.0' })).rejects.toThrow( - 'No file path provided. Usage `rdme docs:single <file> [options]`.' - ); - }); - - it('should error if the argument is not a Markdown file', async () => { - await expect(docsSingle.run({ key, version: '1.0.0', filePath: 'not-a-markdown-file' })).rejects.toThrow( - 'The file path specified is not a Markdown file.' - ); - }); - - it('should support .markdown files but error if file path cannot be found', async () => { - const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); - await expect(docsSingle.run({ key, version: '1.0.0', filePath: 'non-existent-file.markdown' })).rejects.toThrow( - 'ENOENT: no such file or directory' - ); - versionMock.done(); - }); - - describe('new docs', () => { - it('should create new doc', async () => { - const slug = 'new-doc'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - - const getMock = getNockWithVersionHeader(version) - .get(`/api/v1/docs/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'DOC_NOTFOUND', - message: `The doc with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMock = getNockWithVersionHeader(version) - .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(201, { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - await expect( - docsSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key, version }) - ).resolves.toBe( - `🌱 successfully created 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md` - ); - - getMock.done(); - postMock.done(); - versionMock.done(); - }); - - it('should return creation info for dry run', async () => { - const slug = 'new-doc'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); - - const getMock = getNockWithVersionHeader(version) - .get(`/api/v1/docs/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'DOC_NOTFOUND', - message: `The doc with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - await expect( - docsSingle.run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key, version }) - ).resolves.toBe( - `🎭 dry run! This will create 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md with the following metadata: ${JSON.stringify( - doc.data - )}` - ); - - getMock.done(); - versionMock.done(); - }); - - it('should fail if the doc is invalid', async () => { - const folder = 'failure-docs'; - const slug = 'fail-doc'; - - const errorObject = { - error: 'DOC_INVALID', - message: "We couldn't save this doc (Path `category` is required.).", - }; - - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); - - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); - - const getMock = getNockWithVersionHeader(version) - .get(`/api/v1/docs/${slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'DOC_NOTFOUND', - message: `The doc with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMock = getNockWithVersionHeader(version) - .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(400, errorObject); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; - - const formattedErrorObject = { - ...errorObject, - message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, - }; - - await expect(docsSingle.run({ filePath, key, version })).rejects.toStrictEqual( - new APIError(formattedErrorObject) - ); - - getMock.done(); - postMock.done(); - versionMock.done(); - }); - - it('should fail if some other error when retrieving page slug', async () => { - const slug = 'fail-doc'; - - const errorObject = { - error: 'INTERNAL_ERROR', - message: 'Unknown error (yikes)', - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }; - - const getMock = getNockWithVersionHeader(version) - .get(`/api/v1/docs/${slug}`) - .basicAuth({ user: key }) - .reply(500, errorObject); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; - - const formattedErrorObject = { - ...errorObject, - message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, - }; - - await expect(docsSingle.run({ filePath, key, version })).rejects.toStrictEqual( - new APIError(formattedErrorObject) - ); - - getMock.done(); - versionMock.done(); - }); - }); - - describe('slug metadata', () => { - it('should use provided slug', async () => { - const slug = 'new-doc-slug'; - const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); - const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); - - const getMock = getApiNock() - .get(`/api/v1/docs/${doc.data.slug}`) - .basicAuth({ user: key }) - .reply(404, { - error: 'DOC_NOTFOUND', - message: `The doc with the slug '${slug}' couldn't be found`, - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }); - - const postMock = getApiNock() - .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) - .basicAuth({ user: key }) - .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - await expect( - docsSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md`, key, version }) - ).resolves.toBe( - `🌱 successfully created 'marc-actually-wrote-a-test' with contents from ./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md` - ); - - getMock.done(); - postMock.done(); - versionMock.done(); - }); - }); - - describe('existing docs', () => { - let simpleDoc; - - beforeEach(() => { - const fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); - simpleDoc = { - slug: 'simple-doc', - doc: frontMatter(fileContents), - hash: hashFileContents(fileContents), - }; - }); - - it('should fetch doc and merge with what is returned', () => { - const getMock = getNockWithVersionHeader(version) - .get('/api/v1/docs/simple-doc') - .basicAuth({ user: key }) - .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); - - const updateMock = getNockWithVersionHeader(version) - .put('/api/v1/docs/simple-doc', { - category, - slug: simpleDoc.slug, - body: simpleDoc.doc.content, - lastUpdatedHash: simpleDoc.hash, - ...simpleDoc.doc.data, - }) - .basicAuth({ user: key }) - .reply(200, { - category, - slug: simpleDoc.slug, - body: simpleDoc.doc.content, - }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - return docsSingle - .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key, version }) - .then(updatedDocs => { - expect(updatedDocs).toBe( - `✏️ successfully updated 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md` - ); - - getMock.done(); - updateMock.done(); - versionMock.done(); - }); - }); - - it('should return doc update info for dry run', () => { - expect.assertions(1); - - const getMock = getNockWithVersionHeader(version) - .get('/api/v1/docs/simple-doc') - .basicAuth({ user: key }) - .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - return docsSingle - .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key, version }) - .then(updatedDocs => { - // All docs should have been updated because their hashes from the GET request were different from what they - // are currently. - expect(updatedDocs).toBe( - [ - `🎭 dry run! This will update 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md with the following metadata: ${JSON.stringify( - simpleDoc.doc.data - )}`, - ].join('\n') - ); - - getMock.done(); - versionMock.done(); - }); - }); - - it('should not send requests for docs that have not changed', () => { - expect.assertions(1); - - const getMock = getNockWithVersionHeader(version) - .get('/api/v1/docs/simple-doc') - .basicAuth({ user: key }) - .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - return docsSingle - .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key, version }) - .then(skippedDocs => { - expect(skippedDocs).toBe('`simple-doc` was not updated because there were no changes.'); - - getMock.done(); - versionMock.done(); - }); - }); - - it('should adjust "no changes" message if in dry run', () => { - const getMock = getNockWithVersionHeader(version) - .get('/api/v1/docs/simple-doc') - .basicAuth({ user: key }) - .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); - - const versionMock = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - return docsSingle - .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key, version }) - .then(skippedDocs => { - expect(skippedDocs).toBe('🎭 dry run! `simple-doc` will not be updated because there were no changes.'); - - getMock.done(); - versionMock.done(); - }); - }); - }); -}); diff --git a/__tests__/cmds/docs/edit.test.ts b/__tests__/cmds/docs/edit.test.ts new file mode 100644 index 000000000..48660095f --- /dev/null +++ b/__tests__/cmds/docs/edit.test.ts @@ -0,0 +1,136 @@ +import fs from 'fs'; + +import DocsEditCommand from '../../../src/cmds/docs/edit'; +import APIError from '../../../src/lib/apiError'; +import getAPIMock, { getAPIMockWithVersionHeader } from '../../helpers/get-api-mock'; + +const docsEdit = new DocsEditCommand(); + +const key = 'API_KEY'; +const version = '1.0.0'; +const category = 'CATEGORY_ID'; + +describe('rdme docs:edit', () => { + it('should error if no api key provided', () => { + return expect(docsEdit.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); + }); + + it('should error if no slug provided', () => { + return expect(docsEdit.run({ key, version: '1.0.0' })).rejects.toThrow( + 'No slug provided. Usage `rdme docs:edit <slug> [options]`.' + ); + }); + + it('should fetch the doc from the api', async () => { + expect.assertions(5); + const consoleSpy = jest.spyOn(console, 'info').mockImplementation(); + const slug = 'getting-started'; + const body = 'abcdef'; + const edits = 'ghijkl'; + + const getMock = getAPIMockWithVersionHeader(version) + .get(`/api/v1/docs/${slug}`) + .basicAuth({ user: key }) + .reply(200, { category, slug, body }); + + const putMock = getAPIMockWithVersionHeader(version) + .put(`/api/v1/docs/${slug}`, { + category, + slug, + body: `${body}${edits}`, + }) + .basicAuth({ user: key }) + .reply(200, { category, slug }); + + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + function mockEditor(filename, cb) { + expect(filename).toBe(`${slug}.md`); + expect(fs.existsSync(filename)).toBe(true); + fs.appendFile(filename, edits, cb.bind(null, 0)); + } + + await expect(docsEdit.run({ slug, key, version: '1.0.0', mockEditor })).resolves.toBeUndefined(); + + getMock.done(); + putMock.done(); + versionMock.done(); + + expect(fs.existsSync(`${slug}.md`)).toBe(false); + // eslint-disable-next-line no-console + expect(console.info).toHaveBeenCalledWith('Doc successfully updated. Cleaning up local file.'); + consoleSpy.mockRestore(); + }); + + it('should error if remote doc does not exist', async () => { + const slug = 'no-such-doc'; + + const errorObject = { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }; + + const getMock = getAPIMock().get(`/api/v1/docs/${slug}`).reply(404, errorObject); + + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect(docsEdit.run({ slug, key, version: '1.0.0' })).rejects.toThrow(new APIError(errorObject)); + + getMock.done(); + versionMock.done(); + }); + + it('should error if doc fails validation', async () => { + const slug = 'getting-started'; + const body = 'abcdef'; + + const errorObject = { + error: 'DOC_INVALID', + message: `We couldn't save this doc (${slug})`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }; + + const getMock = getAPIMock().get(`/api/v1/docs/${slug}`).reply(200, { body }); + const putMock = getAPIMock().put(`/api/v1/docs/${slug}`).reply(400, errorObject); + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + function mockEditor(filename, cb) { + return cb(0); + } + + await expect(docsEdit.run({ slug, key, version: '1.0.0', mockEditor })).rejects.toThrow(new APIError(errorObject)); + + getMock.done(); + putMock.done(); + versionMock.done(); + + expect(fs.existsSync(`${slug}.md`)).toBe(true); + fs.unlinkSync(`${slug}.md`); + }); + + it('should handle error if $EDITOR fails', async () => { + const slug = 'getting-started'; + const body = 'abcdef'; + + const getMock = getAPIMock() + .get(`/api/v1/docs/${slug}`) + .reply(200, { body }) + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + function mockEditor(filename, cb) { + return cb(1); + } + + await expect(docsEdit.run({ slug, key, version: '1.0.0', mockEditor })).rejects.toThrow( + new Error('Non zero exit code from $EDITOR') + ); + + getMock.done(); + fs.unlinkSync(`${slug}.md`); + }); +}); diff --git a/__tests__/cmds/docs/index.test.ts b/__tests__/cmds/docs/index.test.ts new file mode 100644 index 000000000..3ad85117d --- /dev/null +++ b/__tests__/cmds/docs/index.test.ts @@ -0,0 +1,412 @@ +import fs from 'fs'; +import path from 'path'; + +import chalk from 'chalk'; +import frontMatter from 'gray-matter'; +import nock from 'nock'; + +import DocsCommand from '../../../src/cmds/docs'; +import APIError from '../../../src/lib/apiError'; +import getAPIMock, { getAPIMockWithVersionHeader } from '../../helpers/get-api-mock'; +import hashFileContents from '../../helpers/hash-file-contents'; + +const docs = new DocsCommand(); + +const fixturesBaseDir = '__fixtures__/docs'; +const fullFixturesDir = `${__dirname}./../../${fixturesBaseDir}`; + +const key = 'API_KEY'; +const version = '1.0.0'; +const category = 'CATEGORY_ID'; +const apiSetting = 'API_SETTING_ID'; + +describe('rdme docs', () => { + beforeAll(() => nock.disableNetConnect()); + + afterAll(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(docs.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); + }); + + it('should error if no folder provided', () => { + return expect(docs.run({ key, version: '1.0.0' })).rejects.toThrow( + 'No folder provided. Usage `rdme docs <folder> [options]`.' + ); + }); + + it('should error if the argument is not a folder', async () => { + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect(docs.run({ key, version: '1.0.0', folder: 'not-a-folder' })).rejects.toThrow( + "ENOENT: no such file or directory, scandir 'not-a-folder'" + ); + + versionMock.done(); + }); + + it('should error if the folder contains no markdown files', async () => { + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect(docs.run({ key, version: '1.0.0', folder: '.github/workflows' })).rejects.toThrow( + 'We were unable to locate Markdown files in .github/workflows.' + ); + + versionMock.done(); + }); + + describe('existing docs', () => { + let simpleDoc; + let anotherDoc; + + beforeEach(() => { + let fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); + simpleDoc = { + slug: 'simple-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + + fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/subdir/another-doc.md')); + anotherDoc = { + slug: 'another-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + }); + + it('should fetch doc and merge with what is returned', () => { + expect.assertions(1); + + const getMocks = getAPIMockWithVersionHeader(version) + .get('/api/v1/docs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }) + .get('/api/v1/docs/another-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: anotherDoc.slug, lastUpdatedHash: 'anOldHash' }); + + const updateMocks = getAPIMockWithVersionHeader(version) + .put('/api/v1/docs/simple-doc', { + category, + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + lastUpdatedHash: simpleDoc.hash, + ...simpleDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { + category, + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + }) + .put('/api/v1/docs/another-doc', { + category, + slug: anotherDoc.slug, + body: anotherDoc.doc.content, + lastUpdatedHash: anotherDoc.hash, + ...anotherDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { category, slug: anotherDoc.slug, body: anotherDoc.doc.content }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + return docs.run({ folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key, version }).then(updatedDocs => { + // All docs should have been updated because their hashes from the GET request were different from what they + // are currently. + expect(updatedDocs).toBe( + [ + `✏️ successfully updated 'simple-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, + `✏️ successfully updated 'another-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/subdir/another-doc.md`, + ].join('\n') + ); + + getMocks.done(); + updateMocks.done(); + versionMock.done(); + }); + }); + + it('should return doc update info for dry run', () => { + expect.assertions(1); + + const getMocks = getAPIMockWithVersionHeader(version) + .get('/api/v1/docs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }) + .get('/api/v1/docs/another-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: anotherDoc.slug, lastUpdatedHash: 'anOldHash' }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + return docs + .run({ dryRun: true, folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key, version }) + .then(updatedDocs => { + // All docs should have been updated because their hashes from the GET request were different from what they + // are currently. + expect(updatedDocs).toBe( + [ + `🎭 dry run! This will update 'simple-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/simple-doc.md with the following metadata: ${JSON.stringify( + simpleDoc.doc.data + )}`, + `🎭 dry run! This will update 'another-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/subdir/another-doc.md with the following metadata: ${JSON.stringify( + anotherDoc.doc.data + )}`, + ].join('\n') + ); + + getMocks.done(); + versionMock.done(); + }); + }); + + it('should not send requests for docs that have not changed', () => { + expect.assertions(1); + + const getMocks = getAPIMockWithVersionHeader(version) + .get('/api/v1/docs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }) + .get('/api/v1/docs/another-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: anotherDoc.slug, lastUpdatedHash: anotherDoc.hash }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + return docs.run({ folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key, version }).then(skippedDocs => { + expect(skippedDocs).toBe( + [ + '`simple-doc` was not updated because there were no changes.', + '`another-doc` was not updated because there were no changes.', + ].join('\n') + ); + + getMocks.done(); + versionMock.done(); + }); + }); + + it('should adjust "no changes" message if in dry run', () => { + expect.assertions(1); + + const getMocks = getAPIMockWithVersionHeader(version) + .get('/api/v1/docs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }) + .get('/api/v1/docs/another-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: anotherDoc.slug, lastUpdatedHash: anotherDoc.hash }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + return docs + .run({ dryRun: true, folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key, version }) + .then(skippedDocs => { + expect(skippedDocs).toBe( + [ + '🎭 dry run! `simple-doc` will not be updated because there were no changes.', + '🎭 dry run! `another-doc` will not be updated because there were no changes.', + ].join('\n') + ); + + getMocks.done(); + versionMock.done(); + }); + }); + }); + + describe('new docs', () => { + it('should create new doc', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getAPIMockWithVersionHeader(version) + .get(`/api/v1/docs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMockWithVersionHeader(version) + .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect(docs.run({ folder: `./__tests__/${fixturesBaseDir}/new-docs`, key, version })).resolves.toBe( + `🌱 successfully created 'new-doc' with contents from __tests__/${fixturesBaseDir}/new-docs/new-doc.md` + ); + + getMock.done(); + postMock.done(); + versionMock.done(); + }); + + it('should return creation info for dry run', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getAPIMockWithVersionHeader(version) + .get(`/api/v1/docs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect( + docs.run({ dryRun: true, folder: `./__tests__/${fixturesBaseDir}/new-docs`, key, version }) + ).resolves.toBe( + `🎭 dry run! This will create 'new-doc' with contents from __tests__/${fixturesBaseDir}/new-docs/new-doc.md with the following metadata: ${JSON.stringify( + doc.data + )}` + ); + + getMock.done(); + versionMock.done(); + }); + + it('should fail if any docs are invalid', async () => { + const folder = 'failure-docs'; + const slug = 'fail-doc'; + const slugTwo = 'new-doc'; + + const errorObject = { + error: 'DOC_INVALID', + message: "We couldn't save this doc (Path `category` is required.).", + }; + + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + const docTwo = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slugTwo}.md`))); + + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + const hashTwo = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slugTwo}.md`))); + + const getMocks = getAPIMockWithVersionHeader(version) + .get(`/api/v1/docs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }) + .get(`/api/v1/docs/${slugTwo}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slugTwo}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMocks = getAPIMockWithVersionHeader(version) + .post('/api/v1/docs', { slug: slugTwo, body: docTwo.content, ...docTwo.data, lastUpdatedHash: hashTwo }) + .basicAuth({ user: key }) + .reply(201, { + metadata: { image: [], title: '', description: '' }, + api: { + method: 'post', + url: '', + auth: 'required', + params: [], + apiSetting, + }, + title: 'This is the document title', + updates: [], + type: 'endpoint', + slug: slugTwo, + body: 'Body', + category, + }) + .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(400, errorObject); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + const fullDirectory = `__tests__/${fixturesBaseDir}/${folder}`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${fullDirectory}/${slug}.md`)}:\n\n${errorObject.message}`, + }; + + await expect(docs.run({ folder: `./${fullDirectory}`, key, version })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMocks.done(); + postMocks.done(); + versionMock.done(); + }); + }); + + describe('slug metadata', () => { + it('should use provided slug', async () => { + const slug = 'new-doc-slug'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + + const getMock = getAPIMock() + .get(`/api/v1/docs/${doc.data.slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMock() + .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect(docs.run({ folder: `./__tests__/${fixturesBaseDir}/slug-docs`, key, version })).resolves.toBe( + `🌱 successfully created 'marc-actually-wrote-a-test' with contents from __tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md` + ); + + getMock.done(); + postMock.done(); + versionMock.done(); + }); + }); +}); diff --git a/__tests__/cmds/docs/single.test.ts b/__tests__/cmds/docs/single.test.ts new file mode 100644 index 000000000..acc6d4ea0 --- /dev/null +++ b/__tests__/cmds/docs/single.test.ts @@ -0,0 +1,368 @@ +import fs from 'fs'; +import path from 'path'; + +import chalk from 'chalk'; +import frontMatter from 'gray-matter'; +import nock from 'nock'; + +import DocsSingleCommand from '../../../src/cmds/docs/single'; +import APIError from '../../../src/lib/apiError'; +import getAPIMock, { getAPIMockWithVersionHeader } from '../../helpers/get-api-mock'; +import hashFileContents from '../../helpers/hash-file-contents'; + +const docsSingle = new DocsSingleCommand(); + +const fixturesBaseDir = '__fixtures__/docs'; +const fullFixturesDir = `${__dirname}./../../${fixturesBaseDir}`; + +const key = 'API_KEY'; +const version = '1.0.0'; +const category = 'CATEGORY_ID'; + +describe('rdme docs:single', () => { + beforeAll(() => nock.disableNetConnect()); + + afterAll(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(docsSingle.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); + }); + + it('should error if no file path provided', () => { + return expect(docsSingle.run({ key, version: '1.0.0' })).rejects.toThrow( + 'No file path provided. Usage `rdme docs:single <file> [options]`.' + ); + }); + + it('should error if the argument is not a Markdown file', async () => { + await expect(docsSingle.run({ key, version: '1.0.0', filePath: 'not-a-markdown-file' })).rejects.toThrow( + 'The file path specified is not a Markdown file.' + ); + }); + + it('should support .markdown files but error if file path cannot be found', async () => { + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + await expect(docsSingle.run({ key, version: '1.0.0', filePath: 'non-existent-file.markdown' })).rejects.toThrow( + 'ENOENT: no such file or directory' + ); + versionMock.done(); + }); + + describe('new docs', () => { + it('should create new doc', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getAPIMockWithVersionHeader(version) + .get(`/api/v1/docs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMockWithVersionHeader(version) + .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect( + docsSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key, version }) + ).resolves.toBe( + `🌱 successfully created 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md` + ); + + getMock.done(); + postMock.done(); + versionMock.done(); + }); + + it('should return creation info for dry run', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getAPIMockWithVersionHeader(version) + .get(`/api/v1/docs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect( + docsSingle.run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key, version }) + ).resolves.toBe( + `🎭 dry run! This will create 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md with the following metadata: ${JSON.stringify( + doc.data + )}` + ); + + getMock.done(); + versionMock.done(); + }); + + it('should fail if the doc is invalid', async () => { + const folder = 'failure-docs'; + const slug = 'fail-doc'; + + const errorObject = { + error: 'DOC_INVALID', + message: "We couldn't save this doc (Path `category` is required.).", + }; + + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + + const getMock = getAPIMockWithVersionHeader(version) + .get(`/api/v1/docs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMockWithVersionHeader(version) + .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(400, errorObject); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, + }; + + await expect(docsSingle.run({ filePath, key, version })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMock.done(); + postMock.done(); + versionMock.done(); + }); + + it('should fail if some other error when retrieving page slug', async () => { + const slug = 'fail-doc'; + + const errorObject = { + error: 'INTERNAL_ERROR', + message: 'Unknown error (yikes)', + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }; + + const getMock = getAPIMockWithVersionHeader(version) + .get(`/api/v1/docs/${slug}`) + .basicAuth({ user: key }) + .reply(500, errorObject); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, + }; + + await expect(docsSingle.run({ filePath, key, version })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMock.done(); + versionMock.done(); + }); + }); + + describe('slug metadata', () => { + it('should use provided slug', async () => { + const slug = 'new-doc-slug'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + + const getMock = getAPIMock() + .get(`/api/v1/docs/${doc.data.slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'DOC_NOTFOUND', + message: `The doc with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getAPIMock() + .post('/api/v1/docs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect( + docsSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md`, key, version }) + ).resolves.toBe( + `🌱 successfully created 'marc-actually-wrote-a-test' with contents from ./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md` + ); + + getMock.done(); + postMock.done(); + versionMock.done(); + }); + }); + + describe('existing docs', () => { + let simpleDoc; + + beforeEach(() => { + const fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); + simpleDoc = { + slug: 'simple-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + }); + + it('should fetch doc and merge with what is returned', () => { + const getMock = getAPIMockWithVersionHeader(version) + .get('/api/v1/docs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); + + const updateMock = getAPIMockWithVersionHeader(version) + .put('/api/v1/docs/simple-doc', { + category, + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + lastUpdatedHash: simpleDoc.hash, + ...simpleDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { + category, + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + return docsSingle + .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key, version }) + .then(updatedDocs => { + expect(updatedDocs).toBe( + `✏️ successfully updated 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md` + ); + + getMock.done(); + updateMock.done(); + versionMock.done(); + }); + }); + + it('should return doc update info for dry run', () => { + expect.assertions(1); + + const getMock = getAPIMockWithVersionHeader(version) + .get('/api/v1/docs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + return docsSingle + .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key, version }) + .then(updatedDocs => { + // All docs should have been updated because their hashes from the GET request were different from what they + // are currently. + expect(updatedDocs).toBe( + [ + `🎭 dry run! This will update 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md with the following metadata: ${JSON.stringify( + simpleDoc.doc.data + )}`, + ].join('\n') + ); + + getMock.done(); + versionMock.done(); + }); + }); + + it('should not send requests for docs that have not changed', () => { + expect.assertions(1); + + const getMock = getAPIMockWithVersionHeader(version) + .get('/api/v1/docs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + return docsSingle + .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key, version }) + .then(skippedDocs => { + expect(skippedDocs).toBe('`simple-doc` was not updated because there were no changes.'); + + getMock.done(); + versionMock.done(); + }); + }); + + it('should adjust "no changes" message if in dry run', () => { + const getMock = getAPIMockWithVersionHeader(version) + .get('/api/v1/docs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { category, slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + return docsSingle + .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key, version }) + .then(skippedDocs => { + expect(skippedDocs).toBe('🎭 dry run! `simple-doc` will not be updated because there were no changes.'); + + getMock.done(); + versionMock.done(); + }); + }); + }); +}); diff --git a/__tests__/cmds/login.test.js b/__tests__/cmds/login.test.ts similarity index 92% rename from __tests__/cmds/login.test.js rename to __tests__/cmds/login.test.ts index ac3067d18..43b6e6396 100644 --- a/__tests__/cmds/login.test.js +++ b/__tests__/cmds/login.test.ts @@ -1,8 +1,9 @@ -const nock = require('nock'); -const configStore = require('../../src/lib/configstore'); -const Command = require('../../src/cmds/login'); -const APIError = require('../../src/lib/apiError'); -const getApiNock = require('../get-api-nock'); +import nock from 'nock'; + +import Command from '../../src/cmds/login'; +import APIError from '../../src/lib/apiError'; +import configStore from '../../src/lib/configstore'; +import getApiNock from '../helpers/get-api-mock'; const cmd = new Command(); diff --git a/__tests__/cmds/logout.test.js b/__tests__/cmds/logout.test.ts similarity index 84% rename from __tests__/cmds/logout.test.js rename to __tests__/cmds/logout.test.ts index 44a4524b6..e5a7386e9 100644 --- a/__tests__/cmds/logout.test.js +++ b/__tests__/cmds/logout.test.ts @@ -1,6 +1,7 @@ -const config = require('config'); -const configStore = require('../../src/lib/configstore'); -const Command = require('../../src/cmds/logout'); +import config from 'config'; + +import Command from '../../src/cmds/logout'; +import configStore from '../../src/lib/configstore'; const cmd = new Command(); diff --git a/__tests__/cmds/open.test.js b/__tests__/cmds/open.test.ts similarity index 79% rename from __tests__/cmds/open.test.js rename to __tests__/cmds/open.test.ts index 4ef2297e2..f84e2c2c6 100644 --- a/__tests__/cmds/open.test.js +++ b/__tests__/cmds/open.test.ts @@ -1,7 +1,8 @@ -const chalk = require('chalk'); -const config = require('config'); -const configStore = require('../../src/lib/configstore'); -const Command = require('../../src/cmds/open'); +import chalk from 'chalk'; +import config from 'config'; + +import Command from '../../src/cmds/open'; +import configStore from '../../src/lib/configstore'; const cmd = new Command(); diff --git a/__tests__/cmds/openapi.test.js b/__tests__/cmds/openapi.test.ts similarity index 93% rename from __tests__/cmds/openapi.test.js rename to __tests__/cmds/openapi.test.ts index 927d12951..ee3a7652c 100644 --- a/__tests__/cmds/openapi.test.js +++ b/__tests__/cmds/openapi.test.ts @@ -1,17 +1,24 @@ /* eslint-disable no-console */ -const nock = require('nock'); -const chalk = require('chalk'); -const config = require('config'); -const fs = require('fs'); +import fs from 'fs'; + +import chalk from 'chalk'; +import config from 'config'; +import nock from 'nock'; + +import OpenAPICommand from '../../src/cmds/openapi'; +import SwaggerCommand from '../../src/cmds/swagger'; +import APIError from '../../src/lib/apiError'; +import getAPIMock from '../helpers/get-api-mock'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires const promptHandler = require('../../src/lib/prompts'); -const SwaggerCommand = require('../../src/cmds/swagger'); -const OpenAPICommand = require('../../src/cmds/openapi'); -const APIError = require('../../src/lib/apiError'); -const getApiNock = require('../get-api-nock'); const openapi = new OpenAPICommand(); const swagger = new SwaggerCommand(); +let consoleInfoSpy; +let consoleWarnSpy; + const key = 'API_KEY'; const id = '5aa0409b7cf527a93bfb44df'; const version = '1.0.0'; @@ -41,7 +48,7 @@ const testWorkingDir = process.cwd(); jest.mock('../../src/lib/prompts'); const getCommandOutput = () => { - return [console.warn.mock.calls.join('\n\n'), console.info.mock.calls.join('\n\n')].filter(Boolean).join('\n\n'); + return [consoleWarnSpy.mock.calls.join('\n\n'), consoleInfoSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n'); }; const getRandomRegistryId = () => Math.random().toString(36).substring(2); @@ -50,13 +57,13 @@ describe('rdme openapi', () => { beforeAll(() => nock.disableNetConnect()); beforeEach(() => { - console.info = jest.fn(); - console.warn = jest.fn(); + consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); }); afterEach(() => { - console.info.mockRestore(); - console.warn.mockRestore(); + consoleInfoSpy.mockRestore(); + consoleWarnSpy.mockRestore(); nock.cleanAll(); @@ -74,7 +81,7 @@ describe('rdme openapi', () => { ])('should support uploading a %s definition (format: %s)', async (_, format, specVersion, type) => { const registryUUID = getRandomRegistryId(); - const mock = getApiNock() + const mock = getAPIMock() .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) .reply(201, { registryUUID, spec: { openapi: specVersion } }) .get('/api/v1/api-specification') @@ -105,7 +112,7 @@ describe('rdme openapi', () => { it('should discover and upload an API definition if none is provided', async () => { const registryUUID = getRandomRegistryId(); - const mock = getApiNock() + const mock = getAPIMock() .get('/api/v1/version') .basicAuth({ user: key }) .reply(200, [{ version }]) @@ -141,9 +148,9 @@ describe('rdme openapi', () => { it.todo('should test spec selection prompts'); it('should bundle and upload the expected content', async () => { - let requestBody = null; + let requestBody; const registryUUID = getRandomRegistryId(); - const mock = getApiNock() + const mock = getAPIMock() .get(`/api/v1/version/${version}`) .basicAuth({ user: key }) .reply(200, { version: '1.0.0' }) @@ -173,9 +180,9 @@ describe('rdme openapi', () => { }); it('should use specified working directory and upload the expected content', async () => { - let requestBody = null; + let requestBody; const registryUUID = getRandomRegistryId(); - const mock = getApiNock() + const mock = getAPIMock() .get(`/api/v1/version/${version}`) .basicAuth({ user: key }) .reply(200, { version: '1.0.0' }) @@ -223,7 +230,7 @@ describe('rdme openapi', () => { ])('should support updating a %s definition (format: %s)', async (_, format, specVersion, type) => { const registryUUID = getRandomRegistryId(); - const mock = getApiNock() + const mock = getAPIMock() .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) .reply(201, { registryUUID, spec: { openapi: specVersion } }) .put(`/api/v1/api-specification/${id}`, { registryUUID }) @@ -247,7 +254,7 @@ describe('rdme openapi', () => { it('should return warning if providing `id` and `version`', async () => { const registryUUID = getRandomRegistryId(); - const mock = getApiNock() + const mock = getAPIMock() .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) .put(`/api/v1/api-specification/${id}`, { registryUUID }) @@ -288,7 +295,7 @@ describe('rdme openapi', () => { ], }; - const mock = getApiNock().get(`/api/v1/version/${invalidVersion}`).reply(404, errorObject); + const mock = getAPIMock().get(`/api/v1/version/${invalidVersion}`).reply(404, errorObject); await expect( openapi.run({ @@ -309,7 +316,7 @@ describe('rdme openapi', () => { const registryUUID = getRandomRegistryId(); - const mock = getApiNock() + const mock = getAPIMock() .get('/api/v1/version') .basicAuth({ user: key }) .reply(200, [{ version: '1.0.0' }]) @@ -356,7 +363,7 @@ describe('rdme openapi', () => { ], }; - const mock = getApiNock().get('/api/v1/version').reply(401, errorObject); + const mock = getAPIMock().get('/api/v1/version').reply(401, errorObject); await expect( openapi.run({ key, spec: require.resolve('@readme/oas-examples/3.1/json/petstore.json') }) @@ -366,7 +373,7 @@ describe('rdme openapi', () => { }); it('should error if no file was provided or able to be discovered', async () => { - const mock = getApiNock() + const mock = getAPIMock() .get(`/api/v1/version/${version}`) .basicAuth({ user: key }) .reply(200, { version: '1.0.0' }); @@ -404,7 +411,7 @@ describe('rdme openapi', () => { const registryUUID = getRandomRegistryId(); - const mock = getApiNock() + const mock = getAPIMock() .get(`/api/v1/version/${version}`) .basicAuth({ user: key }) .reply(200, { version: '1.0.0' }) @@ -439,7 +446,7 @@ describe('rdme openapi', () => { const registryUUID = getRandomRegistryId(); - const mock = getApiNock() + const mock = getAPIMock() .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) .put(`/api/v1/api-specification/${id}`, { registryUUID }) @@ -467,7 +474,7 @@ describe('rdme openapi', () => { help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }; - const mock = getApiNock() + const mock = getAPIMock() .get(`/api/v1/version/${version}`) .basicAuth({ user: key }) .reply(200, { version: '1.0.0' }) @@ -496,7 +503,7 @@ describe('rdme openapi', () => { const registryUUID = getRandomRegistryId(); - const mock = getApiNock() + const mock = getAPIMock() .get(`/api/v1/version/${version}`) .basicAuth({ user: key }) .reply(200, { version: '1.0.0' }) @@ -520,7 +527,7 @@ describe('rdme openapi', () => { it('should error if API errors (generic upload error)', async () => { const registryUUID = getRandomRegistryId(); - const mock = getApiNock() + const mock = getAPIMock() .get(`/api/v1/version/${version}`) .basicAuth({ user: key }) .reply(200, { version: '1.0.0' }) @@ -548,7 +555,7 @@ describe('rdme openapi', () => { it('should error if API errors (request timeout)', async () => { const registryUUID = getRandomRegistryId(); - const mock = getApiNock() + const mock = getAPIMock() .get(`/api/v1/version/${version}`) .basicAuth({ user: key }) .reply(200, { version: '1.0.0' }) diff --git a/__tests__/cmds/validate.test.js b/__tests__/cmds/validate.test.ts similarity index 90% rename from __tests__/cmds/validate.test.js rename to __tests__/cmds/validate.test.ts index 9218ff9d3..c62def741 100644 --- a/__tests__/cmds/validate.test.js +++ b/__tests__/cmds/validate.test.ts @@ -1,23 +1,27 @@ /* eslint-disable no-console */ -const fs = require('fs'); -const chalk = require('chalk'); -const Command = require('../../src/cmds/validate'); +import fs from 'fs'; + +import chalk from 'chalk'; + +import Command from '../../src/cmds/validate'; const testWorkingDir = process.cwd(); const validate = new Command(); +let consoleSpy; + const getCommandOutput = () => { - return [console.info.mock.calls.join('\n\n')].filter(Boolean).join('\n\n'); + return [consoleSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n'); }; describe('rdme validate', () => { beforeEach(() => { - console.info = jest.fn(); + consoleSpy = jest.spyOn(console, 'info').mockImplementation(); }); afterEach(() => { - console.info.mockRestore(); + consoleSpy.mockRestore(); process.chdir(testWorkingDir); }); diff --git a/__tests__/cmds/versions.test.js b/__tests__/cmds/versions.test.js deleted file mode 100644 index 791864725..000000000 --- a/__tests__/cmds/versions.test.js +++ /dev/null @@ -1,250 +0,0 @@ -const nock = require('nock'); -const promptHandler = require('../../src/lib/prompts'); -const APIError = require('../../src/lib/apiError'); -const getApiNock = require('../get-api-nock'); - -const VersionsCommand = require('../../src/cmds/versions'); -const CreateVersionCommand = require('../../src/cmds/versions/create'); -const DeleteVersionCommand = require('../../src/cmds/versions/delete'); -const UpdateVersionCommand = require('../../src/cmds/versions/update'); - -const key = 'API_KEY'; -const version = '1.0.0'; -const version2 = '2.0.0'; - -const versionPayload = { - createdAt: '2019-06-17T22:39:56.462Z', - is_deprecated: false, - is_hidden: false, - is_beta: false, - is_stable: true, - codename: '', - version, -}; - -const version2Payload = { - createdAt: '2019-06-17T22:39:56.462Z', - is_deprecated: false, - is_hidden: false, - is_beta: false, - is_stable: true, - codename: '', - version: version2, -}; - -jest.mock('../../src/lib/prompts'); - -describe('rdme versions*', () => { - beforeAll(() => nock.disableNetConnect()); - - afterEach(() => nock.cleanAll()); - - describe('rdme versions', () => { - const versions = new VersionsCommand(); - - it('should error if no api key provided', () => { - return expect(versions.run({})).rejects.toStrictEqual( - new Error('No project API key provided. Please use `--key`.') - ); - }); - - it('should make a request to get a list of existing versions', async () => { - const mockRequest = getApiNock() - .get('/api/v1/version') - .basicAuth({ user: key }) - .reply(200, [versionPayload, version2Payload]); - - const table = await versions.run({ key }); - expect(table).toContain(version); - expect(table).toContain(version2); - mockRequest.done(); - }); - - it('should make a request to get a list of existing versions and return them in a raw format', async () => { - const mockRequest = getApiNock() - .get('/api/v1/version') - .basicAuth({ user: key }) - .reply(200, [versionPayload, version2Payload]); - - const raw = await versions.run({ key, raw: true }); - expect(raw).toStrictEqual(JSON.stringify([versionPayload, version2Payload], null, 2)); - mockRequest.done(); - }); - - it('should get a specific version object if version flag provided', async () => { - const mockRequest = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, versionPayload); - - const table = await versions.run({ key, version }); - expect(table).toContain(version); - expect(table).not.toContain(version2); - mockRequest.done(); - }); - - it('should get a specific version object if version flag provided and return it in a raw format', async () => { - const mockRequest = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, versionPayload); - - const raw = await versions.run({ key, version, raw: true }); - expect(raw).toStrictEqual(JSON.stringify(versionPayload, null, 2)); - mockRequest.done(); - }); - }); - - describe('rdme versions:create', () => { - const createVersion = new CreateVersionCommand(); - - it('should error if no api key provided', () => { - return expect(createVersion.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); - }); - - it('should create a specific version', async () => { - promptHandler.createVersionPrompt.mockResolvedValue({ - is_stable: true, - is_beta: false, - from: '1.0.0', - }); - - const mockRequest = getApiNock() - .get('/api/v1/version') - .basicAuth({ user: key }) - .reply(200, [{ version }, { version }]) - .post('/api/v1/version') - .basicAuth({ user: key }) - .reply(201, { version }); - - await expect(createVersion.run({ key, version })).resolves.toBe('Version 1.0.0 created successfully.'); - mockRequest.done(); - }); - - it('should catch any post request errors', async () => { - expect.assertions(1); - promptHandler.createVersionPrompt.mockResolvedValue({ - is_stable: false, - is_beta: false, - }); - - const errorResponse = { - error: 'VERSION_EMPTY', - message: 'You need to include an x-readme-version header', - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }; - - const mockRequest = getApiNock().post('/api/v1/version').basicAuth({ user: key }).reply(400, errorResponse); - - await expect(createVersion.run({ key, version, fork: '0.0.5' })).rejects.toStrictEqual( - new APIError(errorResponse) - ); - mockRequest.done(); - }); - }); - - describe('rdme versions:delete', () => { - const deleteVersion = new DeleteVersionCommand(); - - it('should error if no api key provided', () => { - return expect(deleteVersion.run({})).rejects.toStrictEqual( - new Error('No project API key provided. Please use `--key`.') - ); - }); - - it('should delete a specific version', async () => { - const mockRequest = getApiNock() - .delete(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { removed: true }) - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - await expect(deleteVersion.run({ key, version })).resolves.toBe('Version 1.0.0 deleted successfully.'); - mockRequest.done(); - }); - - it('should catch any request errors', async () => { - const errorResponse = { - error: 'VERSION_NOTFOUND', - message: - "The version you specified ({version}) doesn't match any of the existing versions ({versions_list}) in ReadMe.", - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }; - - const mockRequest = getApiNock() - .delete(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(404, errorResponse) - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - await expect(deleteVersion.run({ key, version })).rejects.toStrictEqual(new APIError(errorResponse)); - mockRequest.done(); - }); - }); - - describe('rdme versions:update', () => { - const updateVersion = new UpdateVersionCommand(); - - it('should error if no api key provided', () => { - return expect(updateVersion.run({})).rejects.toStrictEqual( - new Error('No project API key provided. Please use `--key`.') - ); - }); - - it('should update a specific version object', async () => { - promptHandler.createVersionPrompt.mockResolvedValue({ - is_stable: false, - is_beta: false, - is_deprecated: true, - }); - - const mockRequest = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }) - .put(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(201, { version }) - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - await expect(updateVersion.run({ key, version })).resolves.toBe('Version 1.0.0 updated successfully.'); - mockRequest.done(); - }); - - it('should catch any put request errors', async () => { - promptHandler.createVersionPrompt.mockResolvedValue({ - is_stable: false, - is_beta: false, - }); - - const errorResponse = { - error: 'VERSION_CANT_DEMOTE_STABLE', - message: "You can't make a stable version non-stable", - suggestion: '...a suggestion to resolve the issue...', - help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', - }; - - const mockRequest = getApiNock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }) - .put(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(400, errorResponse) - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - await expect(updateVersion.run({ key, version })).rejects.toStrictEqual(new APIError(errorResponse)); - mockRequest.done(); - }); - }); -}); diff --git a/__tests__/cmds/versions/create.test.ts b/__tests__/cmds/versions/create.test.ts new file mode 100644 index 000000000..e3f6acb93 --- /dev/null +++ b/__tests__/cmds/versions/create.test.ts @@ -0,0 +1,64 @@ +import nock from 'nock'; + +import CreateVersionCommand from '../../../src/cmds/versions/create'; +import APIError from '../../../src/lib/apiError'; +import getAPIMock from '../../helpers/get-api-mock'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const promptHandler = require('../../../src/lib/prompts'); + +const key = 'API_KEY'; +const version = '1.0.0'; + +jest.mock('../../../src/lib/prompts'); + +const createVersion = new CreateVersionCommand(); + +describe('rdme versions:create', () => { + beforeAll(() => nock.disableNetConnect()); + + afterEach(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(createVersion.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); + }); + + it('should create a specific version', async () => { + promptHandler.createVersionPrompt.mockResolvedValue({ + is_stable: true, + is_beta: false, + from: '1.0.0', + }); + + const mockRequest = getAPIMock() + .get('/api/v1/version') + .basicAuth({ user: key }) + .reply(200, [{ version }, { version }]) + .post('/api/v1/version') + .basicAuth({ user: key }) + .reply(201, { version }); + + await expect(createVersion.run({ key, version })).resolves.toBe('Version 1.0.0 created successfully.'); + mockRequest.done(); + }); + + it('should catch any post request errors', async () => { + expect.assertions(1); + promptHandler.createVersionPrompt.mockResolvedValue({ + is_stable: false, + is_beta: false, + }); + + const errorResponse = { + error: 'VERSION_EMPTY', + message: 'You need to include an x-readme-version header', + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }; + + const mockRequest = getAPIMock().post('/api/v1/version').basicAuth({ user: key }).reply(400, errorResponse); + + await expect(createVersion.run({ key, version, fork: '0.0.5' })).rejects.toStrictEqual(new APIError(errorResponse)); + mockRequest.done(); + }); +}); diff --git a/__tests__/cmds/versions/delete.test.ts b/__tests__/cmds/versions/delete.test.ts new file mode 100644 index 000000000..6fc17e0dc --- /dev/null +++ b/__tests__/cmds/versions/delete.test.ts @@ -0,0 +1,56 @@ +import nock from 'nock'; + +import DeleteVersionCommand from '../../../src/cmds/versions/delete'; +import APIError from '../../../src/lib/apiError'; +import getAPIMock from '../../helpers/get-api-mock'; + +const key = 'API_KEY'; +const version = '1.0.0'; + +const deleteVersion = new DeleteVersionCommand(); + +describe('rdme versions:delete', () => { + beforeAll(() => nock.disableNetConnect()); + + afterEach(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(deleteVersion.run({})).rejects.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); + }); + + it('should delete a specific version', async () => { + const mockRequest = getAPIMock() + .delete(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { removed: true }) + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect(deleteVersion.run({ key, version })).resolves.toBe('Version 1.0.0 deleted successfully.'); + mockRequest.done(); + }); + + it('should catch any request errors', async () => { + const errorResponse = { + error: 'VERSION_NOTFOUND', + message: + "The version you specified ({version}) doesn't match any of the existing versions ({versions_list}) in ReadMe.", + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }; + + const mockRequest = getAPIMock() + .delete(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(404, errorResponse) + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect(deleteVersion.run({ key, version })).rejects.toStrictEqual(new APIError(errorResponse)); + mockRequest.done(); + }); +}); diff --git a/__tests__/cmds/versions/index.test.ts b/__tests__/cmds/versions/index.test.ts new file mode 100644 index 000000000..c5405568c --- /dev/null +++ b/__tests__/cmds/versions/index.test.ts @@ -0,0 +1,88 @@ +import nock from 'nock'; + +import VersionsCommand from '../../../src/cmds/versions'; +import getAPIMock from '../../helpers/get-api-mock'; + +const key = 'API_KEY'; +const version = '1.0.0'; +const version2 = '2.0.0'; + +const versionPayload = { + createdAt: '2019-06-17T22:39:56.462Z', + is_deprecated: false, + is_hidden: false, + is_beta: false, + is_stable: true, + codename: '', + version, +}; + +const version2Payload = { + createdAt: '2019-06-17T22:39:56.462Z', + is_deprecated: false, + is_hidden: false, + is_beta: false, + is_stable: true, + codename: '', + version: version2, +}; + +const versions = new VersionsCommand(); + +describe('rdme versions', () => { + beforeAll(() => nock.disableNetConnect()); + + afterEach(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(versions.run({})).rejects.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); + }); + + it('should make a request to get a list of existing versions', async () => { + const mockRequest = getAPIMock() + .get('/api/v1/version') + .basicAuth({ user: key }) + .reply(200, [versionPayload, version2Payload]); + + const table = await versions.run({ key }); + expect(table).toContain(version); + expect(table).toContain(version2); + mockRequest.done(); + }); + + it('should make a request to get a list of existing versions and return them in a raw format', async () => { + const mockRequest = getAPIMock() + .get('/api/v1/version') + .basicAuth({ user: key }) + .reply(200, [versionPayload, version2Payload]); + + const raw = await versions.run({ key, raw: true }); + expect(raw).toStrictEqual(JSON.stringify([versionPayload, version2Payload], null, 2)); + mockRequest.done(); + }); + + it('should get a specific version object if version flag provided', async () => { + const mockRequest = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, versionPayload); + + const table = await versions.run({ key, version }); + expect(table).toContain(version); + expect(table).not.toContain(version2); + mockRequest.done(); + }); + + it('should get a specific version object if version flag provided and return it in a raw format', async () => { + const mockRequest = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, versionPayload); + + const raw = await versions.run({ key, version, raw: true }); + expect(raw).toStrictEqual(JSON.stringify(versionPayload, null, 2)); + mockRequest.done(); + }); +}); diff --git a/__tests__/cmds/versions/update.test.ts b/__tests__/cmds/versions/update.test.ts new file mode 100644 index 000000000..91bbfcb3a --- /dev/null +++ b/__tests__/cmds/versions/update.test.ts @@ -0,0 +1,77 @@ +import nock from 'nock'; + +import UpdateVersionCommand from '../../../src/cmds/versions/update'; +import APIError from '../../../src/lib/apiError'; +import getAPIMock from '../../helpers/get-api-mock'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const promptHandler = require('../../../src/lib/prompts'); + +const key = 'API_KEY'; +const version = '1.0.0'; + +jest.mock('../../../src/lib/prompts'); + +const updateVersion = new UpdateVersionCommand(); + +describe('rdme versions:update', () => { + beforeAll(() => nock.disableNetConnect()); + + afterEach(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(updateVersion.run({})).rejects.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); + }); + + it('should update a specific version object', async () => { + promptHandler.createVersionPrompt.mockResolvedValue({ + is_stable: false, + is_beta: false, + is_deprecated: true, + }); + + const mockRequest = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }) + .put(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(201, { version }) + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect(updateVersion.run({ key, version })).resolves.toBe('Version 1.0.0 updated successfully.'); + mockRequest.done(); + }); + + it('should catch any put request errors', async () => { + promptHandler.createVersionPrompt.mockResolvedValue({ + is_stable: false, + is_beta: false, + }); + + const errorResponse = { + error: 'VERSION_CANT_DEMOTE_STABLE', + message: "You can't make a stable version non-stable", + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }; + + const mockRequest = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }) + .put(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(400, errorResponse) + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect(updateVersion.run({ key, version })).rejects.toStrictEqual(new APIError(errorResponse)); + mockRequest.done(); + }); +}); diff --git a/__tests__/cmds/whoami.test.js b/__tests__/cmds/whoami.test.ts similarity index 78% rename from __tests__/cmds/whoami.test.js rename to __tests__/cmds/whoami.test.ts index 87b663d88..2cd692e66 100644 --- a/__tests__/cmds/whoami.test.js +++ b/__tests__/cmds/whoami.test.ts @@ -1,6 +1,7 @@ -const config = require('config'); -const configStore = require('../../src/lib/configstore'); -const Command = require('../../src/cmds/whoami'); +import config from 'config'; + +import Command from '../../src/cmds/whoami'; +import configStore from '../../src/lib/configstore'; const cmd = new Command(); diff --git a/__tests__/get-api-nock.js b/__tests__/get-api-nock.js deleted file mode 100644 index c3f4bbf1a..000000000 --- a/__tests__/get-api-nock.js +++ /dev/null @@ -1,12 +0,0 @@ -const config = require('config'); -const nock = require('nock'); -const { getUserAgent } = require('../src/lib/fetch'); - -module.exports = function (reqHeaders = {}) { - return nock(config.get('host'), { - reqheaders: { - 'User-Agent': getUserAgent(), - ...reqHeaders, - }, - }); -}; diff --git a/__tests__/helpers/get-api-mock.ts b/__tests__/helpers/get-api-mock.ts new file mode 100644 index 000000000..40bf6b6c1 --- /dev/null +++ b/__tests__/helpers/get-api-mock.ts @@ -0,0 +1,19 @@ +import config from 'config'; +import nock from 'nock'; + +import { getUserAgent } from '../../src/lib/fetch'; + +export default function getAPIMock(reqHeaders = {}) { + return nock(config.get('host'), { + reqheaders: { + 'User-Agent': getUserAgent(), + ...reqHeaders, + }, + }); +} + +export function getAPIMockWithVersionHeader(v) { + return getAPIMock({ + 'x-readme-version': v, + }); +} diff --git a/__tests__/helpers/hash-file-contents.ts b/__tests__/helpers/hash-file-contents.ts new file mode 100644 index 000000000..dd160715d --- /dev/null +++ b/__tests__/helpers/hash-file-contents.ts @@ -0,0 +1,5 @@ +import crypto from 'crypto'; + +export default function hashFileContents(contents) { + return crypto.createHash('sha1').update(contents).digest('hex'); +} diff --git a/__tests__/lib/commands.test.js b/__tests__/lib/commands.test.js deleted file mode 100644 index f6aeb641a..000000000 --- a/__tests__/lib/commands.test.js +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable jest/no-conditional-expect */ -const commands = require('../../src/lib/commands'); - -describe('utils', () => { - describe('#list', () => { - it('should have commands returned', () => { - expect(commands.list()).not.toHaveLength(0); - }); - - describe('commands', () => { - it('should be configured properly', () => { - commands.list().forEach(c => { - const cmd = c.command; - - expect(typeof cmd.command === 'string' && cmd.command.length !== 0).toBe(true); - expect(typeof cmd.usage === 'string' && cmd.usage.length !== 0).toBe(true); - expect(typeof cmd.description === 'string' && cmd.usage.description !== 0).toBe(true); - expect(typeof cmd.cmdCategory === 'string' && cmd.usage.cmdCategory !== 0).toBe(true); - expect(typeof cmd.position === 'number' && cmd.usage.position !== 0).toBe(true); - expect(Array.isArray(cmd.args)).toBe(true); - expect(typeof cmd.run === 'function').toBe(true); - - if (cmd.args.length > 0) { - cmd.args.forEach(arg => { - expect(typeof arg.name === 'string' && arg.name.length !== 0).toBe(true); - expect(typeof arg.type !== 'undefined').toBe(true); - }); - } - - expect(cmd.usage.indexOf(cmd.command) !== -1).toBe(true); - }); - }); - - it('should abide by our cli standards', () => { - commands.list().forEach(c => { - const cmd = c.command; - - // Command descriptions should end with punctuation. - expect(cmd.description[cmd.description.length - 1]).toMatch(/(.|])/); - - cmd.args.forEach(arg => { - if (arg.name === 'key') { - expect(arg.description).toBe('Project API key'); - } else if (arg.name === 'version') { - // If `version` is a hidden argument on the command, it won't have a description so - // we don't need to bother with this test case. - if (Array.isArray(cmd.hiddenArgs) && cmd.hiddenArgs.indexOf('version') !== -1) { - return; - } - - expect(arg.description).toBe( - cmd.command !== 'versions' ? 'Project version' : 'A specific project version to view' - ); - } - }); - }); - }); - }); - }); -}); diff --git a/__tests__/lib/commands.test.ts b/__tests__/lib/commands.test.ts new file mode 100644 index 000000000..c6b35f405 --- /dev/null +++ b/__tests__/lib/commands.test.ts @@ -0,0 +1,72 @@ +/* eslint-disable jest/no-if */ +/// <reference types="jest-extended" /> +/* eslint-disable jest/no-conditional-expect */ +import type Command from '../../src/lib/baseCommand'; + +import * as commands from '../../src/lib/commands'; + +describe('utils', () => { + describe('#list', () => { + it('should have commands returned', () => { + expect(commands.list()).not.toHaveLength(0); + }); + + describe('commands', () => { + it('should be configured properly', () => { + expect.hasAssertions(); + + commands.list().forEach(c => { + const cmd = c.command; + + expect(cmd.command).not.toBeEmpty(); + expect(cmd.usage).not.toBeEmpty(); + expect(cmd.usage).toStartWith(cmd.command); + expect(cmd.description).not.toBeEmpty(); + expect(cmd.cmdCategory).not.toBeEmpty(); + expect(cmd.position).toBeNumber(); + expect(cmd.args).toBeArray(); + expect(cmd.run).toBeFunction(); + + if (cmd.args.length > 0) { + cmd.args.forEach(arg => { + expect(arg.name).not.toBeEmpty(); + expect(arg.type).not.toBeEmpty(); + }); + } + }); + }); + + describe.only('cli standards', () => { + expect.hasAssertions(); + + describe.each<[string, Command]>(commands.list().map(cmd => [cmd.command.command, cmd.command]))( + '%s', + (_, command) => { + it('should have a description that ends with punctuation', () => { + const description = command.description.replace('[inactive]', '').replace('[deprecated]', '').trim(); + expect(description).toEndWith('.'); + }); + + it('should have standardized argument constructs', () => { + command.args.forEach(arg => { + if (arg.name === 'key') { + expect(arg.description).toBe('Project API key'); + } else if (arg.name === 'version') { + // If `version` is a hidden argument on the command, it won't have a description + // so we don't need to bother with this test case. + if (Array.isArray(command.hiddenArgs) && command.hiddenArgs.indexOf('version') !== -1) { + return; + } + + expect(arg.description).toBe( + command.command !== 'versions' ? 'Project version' : 'A specific project version to view' + ); + } + }); + }); + } + ); + }); + }); + }); +}); diff --git a/__tests__/lib/prompts.test.js b/__tests__/lib/prompts.test.ts similarity index 87% rename from __tests__/lib/prompts.test.js rename to __tests__/lib/prompts.test.ts index 45cc46083..0a0fe50d2 100644 --- a/__tests__/lib/prompts.test.js +++ b/__tests__/lib/prompts.test.ts @@ -1,5 +1,6 @@ -const Enquirer = require('enquirer'); -const promptHandler = require('../../src/lib/prompts'); +import Enquirer from 'enquirer'; + +import * as promptHandler from '../../src/lib/prompts'; const versionlist = [ { @@ -81,7 +82,20 @@ describe('prompt test bed', () => { await prompt.keypress(null, { name: 'down' }); await prompt.submit(); }); - const answer = await enquirer.prompt(promptHandler.createOasPrompt([{}], null, 1, null)); + + const answer = await enquirer.prompt( + promptHandler.createOasPrompt( + [ + { + _id: '1234', + title: 'buster', + }, + ], + {}, + 1, + null + ) + ); expect(answer.option).toBe('create'); }); @@ -93,16 +107,21 @@ describe('prompt test bed', () => { await prompt.keypress(null, { name: 'up' }); await prompt.submit(); }); + enquirer.prompt = jest.fn(); enquirer.prompt.mockReturnValue('spec1'); + const parsedDocs = { next: { - page: null, + page: 2, + url: '', }, prev: { - page: null, + page: 1, + url: '', }, }; + const answer = await enquirer.prompt(promptHandler.createOasPrompt(specList, parsedDocs, 1, getSpecs)); expect(answer).toBe('spec1'); @@ -123,7 +142,7 @@ describe('prompt test bed', () => { await prompt.submit(); } }); - const answer = await enquirer.prompt(promptHandler.createVersionPrompt(versionlist, opts, false)); + const answer = await enquirer.prompt(promptHandler.createVersionPrompt(versionlist, opts)); expect(answer.is_hidden).toBe(false); expect(answer.from).toBe('1'); }); @@ -148,7 +167,9 @@ describe('prompt test bed', () => { await prompt.submit(); await prompt.submit(); }); - const answer = await enquirer.prompt(promptHandler.createVersionPrompt(versionlist, opts, true)); + const answer = await enquirer.prompt( + promptHandler.createVersionPrompt(versionlist, opts, { is_stable: '1.2.1' }) + ); expect(answer.is_hidden).toBe(false); expect(answer.from).toBe(''); }); diff --git a/jest.config.js b/jest.config.js index 76c1c2876..36f8076f0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,6 +17,7 @@ module.exports = { preset: 'ts-jest/presets/js-with-ts', roots: ['<rootDir>'], setupFiles: ['./__tests__/set-node-env'], + setupFilesAfterEnv: ['jest-extended/all'], testPathIgnorePatterns: ['dist/', './__tests__/get-api-nock', './__tests__/set-node-env'], testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js?|ts?)$', transform: {}, diff --git a/package-lock.json b/package-lock.json index c1d4cae93..31e5f4609 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "alex": "^10.0.0", "eslint": "^8.21.0", "jest": "^28.1.1", + "jest-extended": "^3.0.2", "nock": "^13.2.7", "prettier": "^2.7.1", "ts-jest": "^28.0.7", @@ -6902,6 +6903,22 @@ "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } }, + "node_modules/jest-extended": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-3.0.2.tgz", + "integrity": "sha512-LnVZvwWLRV9AL8J7f4frKu0KHuTrbIFK15IqrvSwbFCYxalkuC5l7HfcofsksePrvlEJ2WAcfYNnu1+bEGvInA==", + "dev": true, + "dependencies": { + "jest-diff": "^28.0.0", + "jest-get-type": "^28.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "jest": ">=27.2.5" + } + }, "node_modules/jest-get-type": { "version": "28.0.2", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-28.0.2.tgz", @@ -17828,6 +17845,16 @@ "jest-util": "^28.1.3" } }, + "jest-extended": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-3.0.2.tgz", + "integrity": "sha512-LnVZvwWLRV9AL8J7f4frKu0KHuTrbIFK15IqrvSwbFCYxalkuC5l7HfcofsksePrvlEJ2WAcfYNnu1+bEGvInA==", + "dev": true, + "requires": { + "jest-diff": "^28.0.0", + "jest-get-type": "^28.0.0" + } + }, "jest-get-type": { "version": "28.0.2", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-28.0.2.tgz", diff --git a/package.json b/package.json index 18409103c..9f325dc57 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "alex": "^10.0.0", "eslint": "^8.21.0", "jest": "^28.1.1", + "jest-extended": "^3.0.2", "nock": "^13.2.7", "prettier": "^2.7.1", "ts-jest": "^28.0.7", diff --git a/src/cmds/categories/create.ts b/src/cmds/categories/create.ts index 0c600dd83..a1f4f3699 100644 --- a/src/cmds/categories/create.ts +++ b/src/cmds/categories/create.ts @@ -15,9 +15,9 @@ interface Category { } export type Options = { - categoryType: 'guide' | 'reference'; - title: string; - preventDuplicates: boolean; + categoryType?: 'guide' | 'reference'; + title?: string; + preventDuplicates?: boolean; }; export default class CategoriesCreateCommand extends Command { @@ -26,7 +26,7 @@ export default class CategoriesCreateCommand extends Command { this.command = 'categories:create'; this.usage = 'categories:create <title> [options]'; - this.description = 'Create a category with the specified title and guide in your ReadMe project'; + this.description = 'Create a category with the specified title and guide in your ReadMe project.'; this.cmdCategory = CommandCategories.CATEGORIES; this.position = 2; @@ -62,10 +62,14 @@ export default class CategoriesCreateCommand extends Command { } async run(opts: CommandOptions<Options>) { - super.run(opts, true); + super.run(opts); const { categoryType, title, key, version, preventDuplicates } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + if (!title) { return Promise.reject(new Error(`No title provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } diff --git a/src/cmds/categories/index.ts b/src/cmds/categories/index.ts index 3a8176e7a..dface9d01 100644 --- a/src/cmds/categories/index.ts +++ b/src/cmds/categories/index.ts @@ -11,7 +11,7 @@ export default class CategoriesCommand extends Command { this.command = 'categories'; this.usage = 'categories [options]'; - this.description = 'Get all categories in your ReadMe project'; + this.description = 'Get all categories in your ReadMe project.'; this.cmdCategory = CommandCategories.CATEGORIES; this.position = 1; @@ -30,10 +30,14 @@ export default class CategoriesCommand extends Command { } async run(opts: CommandOptions<{}>) { - super.run(opts, true); + super.run(opts); const { key, version } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + const selectedVersion = await getProjectVersion(version, key, true); debug(`selectedVersion: ${selectedVersion}`); diff --git a/src/cmds/changelogs/index.ts b/src/cmds/changelogs/index.ts index bb963e51c..20c221f16 100644 --- a/src/cmds/changelogs/index.ts +++ b/src/cmds/changelogs/index.ts @@ -8,8 +8,8 @@ import { debug } from '../../lib/logger'; import pushDoc, { readdirRecursive } from '../../lib/pushDoc'; export type Options = { - dryRun: boolean; - folder: string; + dryRun?: boolean; + folder?: string; }; export default class ChangelogsCommand extends Command { @@ -43,10 +43,14 @@ export default class ChangelogsCommand extends Command { } async run(opts: CommandOptions<Options>) { - super.run(opts, true); + super.run(opts); const { dryRun, folder, key } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + if (!folder) { return Promise.reject(new Error(`No folder provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } diff --git a/src/cmds/changelogs/single.ts b/src/cmds/changelogs/single.ts index b6a27156e..74a546142 100644 --- a/src/cmds/changelogs/single.ts +++ b/src/cmds/changelogs/single.ts @@ -7,8 +7,8 @@ import Command, { CommandCategories } from '../../lib/baseCommand'; import pushDoc from '../../lib/pushDoc'; export type Options = { - dryRun: boolean; - filePath: string; + dryRun?: boolean; + filePath?: string; }; export default class SingleChangelogCommand extends Command { @@ -42,10 +42,14 @@ export default class SingleChangelogCommand extends Command { } async run(opts: CommandOptions<Options>) { - super.run(opts, true); + super.run(opts); const { dryRun, filePath, key } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + if (!filePath) { return Promise.reject(new Error(`No file path provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } diff --git a/src/cmds/custompages/index.ts b/src/cmds/custompages/index.ts index ff803d40e..2f2293739 100644 --- a/src/cmds/custompages/index.ts +++ b/src/cmds/custompages/index.ts @@ -8,8 +8,8 @@ import { debug } from '../../lib/logger'; import pushDoc, { readdirRecursive } from '../../lib/pushDoc'; export type Options = { - dryRun: boolean; - folder: string; + dryRun?: boolean; + folder?: string; }; export default class CustomPagesCommand extends Command { diff --git a/src/cmds/custompages/single.ts b/src/cmds/custompages/single.ts index 1aaa7bb94..81843e844 100644 --- a/src/cmds/custompages/single.ts +++ b/src/cmds/custompages/single.ts @@ -7,8 +7,8 @@ import Command, { CommandCategories } from '../../lib/baseCommand'; import pushDoc from '../../lib/pushDoc'; export type Options = { - dryRun: boolean; - filePath: string; + dryRun?: boolean; + filePath?: string; }; export default class SingleCustomPageCommand extends Command { @@ -41,10 +41,14 @@ export default class SingleCustomPageCommand extends Command { } async run(opts: CommandOptions<Options>) { - super.run(opts, true); + super.run(opts); const { dryRun, filePath, key } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + if (!filePath) { return Promise.reject(new Error(`No file path provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } diff --git a/src/cmds/docs/edit.ts b/src/cmds/docs/edit.ts index 8630e4430..79cd022ee 100644 --- a/src/cmds/docs/edit.ts +++ b/src/cmds/docs/edit.ts @@ -17,8 +17,8 @@ const readFile = promisify(fs.readFile); const unlink = promisify(fs.unlink); export type Options = { - mockEditor?: boolean; - slug: string; + mockEditor?: (filename: string, cb: () => void) => void; + slug?: string; }; export default class EditDocsCommand extends Command { @@ -52,10 +52,14 @@ export default class EditDocsCommand extends Command { } async run(opts: CommandOptions<Options>) { - super.run(opts, true); + super.run(opts); const { slug, key, version } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + if (!slug) { return Promise.reject(new Error(`No slug provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } @@ -113,7 +117,7 @@ export default class EditDocsCommand extends Command { // Normally we should resolve with a value that is logged to the console, // but since we need to wait for the temporary file to be removed, // it's okay to resolve the promise with no value. - return resolve(true); + return resolve(undefined); }); }); }); diff --git a/src/cmds/docs/index.ts b/src/cmds/docs/index.ts index 10c02277d..200e435a7 100644 --- a/src/cmds/docs/index.ts +++ b/src/cmds/docs/index.ts @@ -9,8 +9,8 @@ import pushDoc, { readdirRecursive } from '../../lib/pushDoc'; import { getProjectVersion } from '../../lib/versionSelect'; export type Options = { - dryRun: boolean; - folder: string; + dryRun?: boolean; + folder?: string; }; export default class DocsCommand extends Command { @@ -49,10 +49,14 @@ export default class DocsCommand extends Command { } async run(opts: CommandOptions<Options>) { - super.run(opts, true); + super.run(opts); const { dryRun, folder, key, version } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + if (!folder) { return Promise.reject(new Error(`No folder provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } diff --git a/src/cmds/docs/single.ts b/src/cmds/docs/single.ts index 8b71109bf..964b23011 100644 --- a/src/cmds/docs/single.ts +++ b/src/cmds/docs/single.ts @@ -9,8 +9,8 @@ import pushDoc from '../../lib/pushDoc'; import { getProjectVersion } from '../../lib/versionSelect'; export type Options = { - dryRun: boolean; - filePath: string; + dryRun?: boolean; + filePath?: string; }; export default class SingleDocCommand extends Command { @@ -49,10 +49,14 @@ export default class SingleDocCommand extends Command { } async run(opts: CommandOptions<Options>) { - super.run(opts, true); + super.run(opts); const { dryRun, filePath, key, version } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + if (!filePath) { return Promise.reject(new Error(`No file path provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } diff --git a/src/cmds/login.ts b/src/cmds/login.ts index 5a97c16b8..3ef15f13d 100644 --- a/src/cmds/login.ts +++ b/src/cmds/login.ts @@ -16,11 +16,11 @@ const read = promisify(readPkg); const testing = process.env.NODE_ENV === 'testing'; export type Options = { - '2fa': string; - email: string; - password: string; - project: string; - token: string; + '2fa'?: string; + email?: string; + password?: string; + project?: string; + token?: string; }; export default class LoginCommand extends Command { diff --git a/src/cmds/open.ts b/src/cmds/open.ts index 3fbbe03b5..b17cf5ed4 100644 --- a/src/cmds/open.ts +++ b/src/cmds/open.ts @@ -9,7 +9,7 @@ import configStore from '../lib/configstore'; import { debug } from '../lib/logger'; export type Options = { - mockOpen?: typeof open; + mockOpen?: any; // @fixme this deserves a better type }; export default class OpenCommand extends Command { diff --git a/src/cmds/openapi.ts b/src/cmds/openapi.ts index 6e316eeef..9eeaf4ece 100644 --- a/src/cmds/openapi.ts +++ b/src/cmds/openapi.ts @@ -12,15 +12,15 @@ import Command, { CommandCategories } from '../lib/baseCommand'; import fetch, { cleanHeaders, handleRes } from '../lib/fetch'; import { debug, warn, oraOptions } from '../lib/logger'; import prepareOas from '../lib/prepareOas'; -import * as promptOpts from '../lib/prompts'; +import * as promptHandler from '../lib/prompts'; import streamSpecToRegistry from '../lib/streamSpecToRegistry'; import { getProjectVersion } from '../lib/versionSelect'; export type Options = { - id: string; - spec: string; - version: string; - workingDirectory: string; + id?: string; + spec?: string; + version?: string; + workingDirectory?: string; }; export default class OpenAPICommand extends Command { @@ -65,9 +65,14 @@ export default class OpenAPICommand extends Command { } async run(opts: CommandOptions<Options>) { - super.run(opts, true); + super.run(opts); + const { key, id, spec, version, workingDirectory } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + let selectedVersion: string; let isUpdate: boolean; const spinner = ora({ ...oraOptions() }); @@ -207,8 +212,7 @@ export default class OpenAPICommand extends Command { if (!apiSettingsBody.length) return createSpec(); const { option }: { option: 'create' | 'update' } = await prompt( - // @ts-expect-error `getSpecs` is getting double type in as `Promise<Promise<Response>>` for some reason. - promptOpts.createOasPrompt(apiSettingsBody, parsedDocs, totalPages, getSpecs) + promptHandler.createOasPrompt(apiSettingsBody, parsedDocs, totalPages, getSpecs) ); debug(`selection result: ${option}`); diff --git a/src/cmds/validate.ts b/src/cmds/validate.ts index 8d9501c7f..65544a249 100644 --- a/src/cmds/validate.ts +++ b/src/cmds/validate.ts @@ -6,8 +6,8 @@ import Command, { CommandCategories } from '../lib/baseCommand'; import prepareOas from '../lib/prepareOas'; export type Options = { - spec: string; - workingDirectory: string; + spec?: string; + workingDirectory?: string; }; export default class ValidateCommand extends Command { diff --git a/src/cmds/versions/create.ts b/src/cmds/versions/create.ts index b93bbb83e..de5f162d0 100644 --- a/src/cmds/versions/create.ts +++ b/src/cmds/versions/create.ts @@ -6,14 +6,14 @@ import semver from 'semver'; import Command, { CommandCategories } from '../../lib/baseCommand'; import fetch, { cleanHeaders, handleRes } from '../../lib/fetch'; -import * as promptOpts from '../../lib/prompts'; +import * as promptHandler from '../../lib/prompts'; export type Options = { - fork: string; - codename: string; - main: string; - beta: string; - isPublic: string; + beta?: string | boolean; + codename?: string; + fork?: string; + isPublic?: string | boolean; + main?: string | boolean; }; export default class CreateVersionCommand extends Command { @@ -67,11 +67,15 @@ export default class CreateVersionCommand extends Command { } async run(opts: CommandOptions<Options>) { - super.run(opts, true); + super.run(opts); let versionList; const { key, version, codename, fork, main, beta, isPublic } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + if (!version || !semver.valid(semver.coerce(version))) { return Promise.reject( new Error(`Please specify a semantic version. See \`${config.get('cli')} help ${this.command}\` for help.`) @@ -85,7 +89,7 @@ export default class CreateVersionCommand extends Command { }).then(res => handleRes(res)); } - const versionPrompt = promptOpts.createVersionPrompt(versionList || [{}], { + const versionPrompt = promptHandler.createVersionPrompt(versionList || [{}], { newVersion: version, ...opts, }); diff --git a/src/cmds/versions/delete.ts b/src/cmds/versions/delete.ts index 77416cc7b..a18862a7e 100644 --- a/src/cmds/versions/delete.ts +++ b/src/cmds/versions/delete.ts @@ -33,10 +33,14 @@ export default class DeleteVersionCommand extends Command { } async run(opts: CommandOptions<{}>) { - super.run(opts, true); + super.run(opts); const { key, version } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + const selectedVersion = await getProjectVersion(version, key, false).catch(e => { return Promise.reject(e); }); diff --git a/src/cmds/versions/index.ts b/src/cmds/versions/index.ts index 335de5388..18ac99912 100644 --- a/src/cmds/versions/index.ts +++ b/src/cmds/versions/index.ts @@ -21,7 +21,7 @@ interface Version { } export type Options = { - raw: boolean; + raw?: boolean; }; export default class VersionsCommand extends Command { @@ -106,9 +106,14 @@ export default class VersionsCommand extends Command { } async run(opts: CommandOptions<Options>) { - super.run(opts, true); + super.run(opts); + const { key, version, raw } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + const uri = version ? `${config.get('host')}/api/v1/version/${version}` : `${config.get('host')}/api/v1/version`; return fetch(uri, { diff --git a/src/cmds/versions/update.ts b/src/cmds/versions/update.ts index 78de4a46a..969b27499 100644 --- a/src/cmds/versions/update.ts +++ b/src/cmds/versions/update.ts @@ -6,16 +6,16 @@ import { prompt } from 'enquirer'; import Command, { CommandCategories } from '../../lib/baseCommand'; import fetch, { cleanHeaders, handleRes } from '../../lib/fetch'; import { debug } from '../../lib/logger'; -import * as promptOpts from '../../lib/prompts'; +import * as promptHandler from '../../lib/prompts'; import { getProjectVersion } from '../../lib/versionSelect'; export type Options = { - beta: string; - codename: string; - deprecated: string; - isPublic: string; - main: string; - newVersion: string; + beta?: string; + codename?: string; + deprecated?: string; + isPublic?: string; + main?: string; + newVersion?: string; }; export default class UpdateVersionCommand extends Command { @@ -63,10 +63,14 @@ export default class UpdateVersionCommand extends Command { } async run(opts: CommandOptions<Options>) { - super.run(opts, true); + super.run(opts); const { key, version, codename, newVersion, main, beta, isPublic, deprecated } = opts; + if (!opts.key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + const selectedVersion = await getProjectVersion(version, key, false).catch(e => { return Promise.reject(e); }); @@ -86,7 +90,7 @@ export default class UpdateVersionCommand extends Command { newVersion: string; } = await prompt( // @ts-expect-error Seems like our version prompts aren't what Enquirer actually expects. - promptOpts.createVersionPrompt([{}], opts, foundVersion) + promptHandler.createVersionPrompt([{}], opts, foundVersion) ); return fetch(`${config.get('host')}/api/v1/version/${selectedVersion}`, { diff --git a/src/lib/apiError.ts b/src/lib/apiError.ts index 106b870d6..6809cb0f6 100644 --- a/src/lib/apiError.ts +++ b/src/lib/apiError.ts @@ -1,10 +1,10 @@ type APIErrorResponse = { error: string; message: string; - suggestion: string; - docs: string; - help: string; - poem: string[]; + suggestion?: string; + docs?: string; + help?: string; + poem?: string[]; }; export default class APIError extends Error { diff --git a/src/lib/baseCommand.ts b/src/lib/baseCommand.ts index 88d5e088b..a3049ee34 100644 --- a/src/lib/baseCommand.ts +++ b/src/lib/baseCommand.ts @@ -1,8 +1,8 @@ import { debug } from './logger'; export type CommandOptions<T> = T & { - key: string; - version: string; + key?: string; + version?: string; }; export enum CommandCategories { @@ -37,15 +37,8 @@ export default class Command { defaultOption?: boolean; }[]; - // eslint-disable-next-line consistent-return - async run(opts: CommandOptions<{}>, requiresAuth?: boolean): Promise<any> { + async run(opts: CommandOptions<{}>): Promise<any> { debug(`command: ${this.command}`); debug(`opts: ${JSON.stringify(opts)}`); - - if (requiresAuth) { - if (!opts.key) { - return Promise.reject(new Error('No project API key provided. Please use `--key`.')); - } - } } } diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 19e9d887b..6c190436b 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -63,12 +63,12 @@ export function list() { return [file]; }) .reduce((a, b) => a.concat(b), []) - .filter(file => file.endsWith('.js')) + .filter(file => file.endsWith('.ts')) .map(file => path.join(cmdDir, file)); files.forEach(file => { // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-dynamic-require - const CommandClass = require(file); + const { default: CommandClass } = require(file); commands.push({ file, diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index fe9ccecd4..255860e86 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -126,7 +126,8 @@ export function createOasPrompt( specList: SpecList, parsedDocs: ParsedDocs, totalPages: number, - getSpecs: (url: string) => Promise<ReturnType<typeof fetch>> + // @fixme There's a lot of funk with this type throughout the codebase. + getSpecs: any // (url: string) => Promise<ReturnType<typeof fetch>> ) { return [ { @@ -158,11 +159,11 @@ export function createOasPrompt( export function createVersionPrompt( versionList: VersionList, opts: { - beta?: string; + beta?: string | boolean; deprecated?: string; fork?: string; - isPublic?: string; - main?: string; + isPublic?: string | boolean; + main?: string | boolean; newVersion?: string; }, isUpdate?: { diff --git a/src/lib/versionSelect.ts b/src/lib/versionSelect.ts index 021ad90db..a0ce4877c 100644 --- a/src/lib/versionSelect.ts +++ b/src/lib/versionSelect.ts @@ -3,7 +3,7 @@ import { prompt } from 'enquirer'; import APIError from './apiError'; import fetch, { cleanHeaders, handleRes } from './fetch'; -import * as promptOpts from './prompts'; +import * as promptHandler from './prompts'; export async function getProjectVersion(versionFlag: string, key: string, allowNewVersion: boolean): Promise<string> { try { @@ -27,7 +27,7 @@ export async function getProjectVersion(versionFlag: string, key: string, allowN versionSelection, newVersion, }: { option: 'update' | 'create'; versionSelection: string; newVersion: string } = await prompt( - promptOpts.generatePrompts(versionList) + promptHandler.generatePrompts(versionList) ); if (option === 'update') return versionSelection; @@ -46,7 +46,7 @@ export async function getProjectVersion(versionFlag: string, key: string, allowN } const { versionSelection }: { versionSelection: string } = await prompt( - promptOpts.generatePrompts(versionList, true) + promptHandler.generatePrompts(versionList, true) ); return versionSelection; From 7cae8739d77f25004f15a5cc40b2b806e57961e4 Mon Sep 17 00:00:00 2001 From: Jon Ursenbach <jon@ursenba.ch> Date: Mon, 8 Aug 2022 23:34:12 -0700 Subject: [PATCH 03/21] fix: updating the ci workflow to build the dist --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b982b9a0..ac7f479f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: - name: Install deps run: npm ci + - name: Build dist + run: npm run build + - name: Run tests run: npm test @@ -42,6 +45,9 @@ jobs: - name: Install deps run: npm ci + - name: Build dist + run: npm run build + # rdme doesn't work on Node 12 but we just want to run this single test to make sure that # our "we don't support node 12" error is shown. - name: Run tests From 352b78542ff2ab91d1269492c45f45b864e83c64 Mon Sep 17 00:00:00 2001 From: Jon Ursenbach <jon@ursenba.ch> Date: Mon, 8 Aug 2022 23:54:52 -0700 Subject: [PATCH 04/21] fix: various problems --- .eslintrc | 8 ++++++++ __tests__/cmds/login.test.ts | 10 +++++----- __tests__/{index.test.js => index.test.ts} | 10 ++++++---- __tests__/lib/commands.test.ts | 2 +- __tests__/lib/fetch.test.ts | 11 ++++++----- __tests__/lib/getNodeVersion.test.ts | 5 +++-- bin/rdme | 2 +- bin/set-version-output | 3 +-- package-lock.json | 13 +++++++++++++ package.json | 3 ++- src/.sink.d.ts | 1 + src/cli.ts | 4 ++-- src/lib/commands.ts | 1 + src/lib/configstore.ts | 1 + src/lib/fetch.ts | 8 +++++--- src/lib/getCategories.ts | 1 + src/lib/help.ts | 4 +++- src/lib/isSupportedNodeVersion.ts | 1 + src/lib/logger.ts | 5 +++-- src/lib/prepareOas.ts | 3 ++- src/lib/pushDoc.ts | 7 ++++--- src/lib/streamSpecToRegistry.ts | 8 +++++--- src/typings.d.ts | 1 - tsconfig.json | 9 ++++++--- 24 files changed, 81 insertions(+), 40 deletions(-) rename __tests__/{index.test.js => index.test.ts} (95%) create mode 100644 src/.sink.d.ts diff --git a/.eslintrc b/.eslintrc index 6f43a3ddb..76a4d5b18 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,6 +7,14 @@ "parserOptions": { "ecmaVersion": 2020 }, + "overrides": [ + { + "files": ["bin/set-version-output", "config/*.js"], + "rules": { + "@typescript-eslint/no-var-requires": "off" + } + } + ], "rules": { "@typescript-eslint/ban-types": ["error", { "types": { diff --git a/__tests__/cmds/login.test.ts b/__tests__/cmds/login.test.ts index 43b6e6396..3a9715711 100644 --- a/__tests__/cmds/login.test.ts +++ b/__tests__/cmds/login.test.ts @@ -3,7 +3,7 @@ import nock from 'nock'; import Command from '../../src/cmds/login'; import APIError from '../../src/lib/apiError'; import configStore from '../../src/lib/configstore'; -import getApiNock from '../helpers/get-api-mock'; +import getAPIMock from '../helpers/get-api-mock'; const cmd = new Command(); @@ -33,7 +33,7 @@ describe('rdme login', () => { it('should post to /login on the API', async () => { const apiKey = 'abcdefg'; - const mock = getApiNock().post('/api/v1/login', { email, password, project }).reply(200, { apiKey }); + const mock = getAPIMock().post('/api/v1/login', { email, password, project }).reply(200, { apiKey }); await expect(cmd.run({ email, password, project })).resolves.toMatchSnapshot(); @@ -53,7 +53,7 @@ describe('rdme login', () => { help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }; - const mock = getApiNock().post('/api/v1/login', { email, password, project }).reply(401, errorResponse); + const mock = getAPIMock().post('/api/v1/login', { email, password, project }).reply(401, errorResponse); await expect(cmd.run({ email, password, project })).rejects.toStrictEqual(new APIError(errorResponse)); mock.done(); @@ -67,7 +67,7 @@ describe('rdme login', () => { help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', }; - const mock = getApiNock().post('/api/v1/login', { email, password, project }).reply(401, errorResponse); + const mock = getAPIMock().post('/api/v1/login', { email, password, project }).reply(401, errorResponse); await expect(cmd.run({ email, password, project })).rejects.toStrictEqual(new APIError(errorResponse)); mock.done(); @@ -76,7 +76,7 @@ describe('rdme login', () => { it('should send 2fa token if provided', async () => { const token = '123456'; - const mock = getApiNock().post('/api/v1/login', { email, password, project, token }).reply(200, { apiKey: '123' }); + const mock = getAPIMock().post('/api/v1/login', { email, password, project, token }).reply(200, { apiKey: '123' }); await expect(cmd.run({ email, password, project, token })).resolves.toMatchSnapshot(); mock.done(); diff --git a/__tests__/index.test.js b/__tests__/index.test.ts similarity index 95% rename from __tests__/index.test.js rename to __tests__/index.test.ts index edd476e63..0d5baaea3 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.ts @@ -1,10 +1,12 @@ -const nock = require('nock'); -const cli = require('../src'); -const { version } = require('../package.json'); -const conf = require('../src/lib/configstore'); +import nock from 'nock'; + +import { version } from '../package.json'; +import cli from '../src'; +import conf from '../src/lib/configstore'; describe('cli', () => { it('command not found', async () => { + // @ts-expect-error thius is fine await expect(cli('notARealCommand')).rejects.toThrow('Command not found'); }); diff --git a/__tests__/lib/commands.test.ts b/__tests__/lib/commands.test.ts index c6b35f405..0acbde496 100644 --- a/__tests__/lib/commands.test.ts +++ b/__tests__/lib/commands.test.ts @@ -36,7 +36,7 @@ describe('utils', () => { }); }); - describe.only('cli standards', () => { + describe('cli standards', () => { expect.hasAssertions(); describe.each<[string, Command]>(commands.list().map(cmd => [cmd.command.command, cmd.command]))( diff --git a/__tests__/lib/fetch.test.ts b/__tests__/lib/fetch.test.ts index 0cfe12e7e..59a399e1d 100644 --- a/__tests__/lib/fetch.test.ts +++ b/__tests__/lib/fetch.test.ts @@ -1,7 +1,8 @@ import config from 'config'; -import fetch, { cleanHeaders, handleRes } from '../../src/lib/fetch'; -import getApiNock from '../get-api-nock'; + import pkg from '../../package.json'; +import fetch, { cleanHeaders, handleRes } from '../../src/lib/fetch'; +import getAPIMock from '../helpers/get-api-mock'; describe('#fetch()', () => { describe('GitHub Actions environment', () => { @@ -30,7 +31,7 @@ describe('#fetch()', () => { it('should have correct headers for requests in GitHub Action env', async () => { const key = 'API_KEY'; - const mock = getApiNock() + const mock = getAPIMock() .get('/api/v1') .basicAuth({ user: key }) .reply(200, function () { @@ -56,7 +57,7 @@ describe('#fetch()', () => { it('should wrap all requests with standard user-agent and source headers', async () => { const key = 'API_KEY'; - const mock = getApiNock() + const mock = getAPIMock() .get('/api/v1') .basicAuth({ user: key }) .reply(200, function () { @@ -79,7 +80,7 @@ describe('#fetch()', () => { }); it('should support if we dont supply any other options with the request', async () => { - const mock = getApiNock() + const mock = getAPIMock() .get('/api/v1/doesnt-need-auth') .reply(200, function () { return this.req.headers; diff --git a/__tests__/lib/getNodeVersion.test.ts b/__tests__/lib/getNodeVersion.test.ts index 1a1eaf349..d5476515b 100644 --- a/__tests__/lib/getNodeVersion.test.ts +++ b/__tests__/lib/getNodeVersion.test.ts @@ -1,7 +1,8 @@ -import getNodeVersion from '../../src/lib/getNodeVersion'; -import pkg from '../../package.json'; import semver from 'semver'; +import pkg from '../../package.json'; +import getNodeVersion from '../../src/lib/getNodeVersion'; + describe('#getNodeVersion()', () => { it('should extract version that matches range in package.json', () => { const version = parseInt(getNodeVersion(), 10); diff --git a/bin/rdme b/bin/rdme index b1b75c68a..c0e1b743c 100755 --- a/bin/rdme +++ b/bin/rdme @@ -1,2 +1,2 @@ #!/usr/bin/env node -require('../dist/bin'); +require('../dist/src/cli'); diff --git a/bin/set-version-output b/bin/set-version-output index 0779ff36b..d7fa534a1 100755 --- a/bin/set-version-output +++ b/bin/set-version-output @@ -1,7 +1,6 @@ #! /usr/bin/env node - -const getNodeVersion = require('../src/lib/getNodeVersion'); const pkg = require('../package.json'); +const getNodeVersion = require('../src/lib/getNodeVersion'); const name1 = 'RDME_VERSION'; const name2 = 'NODE_VERSION'; diff --git a/package-lock.json b/package-lock.json index 31e5f4609..c5e2a3560 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "@types/parse-link-header": "^2.0.0", "@types/read": "^0.0.29", "@types/semver": "^7.3.10", + "@types/tmp": "^0.2.3", "@types/update-notifier": "^6.0.1", "alex": "^10.0.0", "eslint": "^8.21.0", @@ -1919,6 +1920,12 @@ "integrity": "sha512-dPWnWsf+kzIG140B8z2w3fr5D03TLWbOAFQl45xUpI3vcizeXriNR5VYkWZ+WTMsUHqZ9Xlt3hrxGNANFyNQfw==", "dev": true }, + "node_modules/@types/tmp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-dDZH/tXzwjutnuk4UacGgFRwV+JSLaXL1ikvidfJprkb7L9Nx1njcRHHmi3Dsvt7pgqqTEeucQuOrWHPFgzVHA==", + "dev": true + }, "node_modules/@types/unist": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", @@ -14321,6 +14328,12 @@ "integrity": "sha512-dPWnWsf+kzIG140B8z2w3fr5D03TLWbOAFQl45xUpI3vcizeXriNR5VYkWZ+WTMsUHqZ9Xlt3hrxGNANFyNQfw==", "dev": true }, + "@types/tmp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-dDZH/tXzwjutnuk4UacGgFRwV+JSLaXL1ikvidfJprkb7L9Nx1njcRHHmi3Dsvt7pgqqTEeucQuOrWHPFgzVHA==", + "dev": true + }, "@types/unist": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", diff --git a/package.json b/package.json index 9f325dc57..486d2cb9f 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@types/parse-link-header": "^2.0.0", "@types/read": "^0.0.29", "@types/semver": "^7.3.10", + "@types/tmp": "^0.2.3", "@types/update-notifier": "^6.0.1", "alex": "^10.0.0", "eslint": "^8.21.0", @@ -86,7 +87,7 @@ }, "scripts": { "build": "tsc", - "debug:bin": "node -r ts-node/register src/bin.ts", + "debug:bin": "node -r ts-node/register src/cli.ts", "lint": "eslint . bin/rdme bin/set-version-output --ext .js,.ts", "lint-docs": "alex .", "prebuild": "rm -rf dist/", diff --git a/src/.sink.d.ts b/src/.sink.d.ts new file mode 100644 index 000000000..919b5d139 --- /dev/null +++ b/src/.sink.d.ts @@ -0,0 +1 @@ +declare module '@npmcli/ci-detect'; diff --git a/src/cli.ts b/src/cli.ts index bd3980cbd..03142fb51 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,8 @@ #! /usr/bin/env node -import chalk from 'chalk'; import core from '@actions/core'; - +import chalk from 'chalk'; import updateNotifier from 'update-notifier'; + import pkg from '../package.json'; import isGHA from './lib/isGitHub'; diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 6c190436b..34bf272b3 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1,5 +1,6 @@ import type Command from './baseCommand'; import type { CommandCategories } from './baseCommand'; + import fs from 'fs'; import path from 'path'; diff --git a/src/lib/configstore.ts b/src/lib/configstore.ts index 20fd8890d..7063d590d 100644 --- a/src/lib/configstore.ts +++ b/src/lib/configstore.ts @@ -1,4 +1,5 @@ import Configstore from 'configstore'; + import pkg from '../../package.json'; export default new Configstore(`${pkg.name}-${process.env.NODE_ENV || 'production'}`); diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index 44f946f6d..02416f239 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -2,12 +2,14 @@ import type { Headers } from 'form-data'; import type { BodyInit, Response } from 'node-fetch'; -import { debug } from './logger'; -import nodeFetch from 'node-fetch'; -import isGHA from './isGitHub'; import mime from 'mime-types'; +import nodeFetch from 'node-fetch'; + import pkg from '../../package.json'; + import APIError from './apiError'; +import isGHA from './isGitHub'; +import { debug } from './logger'; /** * Getter function for a string to be used in the user-agent header diff --git a/src/lib/getCategories.ts b/src/lib/getCategories.ts index 8adefb104..4d41e70f9 100644 --- a/src/lib/getCategories.ts +++ b/src/lib/getCategories.ts @@ -1,4 +1,5 @@ import config from 'config'; + import fetch, { cleanHeaders, handleRes } from './fetch'; /** diff --git a/src/lib/help.ts b/src/lib/help.ts index cc53a7727..52675f4e8 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -1,7 +1,9 @@ import type Command from './baseCommand'; + import chalk from 'chalk'; -import config from 'config'; import usage from 'command-line-usage'; +import config from 'config'; + import * as commands from './commands'; function formatCommands(cmds: { name: string; description: string; position: number }[]) { diff --git a/src/lib/isSupportedNodeVersion.ts b/src/lib/isSupportedNodeVersion.ts index 976f94846..17b339876 100644 --- a/src/lib/isSupportedNodeVersion.ts +++ b/src/lib/isSupportedNodeVersion.ts @@ -1,4 +1,5 @@ import semver from 'semver'; + import pkg from '../../package.json'; /** diff --git a/src/lib/logger.ts b/src/lib/logger.ts index e67937ef6..d26cedc95 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,10 +1,11 @@ -import type { Writable } from 'type-fest'; import type { Options as OraOptions } from 'ora'; +import type { Writable } from 'type-fest'; +import core from '@actions/core'; import chalk from 'chalk'; import config from 'config'; -import core from '@actions/core'; import debugModule from 'debug'; + import isGHA from './isGitHub'; const debugPackage = debugModule(config.get('cli')); diff --git a/src/lib/prepareOas.ts b/src/lib/prepareOas.ts index 00d431bee..cb3a5b0c4 100644 --- a/src/lib/prepareOas.ts +++ b/src/lib/prepareOas.ts @@ -1,5 +1,6 @@ -import chalk from 'chalk'; import fs from 'fs'; + +import chalk from 'chalk'; import OASNormalize from 'oas-normalize'; import ora from 'ora'; diff --git a/src/lib/pushDoc.ts b/src/lib/pushDoc.ts index 3bb3246cb..a7eb80642 100644 --- a/src/lib/pushDoc.ts +++ b/src/lib/pushDoc.ts @@ -1,10 +1,11 @@ -import chalk from 'chalk'; -import config from 'config'; import crypto from 'crypto'; import fs from 'fs'; -import grayMatter from 'gray-matter'; import path from 'path'; +import chalk from 'chalk'; +import config from 'config'; +import grayMatter from 'gray-matter'; + import APIError from './apiError'; import { CommandCategories } from './baseCommand'; import fetch, { cleanHeaders, handleRes } from './fetch'; diff --git a/src/lib/streamSpecToRegistry.ts b/src/lib/streamSpecToRegistry.ts index b3ce75549..2feefcee3 100644 --- a/src/lib/streamSpecToRegistry.ts +++ b/src/lib/streamSpecToRegistry.ts @@ -1,11 +1,13 @@ +import fs from 'fs'; + import config from 'config'; -import { debug, oraOptions } from './logger'; -import fetch, { handleRes } from './fetch'; import FormData from 'form-data'; -import fs from 'fs'; import ora from 'ora'; import { file as tmpFile } from 'tmp-promise'; +import fetch, { handleRes } from './fetch'; +import { debug, oraOptions } from './logger'; + /** * Uploads a spec to the API registry for usage in ReadMe * diff --git a/src/typings.d.ts b/src/typings.d.ts index a710d8f2a..211f3aee3 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -1,4 +1,3 @@ // These packges don't have any TS types so we need to declare a module in order to use them. -declare module '@npmcli/ci-detect'; declare module 'editor'; declare module 'oas-normalize'; diff --git a/tsconfig.json b/tsconfig.json index 21df4230d..936f5caef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,13 +2,16 @@ "compilerOptions": { "allowJs": true, "baseUrl": "./src", - "declaration": true, "downlevelIteration": true, "esModuleInterop": true, "lib": ["es2020"], "noImplicitAny": true, "outDir": "dist/", - "resolveJsonModule": true + "paths": { + "@npmcli/ci-detect": [".sink.d.ts"] + }, + "resolveJsonModule": true, + "target": "ES3" }, - "include": ["./src/**/*"] + "include": ["./config/*.js", "./config/*.json", "./src/**/*"] } From 6136c29ac9362c33603b558ce4b7aeecc3390a6c Mon Sep 17 00:00:00 2001 From: Jon Ursenbach <jon@ursenba.ch> Date: Tue, 9 Aug 2022 10:24:59 -0700 Subject: [PATCH 05/21] fix: refactoring the fetch wrapper to use the Headers API --- .eslintrc | 4 +- ...{index.test.js.snap => index.test.ts.snap} | 0 __tests__/bin.test.ts | 8 +-- __tests__/index.test.ts | 7 ++- __tests__/lib/fetch.test.ts | 33 +++++++--- jest.config.js | 7 ++- src/.sink.d.ts | 3 + src/cmds/categories/create.ts | 12 ++-- src/cmds/docs/edit.ts | 24 +++++--- src/cmds/openapi.ts | 23 ++++--- src/cmds/versions/create.ts | 12 ++-- src/cmds/versions/update.ts | 12 ++-- src/lib/commands.ts | 2 +- src/lib/fetch.ts | 61 +++++++++---------- src/lib/getCategories.ts | 23 ++++--- src/lib/prepareOas.ts | 3 +- src/lib/pushDoc.ts | 34 +++++++---- src/lib/versionSelect.ts | 3 +- src/typings.d.ts | 3 - tsconfig.json | 6 +- 20 files changed, 175 insertions(+), 105 deletions(-) rename __tests__/__snapshots__/{index.test.js.snap => index.test.ts.snap} (100%) delete mode 100644 src/typings.d.ts diff --git a/.eslintrc b/.eslintrc index 76a4d5b18..ff5bea0b1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -52,6 +52,8 @@ * Furthermore, we should also be using our custom loggers (see src/lib/logger.js) * instead of using console.info() or console.warn() statements. */ - "no-console": ["warn"] + "no-console": "warn", + + "no-restricted-syntax": "off" } } diff --git a/__tests__/__snapshots__/index.test.js.snap b/__tests__/__snapshots__/index.test.ts.snap similarity index 100% rename from __tests__/__snapshots__/index.test.js.snap rename to __tests__/__snapshots__/index.test.ts.snap diff --git a/__tests__/bin.test.ts b/__tests__/bin.test.ts index 6843af9b3..1d79683f2 100644 --- a/__tests__/bin.test.ts +++ b/__tests__/bin.test.ts @@ -8,10 +8,10 @@ describe('bin', () => { it('should show our help screen', async () => { expect.assertions(1); - await new Promise(done => { + await new Promise(resolve => { exec(`node ${__dirname}/../bin/rdme`, (error, stdout) => { expect(stdout).toContain('a utility for interacting with ReadMe'); - done(); + resolve(true); }); }); }); @@ -19,12 +19,12 @@ describe('bin', () => { it('should fail with a message', async () => { expect.assertions(1); - await new Promise(done => { + await new Promise(resolve => { exec(`node ${__dirname}/../bin/rdme`, (error, stdout, stderr) => { expect(stderr).toContain( `We're sorry, this release of rdme does not support Node.js ${process.version}. We support the following versions: ${pkg.engines.node}` ); - done(); + resolve(true); }); }); }); diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 0d5baaea3..ca3cb8bea 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -6,8 +6,7 @@ import conf from '../src/lib/configstore'; describe('cli', () => { it('command not found', async () => { - // @ts-expect-error thius is fine - await expect(cli('notARealCommand')).rejects.toThrow('Command not found'); + await expect(cli(['no-such-command'])).rejects.toThrow('Command not found'); }); describe('--version', () => { @@ -57,10 +56,14 @@ describe('cli', () => { process.stderr.columns = 100; }); + /* eslint-disable @typescript-eslint/ban-ts-comment */ afterEach(() => { + // @ts-ignore This is the only way we can disable columns within the `table-layout` library. process.stdout.columns = undefined; + // @ts-ignore process.stderr.columns = undefined; }); + /* eslint-enable @typescript-eslint/ban-ts-comment */ it('should print help', async () => { await expect(cli(['--help'])).resolves.toContain('a utility for interacting with ReadMe'); diff --git a/__tests__/lib/fetch.test.ts b/__tests__/lib/fetch.test.ts index 59a399e1d..d4abe9454 100644 --- a/__tests__/lib/fetch.test.ts +++ b/__tests__/lib/fetch.test.ts @@ -1,4 +1,6 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ import config from 'config'; +import { Headers } from 'node-fetch'; import pkg from '../../package.json'; import fetch, { cleanHeaders, handleRes } from '../../src/lib/fetch'; @@ -101,25 +103,38 @@ describe('#fetch()', () => { describe('#cleanHeaders()', () => { it('should base64-encode key in ReadMe-friendly format', () => { - expect(cleanHeaders('test')).toStrictEqual({ Authorization: 'Basic dGVzdDo=' }); + expect(Array.from(cleanHeaders('test'))).toStrictEqual([['authorization', 'Basic dGVzdDo=']]); }); it('should filter out undefined headers', () => { - expect(cleanHeaders('test', { 'x-readme-version': undefined })).toStrictEqual({ Authorization: 'Basic dGVzdDo=' }); + expect( + // @ts-ignore Testing a quirk of `node-fetch`. + Array.from(cleanHeaders('test', new Headers({ 'x-readme-version': undefined }))) + ).toStrictEqual([['authorization', 'Basic dGVzdDo=']]); }); it('should filter out null headers', () => { - expect(cleanHeaders('test', { 'x-readme-version': undefined, Accept: null })).toStrictEqual({ - Authorization: 'Basic dGVzdDo=', - }); + expect( + // @ts-ignore Testing a quirk of `node-fetch`. + Array.from(cleanHeaders('test', new Headers({ 'x-readme-version': '1234', Accept: null }))) + ).toStrictEqual([ + ['authorization', 'Basic dGVzdDo='], + ['x-readme-version', '1234'], + ]); }); it('should pass in properly defined headers', () => { - expect( - cleanHeaders('test', { 'x-readme-version': undefined, Accept: null, 'Content-Type': 'application/json' }) - ).toStrictEqual({ - Authorization: 'Basic dGVzdDo=', + const headers = new Headers({ + 'x-readme-version': '1234', + Accept: 'text/plain', 'Content-Type': 'application/json', }); + + expect(Array.from(cleanHeaders('test', headers))).toStrictEqual([ + ['authorization', 'Basic dGVzdDo='], + ['accept', 'text/plain'], + ['content-type', 'application/json'], + ['x-readme-version', '1234'], + ]); }); }); diff --git a/jest.config.js b/jest.config.js index 36f8076f0..63346ccf9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,7 +18,12 @@ module.exports = { roots: ['<rootDir>'], setupFiles: ['./__tests__/set-node-env'], setupFilesAfterEnv: ['jest-extended/all'], - testPathIgnorePatterns: ['dist/', './__tests__/get-api-nock', './__tests__/set-node-env'], + testPathIgnorePatterns: [ + 'dist/', + '<rootDir>/__tests__/helpers/', + '<rootDir>/__tests__/get-api-nock', + '<rootDir>/__tests__/set-node-env', + ], testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js?|ts?)$', transform: {}, }; diff --git a/src/.sink.d.ts b/src/.sink.d.ts index 919b5d139..58ffb9a81 100644 --- a/src/.sink.d.ts +++ b/src/.sink.d.ts @@ -1 +1,4 @@ +// These packages don't have any TS types so we need to declare a module in order to use them. declare module '@npmcli/ci-detect'; +declare module 'editor'; +declare module 'oas-normalize'; diff --git a/src/cmds/categories/create.ts b/src/cmds/categories/create.ts index a1f4f3699..cca42755f 100644 --- a/src/cmds/categories/create.ts +++ b/src/cmds/categories/create.ts @@ -2,6 +2,7 @@ import type { CommandOptions } from '../../lib/baseCommand'; import chalk from 'chalk'; import config from 'config'; +import { Headers } from 'node-fetch'; import Command, { CommandCategories } from '../../lib/baseCommand'; import fetch, { cleanHeaders, handleRes } from '../../lib/fetch'; @@ -103,10 +104,13 @@ export default class CategoriesCreateCommand extends Command { } return fetch(`${config.get('host')}/api/v1/categories`, { method: 'post', - headers: cleanHeaders(key, { - 'x-readme-version': selectedVersion, - 'Content-Type': 'application/json', - }), + headers: cleanHeaders( + key, + new Headers({ + 'x-readme-version': selectedVersion, + 'Content-Type': 'application/json', + }) + ), body: JSON.stringify({ title, type: categoryType, diff --git a/src/cmds/docs/edit.ts b/src/cmds/docs/edit.ts index 79cd022ee..b8809d63d 100644 --- a/src/cmds/docs/edit.ts +++ b/src/cmds/docs/edit.ts @@ -4,6 +4,8 @@ import fs from 'fs'; import { promisify } from 'util'; import config from 'config'; +import { Headers } from 'node-fetch'; + import editor from 'editor'; import APIError from '../../lib/apiError'; @@ -72,10 +74,13 @@ export default class EditDocsCommand extends Command { const existingDoc = await fetch(`${config.get('host')}/api/v1/docs/${slug}`, { method: 'get', - headers: cleanHeaders(key, { - 'x-readme-version': selectedVersion, - Accept: 'application/json', - }), + headers: cleanHeaders( + key, + new Headers({ + 'x-readme-version': selectedVersion, + Accept: 'application/json', + }) + ), }).then(res => handleRes(res)); await writeFile(filename, existingDoc.body); @@ -92,10 +97,13 @@ export default class EditDocsCommand extends Command { return fetch(`${config.get('host')}/api/v1/docs/${slug}`, { method: 'put', - headers: cleanHeaders(key, { - 'x-readme-version': selectedVersion, - 'Content-Type': 'application/json', - }), + headers: cleanHeaders( + key, + new Headers({ + 'x-readme-version': selectedVersion, + 'Content-Type': 'application/json', + }) + ), body: JSON.stringify( Object.assign(existingDoc, { body: updatedDoc, diff --git a/src/cmds/openapi.ts b/src/cmds/openapi.ts index 9eeaf4ece..8e003862b 100644 --- a/src/cmds/openapi.ts +++ b/src/cmds/openapi.ts @@ -4,6 +4,7 @@ import type { RequestInit, Response } from 'node-fetch'; import chalk from 'chalk'; import config from 'config'; import { prompt } from 'enquirer'; +import { Headers } from 'node-fetch'; import ora from 'ora'; import parse from 'parse-link-header'; @@ -146,11 +147,14 @@ export default class OpenAPICommand extends Command { const registryUUID = await streamSpecToRegistry(bundledSpec); const options: RequestInit = { - headers: cleanHeaders(key, { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'x-readme-version': selectedVersion, - }), + headers: cleanHeaders( + key, + new Headers({ + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-readme-version': selectedVersion, + }) + ), body: JSON.stringify({ registryUUID }), }; @@ -192,9 +196,12 @@ export default class OpenAPICommand extends Command { function getSpecs(url: string) { return fetch(`${config.get('host')}${url}`, { method: 'get', - headers: cleanHeaders(key, { - 'x-readme-version': selectedVersion, - }), + headers: cleanHeaders( + key, + new Headers({ + 'x-readme-version': selectedVersion, + }) + ), }); } diff --git a/src/cmds/versions/create.ts b/src/cmds/versions/create.ts index de5f162d0..8c33da9d4 100644 --- a/src/cmds/versions/create.ts +++ b/src/cmds/versions/create.ts @@ -2,6 +2,7 @@ import type { CommandOptions } from '../../lib/baseCommand'; import config from 'config'; import { prompt } from 'enquirer'; +import { Headers } from 'node-fetch'; import semver from 'semver'; import Command, { CommandCategories } from '../../lib/baseCommand'; @@ -101,10 +102,13 @@ export default class CreateVersionCommand extends Command { return fetch(`${config.get('host')}/api/v1/version`, { method: 'post', - headers: cleanHeaders(key, { - Accept: 'application/json', - 'Content-Type': 'application/json', - }), + headers: cleanHeaders( + key, + new Headers({ + Accept: 'application/json', + 'Content-Type': 'application/json', + }) + ), body: JSON.stringify({ version, codename: codename || '', diff --git a/src/cmds/versions/update.ts b/src/cmds/versions/update.ts index 969b27499..28f44dbe7 100644 --- a/src/cmds/versions/update.ts +++ b/src/cmds/versions/update.ts @@ -2,6 +2,7 @@ import type { CommandOptions } from '../../lib/baseCommand'; import config from 'config'; import { prompt } from 'enquirer'; +import { Headers } from 'node-fetch'; import Command, { CommandCategories } from '../../lib/baseCommand'; import fetch, { cleanHeaders, handleRes } from '../../lib/fetch'; @@ -95,10 +96,13 @@ export default class UpdateVersionCommand extends Command { return fetch(`${config.get('host')}/api/v1/version/${selectedVersion}`, { method: 'put', - headers: cleanHeaders(key, { - Accept: 'application/json', - 'Content-Type': 'application/json', - }), + headers: cleanHeaders( + key, + new Headers({ + Accept: 'application/json', + 'Content-Type': 'application/json', + }) + ), body: JSON.stringify({ codename: codename || '', version: newVersion || promptResponse.newVersion, diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 34bf272b3..125472f4d 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -90,7 +90,7 @@ export function load(cmd: string) { const file = path.join(__dirname, '../cmds', command, subcommand); try { // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-dynamic-require - const CommandClass = require(file); + const { default: CommandClass } = require(file); return new CommandClass(); } catch (e) { throw new Error('Command not found.'); diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index 02416f239..1f0751f84 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -1,9 +1,7 @@ -/* eslint-disable no-param-reassign */ -import type { Headers } from 'form-data'; -import type { BodyInit, Response } from 'node-fetch'; +import type { RequestInit, Response } from 'node-fetch'; import mime from 'mime-types'; -import nodeFetch from 'node-fetch'; +import nodeFetch, { Headers } from 'node-fetch'; import pkg from '../../package.json'; @@ -12,8 +10,8 @@ import isGHA from './isGitHub'; import { debug } from './logger'; /** - * Getter function for a string to be used in the user-agent header - * based on the current environment. + * Getter function for a string to be used in the user-agent header based on the current + * environment. * */ function getUserAgent() { @@ -25,28 +23,30 @@ function getUserAgent() { * Wrapper for the `fetch` API so we can add rdme-specific headers to all API requests. * */ -export default function fetch( - url: string, - options: { body?: BodyInit; headers?: Headers; method?: string } = { headers: {} } -) { +export default function fetch(url: string, options: RequestInit = { headers: new Headers() }) { let source = 'cli'; - options.headers['User-Agent'] = getUserAgent(); + const headers = options.headers as Headers; + + headers.set('User-Agent', getUserAgent()); if (isGHA()) { source = 'cli-gh'; - options.headers['x-github-repository'] = process.env.GITHUB_REPOSITORY; - options.headers['x-github-run-attempt'] = process.env.GITHUB_RUN_ATTEMPT; - options.headers['x-github-run-id'] = process.env.GITHUB_RUN_ID; - options.headers['x-github-run-number'] = process.env.GITHUB_RUN_NUMBER; - options.headers['x-github-sha'] = process.env.GITHUB_SHA; + headers.set('x-github-repository', process.env.GITHUB_REPOSITORY); + headers.set('x-github-run-attempt', process.env.GITHUB_RUN_ATTEMPT); + headers.set('x-github-run-id', process.env.GITHUB_RUN_ID); + headers.set('x-github-run-number', process.env.GITHUB_RUN_NUMBER); + headers.set('x-github-sha', process.env.GITHUB_SHA); } - options.headers['x-readme-source'] = source; + headers.set('x-readme-source', source); debug(`making ${(options.method || 'get').toUpperCase()} request to ${url}`); - return nodeFetch(url, options); + return nodeFetch(url, { + ...options, + headers, + }); } /** @@ -56,7 +56,6 @@ export default function fetch( * * If we receive non-JSON responses, we consider them errors and throw them. * - * @param {Response} res */ async function handleRes(res: Response) { const contentType = res.headers.get('content-type'); @@ -77,25 +76,23 @@ async function handleRes(res: Response) { } /** - * Returns the basic auth header and any other defined headers for use in node-fetch API calls. + * Returns the basic auth header and any other defined headers for use in `node-fetch` API calls. * - * @param {string} key The ReadMe project API key - * @param {Object} inputHeaders Any additional headers to be cleaned - * @returns An object with cleaned request headers for usage in the node-fetch requests to the ReadMe API. */ -function cleanHeaders(key: string, inputHeaders: Headers = {}) { +function cleanHeaders(key: string, inputHeaders: Headers = new Headers()) { const encodedKey = Buffer.from(`${key}:`).toString('base64'); - const headers: Record<string, string> = { + const headers = new Headers({ Authorization: `Basic ${encodedKey}`, - }; - - Object.keys(inputHeaders).forEach(header => { - // For some reason, node-fetch will send in the string 'undefined' - // if you pass in an undefined value for a header, - // so that's why headers are added incrementally. - if (typeof inputHeaders[header] === 'string') headers[header] = inputHeaders[header]; }); + for (const header of inputHeaders.entries()) { + // If you supply `undefined` or `null` to the `Headers` API it'll convert that those to a + // string. + if (header[1] !== 'null' && header[1] !== 'undefined' && header[1].length > 0) { + headers.set(header[0], header[1]); + } + } + return headers; } diff --git a/src/lib/getCategories.ts b/src/lib/getCategories.ts index 4d41e70f9..8eaac2c3b 100644 --- a/src/lib/getCategories.ts +++ b/src/lib/getCategories.ts @@ -1,4 +1,5 @@ import config from 'config'; +import { Headers } from 'node-fetch'; import fetch, { cleanHeaders, handleRes } from './fetch'; @@ -14,10 +15,13 @@ export default async function getCategories(key: string, selectedVersion: string let totalCount = 0; return fetch(`${config.get('host')}/api/v1/categories?perPage=20&page=1`, { method: 'get', - headers: cleanHeaders(key, { - 'x-readme-version': selectedVersion, - Accept: 'application/json', - }), + headers: cleanHeaders( + key, + new Headers({ + 'x-readme-version': selectedVersion, + Accept: 'application/json', + }) + ), }) .then(res => { totalCount = Math.ceil(parseInt(res.headers.get('x-total-count'), 10) / 20); @@ -36,10 +40,13 @@ export default async function getCategories(key: string, selectedVersion: string [...new Array(totalCount + 1).keys()].slice(2).map(async page => { return fetch(`${config.get('host')}/api/v1/categories?perPage=20&page=${page}`, { method: 'get', - headers: cleanHeaders(key, { - 'x-readme-version': selectedVersion, - Accept: 'application/json', - }), + headers: cleanHeaders( + key, + new Headers({ + 'x-readme-version': selectedVersion, + Accept: 'application/json', + }) + ), }).then(res => handleRes(res)); }) )) diff --git a/src/lib/prepareOas.ts b/src/lib/prepareOas.ts index cb3a5b0c4..69e81b238 100644 --- a/src/lib/prepareOas.ts +++ b/src/lib/prepareOas.ts @@ -1,9 +1,10 @@ import fs from 'fs'; import chalk from 'chalk'; -import OASNormalize from 'oas-normalize'; import ora from 'ora'; +import OASNormalize from 'oas-normalize'; + import { debug, info, oraOptions } from './logger'; /** diff --git a/src/lib/pushDoc.ts b/src/lib/pushDoc.ts index a7eb80642..aafda83e2 100644 --- a/src/lib/pushDoc.ts +++ b/src/lib/pushDoc.ts @@ -5,6 +5,7 @@ import path from 'path'; import chalk from 'chalk'; import config from 'config'; import grayMatter from 'gray-matter'; +import { Headers } from 'node-fetch'; import APIError from './apiError'; import { CommandCategories } from './baseCommand'; @@ -63,10 +64,13 @@ export default async function pushDoc( return fetch(`${config.get('host')}/api/v1/${type}`, { method: 'post', - headers: cleanHeaders(key, { - 'x-readme-version': selectedVersion, - 'Content-Type': 'application/json', - }), + headers: cleanHeaders( + key, + new Headers({ + 'x-readme-version': selectedVersion, + 'Content-Type': 'application/json', + }) + ), body: JSON.stringify({ slug, ...data, @@ -91,10 +95,13 @@ export default async function pushDoc( return fetch(`${config.get('host')}/api/v1/${type}/${slug}`, { method: 'put', - headers: cleanHeaders(key, { - 'x-readme-version': selectedVersion, - 'Content-Type': 'application/json', - }), + headers: cleanHeaders( + key, + new Headers({ + 'x-readme-version': selectedVersion, + 'Content-Type': 'application/json', + }) + ), body: JSON.stringify( Object.assign(existingDoc, { ...data, @@ -107,10 +114,13 @@ export default async function pushDoc( return fetch(`${config.get('host')}/api/v1/${type}/${slug}`, { method: 'get', - headers: cleanHeaders(key, { - 'x-readme-version': selectedVersion, - Accept: 'application/json', - }), + headers: cleanHeaders( + key, + new Headers({ + 'x-readme-version': selectedVersion, + Accept: 'application/json', + }) + ), }) .then(async res => { const body = await res.json(); diff --git a/src/lib/versionSelect.ts b/src/lib/versionSelect.ts index a0ce4877c..fa1086adf 100644 --- a/src/lib/versionSelect.ts +++ b/src/lib/versionSelect.ts @@ -1,5 +1,6 @@ import config from 'config'; import { prompt } from 'enquirer'; +import { Headers } from 'node-fetch'; import APIError from './apiError'; import fetch, { cleanHeaders, handleRes } from './fetch'; @@ -34,7 +35,7 @@ export async function getProjectVersion(versionFlag: string, key: string, allowN await fetch(`${config.get('host')}/api/v1/version`, { method: 'post', - headers: cleanHeaders(key, { 'Content-Type': 'application/json' }), + headers: cleanHeaders(key, new Headers({ 'Content-Type': 'application/json' })), body: JSON.stringify({ from: versionList[0].version, version: newVersion, diff --git a/src/typings.d.ts b/src/typings.d.ts deleted file mode 100644 index 211f3aee3..000000000 --- a/src/typings.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// These packges don't have any TS types so we need to declare a module in order to use them. -declare module 'editor'; -declare module 'oas-normalize'; diff --git a/tsconfig.json b/tsconfig.json index 936f5caef..c6cd9756a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,10 +8,12 @@ "noImplicitAny": true, "outDir": "dist/", "paths": { - "@npmcli/ci-detect": [".sink.d.ts"] + "@npmcli/ci-detect": [".sink.d.ts"], + "editor": [".sink.d.ts"], + "oas-normalize": [".sink.d.ts"] }, "resolveJsonModule": true, - "target": "ES3" + "target": "ES5" }, "include": ["./config/*.js", "./config/*.json", "./src/**/*"] } From 512fff1ece5933fe9b6965d462b4ae349d14afd6 Mon Sep 17 00:00:00 2001 From: Jon Ursenbach <jon@ursenba.ch> Date: Tue, 9 Aug 2022 10:38:40 -0700 Subject: [PATCH 06/21] fix: broken tests --- __tests__/cmds/openapi.test.ts | 8 ++++++++ src/cmds/openapi.ts | 5 +++-- src/lib/fetch.ts | 5 ++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/__tests__/cmds/openapi.test.ts b/__tests__/cmds/openapi.test.ts index ee3a7652c..7f0f203f5 100644 --- a/__tests__/cmds/openapi.test.ts +++ b/__tests__/cmds/openapi.test.ts @@ -583,6 +583,14 @@ describe('rdme openapi', () => { }); describe('rdme swagger', () => { + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + it('should run `rdme openapi`', () => { return expect(swagger.run({ spec: '', key, id, version })).rejects.toThrow( "We couldn't find an OpenAPI or Swagger definition.\n\n" + diff --git a/src/cmds/openapi.ts b/src/cmds/openapi.ts index 8e003862b..4c06aa00c 100644 --- a/src/cmds/openapi.ts +++ b/src/cmds/openapi.ts @@ -8,7 +8,6 @@ import { Headers } from 'node-fetch'; import ora from 'ora'; import parse from 'parse-link-header'; -import APIError from '../lib/apiError'; import Command, { CommandCategories } from '../lib/baseCommand'; import fetch, { cleanHeaders, handleRes } from '../lib/fetch'; import { debug, warn, oraOptions } from '../lib/logger'; @@ -122,9 +121,10 @@ export default class OpenAPICommand extends Command { async function error(res: Response) { return handleRes(res).catch(err => { // If we receive an APIError, no changes needed! Throw it as is. - if (err instanceof APIError) { + if (err.name === 'APIError') { throw err; } + // If we receive certain text responses, it's likely a 5xx error from our server. if ( typeof err === 'string' && @@ -135,6 +135,7 @@ export default class OpenAPICommand extends Command { "We're sorry, your upload request timed out. Please try again or split your file up into smaller chunks." ); } + // As a fallback, we throw a more generic error. throw new Error( `Yikes, something went wrong! Please try uploading your spec again and if the problem persists, get in touch with our support team at ${chalk.underline( diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index 1f0751f84..b1927423b 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -25,8 +25,11 @@ function getUserAgent() { */ export default function fetch(url: string, options: RequestInit = { headers: new Headers() }) { let source = 'cli'; + let headers = options.headers as Headers; - const headers = options.headers as Headers; + if (!(options.headers instanceof Headers)) { + headers = new Headers(options.headers); + } headers.set('User-Agent', getUserAgent()); From 00e8c20ba67fa550b4cecd30bb22c0ca98ec64f1 Mon Sep 17 00:00:00 2001 From: Jon Ursenbach <jon@ursenba.ch> Date: Tue, 9 Aug 2022 10:47:45 -0700 Subject: [PATCH 07/21] fix: better excluding the dist from jest --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index 63346ccf9..e0fe48dae 100644 --- a/jest.config.js +++ b/jest.config.js @@ -19,7 +19,7 @@ module.exports = { setupFiles: ['./__tests__/set-node-env'], setupFilesAfterEnv: ['jest-extended/all'], testPathIgnorePatterns: [ - 'dist/', + '<rootDir>/dist/', '<rootDir>/__tests__/helpers/', '<rootDir>/__tests__/get-api-nock', '<rootDir>/__tests__/set-node-env', From edf839c47c6bccd7b138e88164c53b93e90da754 Mon Sep 17 00:00:00 2001 From: Jon Ursenbach <jon@ursenba.ch> Date: Tue, 9 Aug 2022 11:27:21 -0700 Subject: [PATCH 08/21] fix: refactoring away the dynamic command loading system for something more strict --- .../lib/__snapshots__/commands.test.ts.snap | 151 ++++++++++++++++++ __tests__/lib/commands.test.ts | 45 +++++- src/cmds/index.ts | 52 ++++++ src/lib/commands.ts | 51 ++---- 4 files changed, 252 insertions(+), 47 deletions(-) create mode 100644 __tests__/lib/__snapshots__/commands.test.ts.snap create mode 100644 src/cmds/index.ts diff --git a/__tests__/lib/__snapshots__/commands.test.ts.snap b/__tests__/lib/__snapshots__/commands.test.ts.snap new file mode 100644 index 000000000..24cbafa34 --- /dev/null +++ b/__tests__/lib/__snapshots__/commands.test.ts.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`utils #listByCategory should list commands by category 1`] = ` +Object { + "admin": Object { + "commands": Array [ + Object { + "description": "Login to a ReadMe project.", + "name": "login", + "position": 1, + }, + Object { + "description": "Logs the currently authenticated user out of ReadMe.", + "name": "logout", + "position": 2, + }, + Object { + "description": "Displays the current user and project authenticated with ReadMe.", + "name": "whoami", + "position": 3, + }, + ], + "description": "Administration", + }, + "apis": Object { + "commands": Array [ + Object { + "description": "Upload, or resync, your OpenAPI/Swagger definition to ReadMe.", + "name": "openapi", + "position": 1, + }, + Object { + "description": "Alias for \`rdme openapi\`. [deprecated]", + "name": "swagger", + "position": 2, + }, + Object { + "description": "Validate your OpenAPI/Swagger definition.", + "name": "validate", + "position": 2, + }, + ], + "description": "Upload OpenAPI/Swagger definitions", + }, + "categories": Object { + "commands": Array [ + Object { + "description": "Get all categories in your ReadMe project.", + "name": "categories", + "position": 1, + }, + Object { + "description": "Create a category with the specified title and guide in your ReadMe project.", + "name": "categories:create", + "position": 2, + }, + ], + "description": "Categories", + }, + "changelogs": Object { + "commands": Array [ + Object { + "description": "Sync a folder of Markdown files to your ReadMe project as Changelog posts.", + "name": "changelogs", + "position": 1, + }, + Object { + "description": "Sync a single Markdown file to your ReadMe project as a Changelog post.", + "name": "changelogs:single", + "position": 2, + }, + ], + "description": "Changelog", + }, + "custompages": Object { + "commands": Array [ + Object { + "description": "Sync a folder of Markdown files to your ReadMe project as Custom Pages.", + "name": "custompages", + "position": 1, + }, + Object { + "description": "Sync a single Markdown file to your ReadMe project as a Custom Page.", + "name": "custompages:single", + "position": 2, + }, + ], + "description": "Custom Pages", + }, + "docs": Object { + "commands": Array [ + Object { + "description": "Sync a folder of Markdown files to your ReadMe project.", + "name": "docs", + "position": 1, + }, + Object { + "description": "Edit a single file from your ReadMe project without saving locally.", + "name": "docs:edit", + "position": 2, + }, + Object { + "description": "Sync a single Markdown file to your ReadMe project.", + "name": "docs:single", + "position": 3, + }, + ], + "description": "Documentation", + }, + "utilities": Object { + "commands": Array [ + Object { + "description": "Helpful OpenAPI generation tooling. [inactive]", + "name": "oas", + "position": 1, + }, + Object { + "description": "Open your current ReadMe project in the browser.", + "name": "open", + "position": 2, + }, + ], + "description": "Other useful commands", + }, + "versions": Object { + "commands": Array [ + Object { + "description": "List versions available in your project or get a version by SemVer (https://semver.org/).", + "name": "versions", + "position": 1, + }, + Object { + "description": "Create a new version for your project.", + "name": "versions:create", + "position": 2, + }, + Object { + "description": "Delete a version associated with your ReadMe project.", + "name": "versions:delete", + "position": 4, + }, + Object { + "description": "Update an existing version for your project.", + "name": "versions:update", + "position": 3, + }, + ], + "description": "Versions", + }, +} +`; diff --git a/__tests__/lib/commands.test.ts b/__tests__/lib/commands.test.ts index 0acbde496..ae0734733 100644 --- a/__tests__/lib/commands.test.ts +++ b/__tests__/lib/commands.test.ts @@ -1,8 +1,9 @@ -/* eslint-disable jest/no-if */ /// <reference types="jest-extended" /> -/* eslint-disable jest/no-conditional-expect */ +/* eslint-disable jest/no-conditional-expect, jest/no-if */ import type Command from '../../src/lib/baseCommand'; +import SingleDocCommand from '../../src/cmds/docs/single'; +import { CommandCategories } from '../../src/lib/baseCommand'; import * as commands from '../../src/lib/commands'; describe('utils', () => { @@ -13,8 +14,6 @@ describe('utils', () => { describe('commands', () => { it('should be configured properly', () => { - expect.hasAssertions(); - commands.list().forEach(c => { const cmd = c.command; @@ -37,8 +36,6 @@ describe('utils', () => { }); describe('cli standards', () => { - expect.hasAssertions(); - describe.each<[string, Command]>(commands.list().map(cmd => [cmd.command.command, cmd.command]))( '%s', (_, command) => { @@ -69,4 +66,40 @@ describe('utils', () => { }); }); }); + + describe('#load', () => { + it('should load a valid command', () => { + expect(commands.load('docs:single')).toBeInstanceOf(SingleDocCommand); + }); + + it('should throw an error on an invalid command', () => { + expect(() => { + // @ts-expect-error Testing a valid failure case. + commands.load('buster'); + }).toThrow('Command not found'); + }); + }); + + describe('#listByCategory', () => { + it('should list commands by category', () => { + expect(commands.listByCategory()).toMatchSnapshot(); + }); + }); + + describe('#getSimilar', () => { + it('should pull similar commands', () => { + expect(commands.getSimilar(CommandCategories.ADMIN, 'login')).toStrictEqual([ + { + name: 'logout', + description: 'Logs the currently authenticated user out of ReadMe.', + position: 2, + }, + { + name: 'whoami', + description: 'Displays the current user and project authenticated with ReadMe.', + position: 3, + }, + ]); + }); + }); }); diff --git a/src/cmds/index.ts b/src/cmds/index.ts new file mode 100644 index 000000000..b7b1bbc0a --- /dev/null +++ b/src/cmds/index.ts @@ -0,0 +1,52 @@ +import CategoriesCommand from './categories'; +import CategoriesCreateCommand from './categories/create'; +import ChangelogsCommand from './changelogs'; +import SingleChangelogCommand from './changelogs/single'; +import CustomPagesCommand from './custompages'; +import SingleCustomPageCommand from './custompages/single'; +import DocsCommand from './docs'; +import EditDocsCommand from './docs/edit'; +import SingleDocCommand from './docs/single'; +import LoginCommand from './login'; +import LogoutCommand from './logout'; +import OASCommand from './oas'; +import OpenCommand from './open'; +import OpenAPICommand from './openapi'; +import SwaggerCommand from './swagger'; +import ValidateCommand from './validate'; +import VersionsCommand from './versions'; +import CreateVersionCommand from './versions/create'; +import DeleteVersionCommand from './versions/delete'; +import UpdateVersionCommand from './versions/update'; +import WhoAmICommand from './whoami'; + +const commands = { + categories: CategoriesCommand, + 'categories:create': CategoriesCreateCommand, + + changelogs: ChangelogsCommand, + 'changelogs:single': SingleChangelogCommand, + + custompages: CustomPagesCommand, + 'custompages:single': SingleCustomPageCommand, + + docs: DocsCommand, + 'docs:edit': EditDocsCommand, + 'docs:single': SingleDocCommand, + + versions: VersionsCommand, + 'versions:create': CreateVersionCommand, + 'versions:delete': DeleteVersionCommand, + 'versions:update': UpdateVersionCommand, + + login: LoginCommand, + logout: LogoutCommand, + oas: OASCommand, + open: OpenCommand, + openapi: OpenAPICommand, + swagger: SwaggerCommand, + validate: ValidateCommand, + whoami: WhoAmICommand, +}; + +export default commands; diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 125472f4d..438020d1b 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1,8 +1,6 @@ -import type Command from './baseCommand'; import type { CommandCategories } from './baseCommand'; -import fs from 'fs'; -import path from 'path'; +import commands from '../cmds'; export function getCategories(): Record< string, @@ -52,49 +50,20 @@ export function getCategories(): Record< } export function list() { - const commands: { file: string; command: Command }[] = []; - const cmdDir = `${__dirname}/../cmds`; - const files = fs - .readdirSync(cmdDir) - .map(file => { - const stats = fs.statSync(path.join(cmdDir, file)); - if (stats.isDirectory()) { - return fs.readdirSync(path.join(cmdDir, file)).map(f => path.join(file, f)); - } - return [file]; - }) - .reduce((a, b) => a.concat(b), []) - .filter(file => file.endsWith('.ts')) - .map(file => path.join(cmdDir, file)); - - files.forEach(file => { - // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-dynamic-require - const { default: CommandClass } = require(file); - - commands.push({ - file, - command: new CommandClass(), - }); + return Object.entries(commands).map(([name, Cmd]) => { + return { + name, + command: new Cmd(), + }; }); - - return commands; } -export function load(cmd: string) { - let command = cmd; - let subcommand = ''; - if (cmd.includes(':')) { - [command, subcommand] = cmd.split(':'); - } - - const file = path.join(__dirname, '../cmds', command, subcommand); - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-dynamic-require - const { default: CommandClass } = require(file); - return new CommandClass(); - } catch (e) { +export function load(cmd: keyof typeof commands) { + if (!(cmd in commands)) { throw new Error('Command not found.'); } + + return new commands[cmd](); } export function listByCategory() { From bee05e6a558e672271bae2132b6726084d5ccb99 Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 14:11:58 -0500 Subject: [PATCH 09/21] fix: update GHA workflow --- action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index 681ffafc7..0395cddc8 100644 --- a/action.yml +++ b/action.yml @@ -11,8 +11,8 @@ inputs: runs: using: composite steps: - - name: Install rdme deps - run: npm install --production --silent + - name: Set up rdme + run: npm install --silent && npm run build shell: bash working-directory: ${{ github.action_path }} - name: Execute rdme command From d1bb83a6e09b764b47909b4d86e9ffb80104d484 Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 14:25:09 -0500 Subject: [PATCH 10/21] test(node12): point to right file path --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac7f479f5..f3d83d703 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: # rdme doesn't work on Node 12 but we just want to run this single test to make sure that # our "we don't support node 12" error is shown. - name: Run tests - run: npx jest __tests__/bin.test.js + run: npx jest __tests__/bin.test.ts action: name: GitHub Action Dry Run From f0d529dce561d5727322e808a15c0c65ed302885 Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 14:26:36 -0500 Subject: [PATCH 11/21] chore: small jest config fix Saw the following error: ``` jest-haste-map: Haste module naming collision: rdme The following files share their name; please adjust your hasteImpl: * <rootDir>/dist/package.json * <rootDir>/package.json ``` this is a fix for that, stolen from here: https://github.com/facebook/jest/issues/8114#issuecomment-475068766 --- jest.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/jest.config.js b/jest.config.js index e0fe48dae..2e68cedd0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,6 +14,7 @@ module.exports = { }, }, modulePaths: ['<rootDir>'], + modulePathIgnorePatterns: ['<rootDir>/dist/'], preset: 'ts-jest/presets/js-with-ts', roots: ['<rootDir>'], setupFiles: ['./__tests__/set-node-env'], From f139205717a9141c859abba4410e3acce64be2ec Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 14:40:43 -0500 Subject: [PATCH 12/21] chore: fix @actions/core import --- src/lib/logger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/logger.ts b/src/lib/logger.ts index d26cedc95..d5060bf56 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,7 +1,7 @@ import type { Options as OraOptions } from 'ora'; import type { Writable } from 'type-fest'; -import core from '@actions/core'; +import * as core from '@actions/core'; import chalk from 'chalk'; import config from 'config'; import debugModule from 'debug'; From 8662a2b612b64c5d7d75767301e2370da120df46 Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 14:41:51 -0500 Subject: [PATCH 13/21] chore: fix core import in another spot --- src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 03142fb51..e42f3fc33 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,5 @@ #! /usr/bin/env node -import core from '@actions/core'; +import * as core from '@actions/core'; import chalk from 'chalk'; import updateNotifier from 'update-notifier'; From 963e8fb58b34f15769f543016b3d1daa809a8769 Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 15:03:20 -0500 Subject: [PATCH 14/21] chore: simplify debug command --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 486d2cb9f..3063aca62 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ }, "scripts": { "build": "tsc", - "debug:bin": "node -r ts-node/register src/cli.ts", + "debug:bin": "ts-node src/cli.ts", "lint": "eslint . bin/rdme bin/set-version-output --ext .js,.ts", "lint-docs": "alex .", "prebuild": "rm -rf dist/", From c0bbb23741febf93c804de9ea5bfdda4ab16eb54 Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 15:06:50 -0500 Subject: [PATCH 15/21] chore: updating contributing guidelines --- CONTRIBUTING.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ccffc68c..2993cce2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,12 +2,19 @@ ## Running Shell Commands Locally 🐚 -To run test commands from within the repository, run your commands from the root of the repository and use `./bin/rdme` instead of `rdme` so it properly points to the command executable, like so: +To run test commands from within the repository, run the build and then run your commands from the root of the repository and use `./bin/rdme` instead of `rdme` so it properly points to the command executable, like so: ```sh +npm run build ./bin/rdme validate __tests__/__fixtures__/ref-oas/petstore.json ``` +If you need to debug commands quicker and re-building TS everytime is becoming cumbersome, you can use the debug command, like so: + +```sh +npm run debug:bin -- validate __tests__/__fixtures__/ref-oas/petstore.json +``` + ## Running GitHub Actions Locally 🐳 To run GitHub Actions locally, we'll be using [`act`](https://github.com/nektos/act) (make sure to read their [prerequisites list](https://github.com/nektos/act#necessary-prerequisites-for-running-act) and have that ready to go before installing `act`)! From 5ef9415dd7b7ae2ed85a8006046273b5f90c4356 Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 15:12:24 -0500 Subject: [PATCH 16/21] revert: use key variable rather than opts this was bugging me in the diff lol --- src/cmds/categories/create.ts | 2 +- src/cmds/categories/index.ts | 2 +- src/cmds/changelogs/index.ts | 2 +- src/cmds/changelogs/single.ts | 2 +- src/cmds/custompages/single.ts | 2 +- src/cmds/docs/edit.ts | 2 +- src/cmds/docs/index.ts | 2 +- src/cmds/docs/single.ts | 2 +- src/cmds/openapi.ts | 2 +- src/cmds/versions/create.ts | 2 +- src/cmds/versions/delete.ts | 2 +- src/cmds/versions/index.ts | 2 +- src/cmds/versions/update.ts | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/cmds/categories/create.ts b/src/cmds/categories/create.ts index cca42755f..41c3da4c6 100644 --- a/src/cmds/categories/create.ts +++ b/src/cmds/categories/create.ts @@ -67,7 +67,7 @@ export default class CategoriesCreateCommand extends Command { const { categoryType, title, key, version, preventDuplicates } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } diff --git a/src/cmds/categories/index.ts b/src/cmds/categories/index.ts index dface9d01..6775ea490 100644 --- a/src/cmds/categories/index.ts +++ b/src/cmds/categories/index.ts @@ -34,7 +34,7 @@ export default class CategoriesCommand extends Command { const { key, version } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } diff --git a/src/cmds/changelogs/index.ts b/src/cmds/changelogs/index.ts index 20c221f16..c40b7e388 100644 --- a/src/cmds/changelogs/index.ts +++ b/src/cmds/changelogs/index.ts @@ -47,7 +47,7 @@ export default class ChangelogsCommand extends Command { const { dryRun, folder, key } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } diff --git a/src/cmds/changelogs/single.ts b/src/cmds/changelogs/single.ts index 74a546142..68432cc1e 100644 --- a/src/cmds/changelogs/single.ts +++ b/src/cmds/changelogs/single.ts @@ -46,7 +46,7 @@ export default class SingleChangelogCommand extends Command { const { dryRun, filePath, key } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } diff --git a/src/cmds/custompages/single.ts b/src/cmds/custompages/single.ts index 81843e844..8c7ace6a2 100644 --- a/src/cmds/custompages/single.ts +++ b/src/cmds/custompages/single.ts @@ -45,7 +45,7 @@ export default class SingleCustomPageCommand extends Command { const { dryRun, filePath, key } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } diff --git a/src/cmds/docs/edit.ts b/src/cmds/docs/edit.ts index b8809d63d..0e95837f8 100644 --- a/src/cmds/docs/edit.ts +++ b/src/cmds/docs/edit.ts @@ -58,7 +58,7 @@ export default class EditDocsCommand extends Command { const { slug, key, version } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } diff --git a/src/cmds/docs/index.ts b/src/cmds/docs/index.ts index 200e435a7..97dde0c1d 100644 --- a/src/cmds/docs/index.ts +++ b/src/cmds/docs/index.ts @@ -53,7 +53,7 @@ export default class DocsCommand extends Command { const { dryRun, folder, key, version } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } diff --git a/src/cmds/docs/single.ts b/src/cmds/docs/single.ts index 964b23011..7b2483973 100644 --- a/src/cmds/docs/single.ts +++ b/src/cmds/docs/single.ts @@ -53,7 +53,7 @@ export default class SingleDocCommand extends Command { const { dryRun, filePath, key, version } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } diff --git a/src/cmds/openapi.ts b/src/cmds/openapi.ts index 4c06aa00c..0e0a330e8 100644 --- a/src/cmds/openapi.ts +++ b/src/cmds/openapi.ts @@ -69,7 +69,7 @@ export default class OpenAPICommand extends Command { const { key, id, spec, version, workingDirectory } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } diff --git a/src/cmds/versions/create.ts b/src/cmds/versions/create.ts index 8c33da9d4..2e2e6eb0f 100644 --- a/src/cmds/versions/create.ts +++ b/src/cmds/versions/create.ts @@ -73,7 +73,7 @@ export default class CreateVersionCommand extends Command { let versionList; const { key, version, codename, fork, main, beta, isPublic } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } diff --git a/src/cmds/versions/delete.ts b/src/cmds/versions/delete.ts index a18862a7e..3f130600d 100644 --- a/src/cmds/versions/delete.ts +++ b/src/cmds/versions/delete.ts @@ -37,7 +37,7 @@ export default class DeleteVersionCommand extends Command { const { key, version } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } diff --git a/src/cmds/versions/index.ts b/src/cmds/versions/index.ts index 18ac99912..14a5822e1 100644 --- a/src/cmds/versions/index.ts +++ b/src/cmds/versions/index.ts @@ -110,7 +110,7 @@ export default class VersionsCommand extends Command { const { key, version, raw } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } diff --git a/src/cmds/versions/update.ts b/src/cmds/versions/update.ts index 28f44dbe7..7e18c9ea7 100644 --- a/src/cmds/versions/update.ts +++ b/src/cmds/versions/update.ts @@ -68,7 +68,7 @@ export default class UpdateVersionCommand extends Command { const { key, version, codename, newVersion, main, beta, isPublic, deprecated } = opts; - if (!opts.key) { + if (!key) { return Promise.reject(new Error('No project API key provided. Please use `--key`.')); } From 0bd3c2c36a8bdb647d510b2b0263a71127179d9b Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 15:22:09 -0500 Subject: [PATCH 17/21] feat: stricter return types for commands --- src/cmds/docs/edit.ts | 2 +- src/lib/baseCommand.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cmds/docs/edit.ts b/src/cmds/docs/edit.ts index 0e95837f8..283167abe 100644 --- a/src/cmds/docs/edit.ts +++ b/src/cmds/docs/edit.ts @@ -53,7 +53,7 @@ export default class EditDocsCommand extends Command { ]; } - async run(opts: CommandOptions<Options>) { + async run(opts: CommandOptions<Options>): Promise<undefined> { super.run(opts); const { slug, key, version } = opts; diff --git a/src/lib/baseCommand.ts b/src/lib/baseCommand.ts index a3049ee34..a1f960fbf 100644 --- a/src/lib/baseCommand.ts +++ b/src/lib/baseCommand.ts @@ -37,7 +37,7 @@ export default class Command { defaultOption?: boolean; }[]; - async run(opts: CommandOptions<{}>): Promise<any> { + run(opts: CommandOptions<{}>): void | Promise<string> { debug(`command: ${this.command}`); debug(`opts: ${JSON.stringify(opts)}`); } From de29a7f8746eeda222ee36fc653b5da4c2b382f4 Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 15:28:18 -0500 Subject: [PATCH 18/21] fix: another strict return type --- src/lib/prompts.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 255860e86..c859f0100 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -1,4 +1,4 @@ -import type fetch from './fetch'; +import type { Response } from 'node-fetch'; import { prompt } from 'enquirer'; import parse from 'parse-link-header'; @@ -83,7 +83,7 @@ const updateOasPrompt = ( parsedDocs: ParsedDocs, currPage: number, totalPages: number, - getSpecs: (url: string) => Promise<ReturnType<typeof fetch>> + getSpecs: (url: string) => Promise<Response> ) => [ { type: 'select', @@ -126,8 +126,7 @@ export function createOasPrompt( specList: SpecList, parsedDocs: ParsedDocs, totalPages: number, - // @fixme There's a lot of funk with this type throughout the codebase. - getSpecs: any // (url: string) => Promise<ReturnType<typeof fetch>> + getSpecs: (url: string) => Promise<Response> ) { return [ { From 6d7b6148f152a6acbcb8902e681a25010be32e14 Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 15:30:54 -0500 Subject: [PATCH 19/21] fix: another stricter return type --- __tests__/cmds/open.test.ts | 2 +- src/cmds/open.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/cmds/open.test.ts b/__tests__/cmds/open.test.ts index f84e2c2c6..c96699f26 100644 --- a/__tests__/cmds/open.test.ts +++ b/__tests__/cmds/open.test.ts @@ -19,7 +19,7 @@ describe('rdme open', () => { const projectUrl = 'https://subdomain.readme.io'; - function mockOpen(url) { + function mockOpen(url: string) { expect(url).toBe(projectUrl); return Promise.resolve(); } diff --git a/src/cmds/open.ts b/src/cmds/open.ts index b17cf5ed4..048fc99fe 100644 --- a/src/cmds/open.ts +++ b/src/cmds/open.ts @@ -9,7 +9,7 @@ import configStore from '../lib/configstore'; import { debug } from '../lib/logger'; export type Options = { - mockOpen?: any; // @fixme this deserves a better type + mockOpen?: (url: string) => Promise<void>; }; export default class OpenCommand extends Command { From c27a59e7f7c35009061e49f93833166e93f317f2 Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 16:12:53 -0500 Subject: [PATCH 20/21] fix: use command-line-usage types --- src/lib/baseCommand.ts | 10 +++------- src/lib/help.ts | 13 +++---------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/lib/baseCommand.ts b/src/lib/baseCommand.ts index a1f960fbf..cb000678b 100644 --- a/src/lib/baseCommand.ts +++ b/src/lib/baseCommand.ts @@ -1,3 +1,5 @@ +import type { OptionDefinition } from 'command-line-usage'; + import { debug } from './logger'; export type CommandOptions<T> = T & { @@ -29,13 +31,7 @@ export default class Command { hiddenArgs: string[] = []; - args: { - name: string; - alias?: string; - type: BooleanConstructor | StringConstructor; - description?: string; - defaultOption?: boolean; - }[]; + args: OptionDefinition[]; run(opts: CommandOptions<{}>): void | Promise<string> { debug(`command: ${this.command}`); diff --git a/src/lib/help.ts b/src/lib/help.ts index 52675f4e8..c85f840cb 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -1,4 +1,5 @@ import type Command from './baseCommand'; +import type { Section } from 'command-line-usage'; import chalk from 'chalk'; import usage from 'command-line-usage'; @@ -53,16 +54,8 @@ const owlbert = () => { raw?: boolean; }[] */ -type Usage = { - content?: any; // TODO give this a better type - header?: string; - hide?: string[]; - optionList?: Command['args']; - raw?: boolean; -}[]; - function commandUsage(cmd: Command) { - const helpContent: Usage = [ + const helpContent: Section[] = [ { content: cmd.description, raw: true, @@ -97,7 +90,7 @@ function commandUsage(cmd: Command) { } async function globalUsage(args: Command['args']) { - const helpContent: Usage = [ + const helpContent: Section[] = [ { content: owlbert(), raw: true, From 624a65f453ff94a9fe8d950b55fb48640c4e3051 Mon Sep 17 00:00:00 2001 From: Kanad Gupta <kgupta@umn.edu> Date: Tue, 9 Aug 2022 16:13:06 -0500 Subject: [PATCH 21/21] fix: more getSpecs types fixes --- __tests__/lib/prompts.test.ts | 16 ++++++++++------ src/lib/prompts.ts | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/__tests__/lib/prompts.test.ts b/__tests__/lib/prompts.test.ts index 0a0fe50d2..a9fdf10f0 100644 --- a/__tests__/lib/prompts.test.ts +++ b/__tests__/lib/prompts.test.ts @@ -1,3 +1,5 @@ +import type { Response } from 'node-fetch'; + import Enquirer from 'enquirer'; import * as promptHandler from '../../src/lib/prompts'; @@ -23,12 +25,14 @@ const specList = [ ]; const getSpecs = () => { - return [ - { - _id: 'spec3', - title: 'spec3_title', - }, - ]; + return { + body: [ + { + _id: 'spec3', + title: 'spec3_title', + }, + ], + } as unknown as Promise<Response>; }; describe('prompt test bed', () => { diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index c859f0100..0b81b391f 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -126,7 +126,7 @@ export function createOasPrompt( specList: SpecList, parsedDocs: ParsedDocs, totalPages: number, - getSpecs: (url: string) => Promise<Response> + getSpecs: ((url: string) => Promise<Response>) | null ) { return [ {