From 8714967d8fb48e444bf3ecbf7a35f895d2ef9c96 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Fri, 19 Aug 2022 10:34:45 -0500 Subject: [PATCH] refactor: migrate from `enquirer` to `prompts` (#570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: throw errors if version POST fails found an interesting bug... if someone creates a version but the request is invalid for any reason, we just swallow the request and call it a day :grimacing: This slightly refactors the logic so we actually digest the request and then use the data from its response, rather than the raw input from the user. * chore: small refactor to align patterns * feat: first pass at migration version selection ... over to prompts library this also entailed removing some unnecessary types * refactor: wrap prompts in exit handler * temp: why isn't this working 🧐 I've isolated a test here to demonstrate some weirdness going on... basically the generatePrompts() function isn't being called from this test at all, but it works fine when I run the command locally * Revert "temp: why isn't this working 🧐" This reverts commit 9c529f6c61e72ee75a3c6c98ed0a2142b852a6c9. * fix: properly add onState function Since `questions` can be either an object or an array, we have to be a little smarter about how we tack on the `onState` function * test: stricter test for testing version prompt I'm so mad lol I hate enquirer (also tysm @erunion for finding this stupid mock that was ruining everything) * test: update version selection tests * feat: migrate oas selection to prompts lib I continue to be so mad at how nice prompts is * test: more resilient version selection tests * feat: API definition id in description * chore: small logic removal our openapi test coverage is officially :100: i literally have no idea how to even get to this point in the logic * refactor: better return types I think this is better? VSCode seems to flag stuff better this way... * refactor: consolidate version opts * docs: add JSDoc for getVersionArg * refactor(version): consolidate opt types, stricter jeez, this one was wild. i noticed we had a lot of duplicate type definitions, so i consolidated all of them in the `versions:create` command. easy enough right? well I noticed we had union boolean/string definitions for `isPublic`, `main`, etc. even though the opts were only being created as strings—turns out we only had them defined as booleans because of our paltry tests. I typed those opts as string enums and separated them out into which command they're being used for so it's a little bit more clear. * refactor: migrate `createVersionPrompt` to prompts * test: far better tests for prompts honestly I don't think these were working properly before at all... but now they are! * test(version:create): far better tests * fix: correct type this was holdover from a bad `createVersionPrompt` test. this should always be a boolean. * chore: clearer question * fix: document some missing parameters Also as part of this, I'm moving the `newVersion` param to only live in the `VersionUpdateOptions` type since that's technically only an opt for that command. Also doing some slight rearranging of the opts in our object destructuring so it aligns with the order of the opt definitions. * test(version:update): overhauling test suite god these commands are a mess * chore: remove redundant catch * test(versions:create): more coverage * fix(createVersionPrompt): validation * test: add TODO * chore(deps): rip out enquirer :put_litter_in_its_place: * feat(login): use prompts as part of this, i swapped out read for enquirer and isemail for validator * chore: shorten debug command * feat: cuter goodbye :wave: * chore: PR feedback Co-Authored-By: Jon Ursenbach * docs: add command attribute descriptions Co-authored-by: Jon Ursenbach --- CONTRIBUTING.md | 2 +- .../cmds/__snapshots__/login.test.ts.snap | 2 + __tests__/cmds/login.test.ts | 37 ++++- __tests__/cmds/openapi.test.ts | 79 ++++++++-- __tests__/cmds/versions/create.test.ts | 76 +++++++--- __tests__/cmds/versions/update.test.ts | 98 ++++++++++--- __tests__/lib/prompts.test.ts | 131 +++++------------ package-lock.json | 125 ++++------------ package.json | 10 +- src/cmds/login.ts | 58 +++++--- src/cmds/openapi.ts | 9 +- src/cmds/versions/create.ts | 45 ++---- src/cmds/versions/delete.ts | 4 +- src/cmds/versions/update.ts | 50 ++----- src/lib/baseCommand.ts | 68 +++++++++ src/lib/prepareOas.ts | 4 +- src/lib/promptWrapper.ts | 39 +++++ src/lib/prompts.ts | 137 ++++++++---------- src/lib/versionSelect.ts | 22 +-- 19 files changed, 546 insertions(+), 450 deletions(-) create mode 100644 src/lib/promptWrapper.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2993cce2d..ac88f86f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ npm run build 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 +npm run debug -- validate __tests__/__fixtures__/ref-oas/petstore.json ``` ## Running GitHub Actions Locally 🐳 diff --git a/__tests__/cmds/__snapshots__/login.test.ts.snap b/__tests__/cmds/__snapshots__/login.test.ts.snap index e39ee3c50..28e59eece 100644 --- a/__tests__/cmds/__snapshots__/login.test.ts.snap +++ b/__tests__/cmds/__snapshots__/login.test.ts.snap @@ -2,4 +2,6 @@ exports[`rdme login should post to /login on the API 1`] = `"Successfully logged in as user@example.com to the subdomain project."`; +exports[`rdme login should post to /login on the API if passing in project via opt 1`] = `"Successfully logged in as user@example.com to the subdomain project."`; + exports[`rdme login should send 2fa token if provided 1`] = `"Successfully logged in as user@example.com to the subdomain project."`; diff --git a/__tests__/cmds/login.test.ts b/__tests__/cmds/login.test.ts index 3a9715711..4cb8ff4e9 100644 --- a/__tests__/cmds/login.test.ts +++ b/__tests__/cmds/login.test.ts @@ -1,4 +1,5 @@ import nock from 'nock'; +import prompts from 'prompts'; import Command from '../../src/cmds/login'; import APIError from '../../src/lib/apiError'; @@ -19,23 +20,40 @@ describe('rdme login', () => { afterEach(() => configStore.clear()); it('should error if no project provided', () => { + prompts.inject([email, password]); return expect(cmd.run({})).rejects.toStrictEqual( new Error('No project subdomain provided. Please use `--project`.') ); }); it('should error if email is invalid', () => { - return expect(cmd.run({ project: 'subdomain', email: 'this-is-not-an-email' })).rejects.toStrictEqual( - new Error('You must provide a valid email address.') - ); + prompts.inject(['this-is-not-an-email', password, project]); + return expect(cmd.run({})).rejects.toStrictEqual(new Error('You must provide a valid email address.')); }); it('should post to /login on the API', async () => { + prompts.inject([email, password, project]); + const apiKey = 'abcdefg'; + + const mock = getAPIMock().post('/api/v1/login').reply(200, { apiKey }); + + await expect(cmd.run({})).resolves.toMatchSnapshot(); + + mock.done(); + + expect(configStore.get('apiKey')).toBe(apiKey); + expect(configStore.get('email')).toBe(email); + expect(configStore.get('project')).toBe(project); + configStore.clear(); + }); + + it('should post to /login on the API if passing in project via opt', async () => { + prompts.inject([email, password]); const apiKey = 'abcdefg'; - const mock = getAPIMock().post('/api/v1/login', { email, password, project }).reply(200, { apiKey }); + const mock = getAPIMock().post('/api/v1/login').reply(200, { apiKey }); - await expect(cmd.run({ email, password, project })).resolves.toMatchSnapshot(); + await expect(cmd.run({ project })).resolves.toMatchSnapshot(); mock.done(); @@ -46,6 +64,7 @@ describe('rdme login', () => { }); it('should error if invalid credentials are given', async () => { + prompts.inject([email, password, project]); const errorResponse = { error: 'LOGIN_INVALID', message: 'Either your email address or password is incorrect', @@ -55,11 +74,12 @@ describe('rdme login', () => { 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)); + await expect(cmd.run({})).rejects.toStrictEqual(new APIError(errorResponse)); mock.done(); }); it('should error if missing two factor token', async () => { + prompts.inject([email, password, project]); const errorResponse = { error: 'LOGIN_TWOFACTOR', message: 'You must provide a two-factor code', @@ -69,16 +89,17 @@ describe('rdme login', () => { 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)); + await expect(cmd.run({})).rejects.toStrictEqual(new APIError(errorResponse)); mock.done(); }); it('should send 2fa token if provided', async () => { const token = '123456'; + prompts.inject([email, password, project, token]); 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(); + await expect(cmd.run({ '2fa': true })).resolves.toMatchSnapshot(); mock.done(); }); diff --git a/__tests__/cmds/openapi.test.ts b/__tests__/cmds/openapi.test.ts index 96acc5671..3bb7371ef 100644 --- a/__tests__/cmds/openapi.test.ts +++ b/__tests__/cmds/openapi.test.ts @@ -2,15 +2,13 @@ import chalk from 'chalk'; import config from 'config'; import nock from 'nock'; +import prompts from 'prompts'; 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 openapi = new OpenAPICommand(); const swagger = new SwaggerCommand(); @@ -43,8 +41,6 @@ const successfulUpdate = (specPath, specType = 'OpenAPI') => const testWorkingDir = process.cwd(); -jest.mock('../../src/lib/prompts'); - const getCommandOutput = () => { return [consoleWarnSpy.mock.calls.join('\n\n'), consoleInfoSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n'); }; @@ -142,7 +138,36 @@ describe('rdme openapi', () => { return mock.done(); }); - it.todo('should test spec selection prompts'); + it('should create a new spec via prompts', async () => { + prompts.inject(['create']); + const registryUUID = getRandomRegistryId(); + + const mock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, [{ version }]) + .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) + .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) + .get('/api/v1/api-specification') + .basicAuth({ user: key }) + .reply(200, [{ _id: 'spec1', title: 'spec1_title' }]) + .post('/api/v1/api-specification', { registryUUID }) + .delayConnection(1000) + .basicAuth({ user: key }) + .reply(201, { _id: 1 }, { location: exampleRefLocation }); + + const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; + + await expect( + openapi.run({ + key, + version, + spec, + }) + ).resolves.toBe(successfulUpload(spec)); + + return mock.done(); + }); it('should bundle and upload the expected content', async () => { let requestBody; @@ -290,6 +315,41 @@ describe('rdme openapi', () => { return mock.done(); }); + + it('should update a spec via prompts', async () => { + prompts.inject(['update', 'spec2']); + const registryUUID = getRandomRegistryId(); + + const mock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, [{ version }]) + .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) + .reply(201, { registryUUID, spec: { openapi: '3.0.0' } }) + .get('/api/v1/api-specification') + .basicAuth({ user: key }) + .reply(200, [ + { _id: 'spec1', title: 'spec1_title' }, + { _id: 'spec2', title: 'spec2_title' }, + ]) + .put('/api/v1/api-specification/spec2', { registryUUID }) + .delayConnection(1000) + .basicAuth({ user: key }) + .reply(201, { _id: 1 }, { location: exampleRefLocation }); + + const spec = './__tests__/__fixtures__/ref-oas/petstore.json'; + + await expect( + openapi.run({ + key, + version, + spec, + }) + ).resolves.toBe(successfulUpdate(spec)); + return mock.done(); + }); + + it.todo('should paginate to next and previous pages of specs'); }); describe('versioning', () => { @@ -429,10 +489,7 @@ describe('rdme openapi', () => { }); it('should request a version list if version is not found', async () => { - promptHandler.generatePrompts.mockResolvedValue({ - option: 'create', - newVersion: '1.0.1', - }); + prompts.inject(['create', '1.0.1']); const registryUUID = getRandomRegistryId(); @@ -440,7 +497,7 @@ describe('rdme openapi', () => { .get('/api/v1/version') .basicAuth({ user: key }) .reply(200, [{ version: '1.0.0' }]) - .post('/api/v1/version') + .post('/api/v1/version', { from: '1.0.0', version: '1.0.1', is_stable: false }) .basicAuth({ user: key }) .reply(200, { from: '1.0.0', version: '1.0.1' }) .post('/api/v1/api-registry', body => body.match('form-data; name="spec"')) diff --git a/__tests__/cmds/versions/create.test.ts b/__tests__/cmds/versions/create.test.ts index 82330d589..9f8d1af33 100644 --- a/__tests__/cmds/versions/create.test.ts +++ b/__tests__/cmds/versions/create.test.ts @@ -1,17 +1,13 @@ import nock from 'nock'; +import prompts from 'prompts'; 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', () => { @@ -25,32 +21,72 @@ describe('rdme versions:create', () => { ); }); + it('should error if no version provided', () => { + return expect(createVersion.run({ key })).rejects.toStrictEqual( + new Error('Please specify a semantic version. See `rdme help versions:create` for help.') + ); + }); + + it('should error if invaild version provided', () => { + return expect(createVersion.run({ key, version: 'test' })).rejects.toStrictEqual( + new Error('Please specify a semantic version. See `rdme help versions:create` for help.') + ); + }); + it('should create a specific version', async () => { - promptHandler.createVersionPrompt.mockResolvedValue({ - is_stable: true, - is_beta: false, - from: '1.0.0', - }); + prompts.inject([version, false, true, true]); + const newVersion = '1.0.1'; const mockRequest = getAPIMock() .get('/api/v1/version') .basicAuth({ user: key }) - .reply(200, [{ version }, { version }]) - .post('/api/v1/version') + .reply(200, [{ version }, { version: '1.1.0' }]) + .post('/api/v1/version', { + version: newVersion, + codename: '', + is_stable: false, + is_beta: true, + from: '1.0.0', + is_hidden: false, + }) + .basicAuth({ user: key }) + .reply(201, { version: newVersion }); + + await expect(createVersion.run({ key, version: newVersion })).resolves.toBe( + `Version ${newVersion} created successfully.` + ); + mockRequest.done(); + }); + + it('should create a specific version with options', async () => { + const newVersion = '1.0.1'; + + const mockRequest = getAPIMock() + .post('/api/v1/version', { + version: newVersion, + codename: 'test', + from: '1.0.0', + is_hidden: false, + }) .basicAuth({ user: key }) - .reply(201, { version }); + .reply(201, { version: newVersion }); + + await expect( + createVersion.run({ + key, + version: newVersion, + fork: version, + beta: 'false', + main: 'false', + codename: 'test', + isPublic: 'true', + }) + ).resolves.toBe(`Version ${newVersion} created successfully.`); - 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', diff --git a/__tests__/cmds/versions/update.test.ts b/__tests__/cmds/versions/update.test.ts index 91bbfcb3a..2d55fa136 100644 --- a/__tests__/cmds/versions/update.test.ts +++ b/__tests__/cmds/versions/update.test.ts @@ -1,17 +1,13 @@ import nock from 'nock'; +import prompts from 'prompts'; 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', () => { @@ -26,32 +22,86 @@ describe('rdme versions:update', () => { }); it('should update a specific version object', async () => { - promptHandler.createVersionPrompt.mockResolvedValue({ + const versionToChange = '1.1.0'; + const renamedVersion = '1.1.0-update'; + prompts.inject([versionToChange, renamedVersion, false, true, true, false]); + + const updatedVersionObject = { + codename: '', + version: renamedVersion, is_stable: false, - is_beta: false, - is_deprecated: true, - }); + is_beta: true, + is_deprecated: false, + is_hidden: false, + }; const mockRequest = getAPIMock() - .get(`/api/v1/version/${version}`) + .get('/api/v1/version') + .basicAuth({ user: key }) + .reply(200, [{ version }, { version: versionToChange }]) + .get(`/api/v1/version/${versionToChange}`) .basicAuth({ user: key }) .reply(200, { version }) - .put(`/api/v1/version/${version}`) + .put(`/api/v1/version/${versionToChange}`, updatedVersionObject) .basicAuth({ user: key }) - .reply(201, { version }) - .get(`/api/v1/version/${version}`) + .reply(201, updatedVersionObject); + + await expect(updateVersion.run({ key })).resolves.toBe(`Version ${versionToChange} updated successfully.`); + mockRequest.done(); + }); + + it('should update a specific version object using flags', async () => { + const versionToChange = '1.1.0'; + const renamedVersion = '1.1.0-update'; + + const updatedVersionObject = { + codename: 'updated-test', + version: renamedVersion, + is_beta: true, + is_hidden: false, + }; + + const mockRequest = getAPIMock() + .get(`/api/v1/version/${versionToChange}`) .basicAuth({ user: key }) - .reply(200, { version }); + .reply(200, { version: versionToChange }) + .get(`/api/v1/version/${versionToChange}`) + .basicAuth({ user: key }) + .reply(200, { version: versionToChange }) + .put(`/api/v1/version/${versionToChange}`, updatedVersionObject) + .basicAuth({ user: key }) + .reply(201, updatedVersionObject); - await expect(updateVersion.run({ key, version })).resolves.toBe('Version 1.0.0 updated successfully.'); + await expect( + updateVersion.run({ + key, + version: versionToChange, + newVersion: renamedVersion, + beta: 'true', + main: 'false', + codename: 'updated-test', + isPublic: 'true', + }) + ).resolves.toBe(`Version ${versionToChange} updated successfully.`); mockRequest.done(); }); + // Note: this test is a bit bizarre since the flag management + // in our version commands is really confusing to follow. + // I'm not sure if it's technically possible to demote a stable version + // with our current prompt/flag management flow, but that's not + // really the purpose of this test so I think it's fine as is. it('should catch any put request errors', async () => { - promptHandler.createVersionPrompt.mockResolvedValue({ - is_stable: false, - is_beta: false, - }); + const renamedVersion = '1.0.0-update'; + + const updatedVersionObject = { + codename: '', + version: renamedVersion, + is_beta: true, + is_hidden: true, + }; + + prompts.inject([renamedVersion, true]); const errorResponse = { error: 'VERSION_CANT_DEMOTE_STABLE', @@ -64,14 +114,14 @@ describe('rdme versions:update', () => { .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 }); + .reply(200, { version }) + .put(`/api/v1/version/${version}`, updatedVersionObject) + .basicAuth({ user: key }) + .reply(400, errorResponse); - await expect(updateVersion.run({ key, version })).rejects.toStrictEqual(new APIError(errorResponse)); + await expect(updateVersion.run({ key, version, main: 'false' })).rejects.toStrictEqual(new APIError(errorResponse)); mockRequest.done(); }); }); diff --git a/__tests__/lib/prompts.test.ts b/__tests__/lib/prompts.test.ts index a9fdf10f0..9fcfc2bae 100644 --- a/__tests__/lib/prompts.test.ts +++ b/__tests__/lib/prompts.test.ts @@ -1,8 +1,10 @@ +import type { VersionCreateOptions } from '../../src/cmds/versions/create'; import type { Response } from 'node-fetch'; -import Enquirer from 'enquirer'; +import prompts from 'prompts'; import * as promptHandler from '../../src/lib/prompts'; +import promptTerminal from '../../src/lib/promptWrapper'; const versionlist = [ { @@ -36,58 +38,34 @@ const getSpecs = () => { }; describe('prompt test bed', () => { - let enquirer; - - beforeEach(() => { - enquirer = new Enquirer({ show: false }); - }); - describe('generatePrompts()', () => { - it('should not allow selection of version if chosen to create new version', async () => { - enquirer.on('prompt', async prompt => { - // eslint-disable-next-line default-case - switch (prompt.name) { - case 'option': - await prompt.keypress(null, { name: 'down' }); - await prompt.submit(); - break; - - case 'versionSelection': - // eslint-disable-next-line jest/no-conditional-expect - await expect(prompt.skip()).resolves.toBe(true); - break; - - case 'newVersion': - // eslint-disable-next-line no-param-reassign - prompt.value = '1.2.1'; - await prompt.submit(); - } - }); + it('should return an update option if selected', async () => { + prompts.inject(['update', '2']); - await enquirer.prompt(promptHandler.generatePrompts(versionlist)); + const answer = await promptTerminal(promptHandler.generatePrompts(versionlist)); + expect(answer).toStrictEqual({ option: 'update', versionSelection: '2' }); }); it('should return a create option if selected', async () => { - enquirer.on('prompt', async prompt => { - await prompt.keypress(null, { name: 'down' }); - await prompt.keypress(null, { name: 'up' }); - await prompt.submit(); - await prompt.submit(); - }); + prompts.inject(['create', '1.1']); + + const answer = await promptTerminal(promptHandler.generatePrompts(versionlist)); + expect(answer).toStrictEqual({ newVersion: '1.1', option: 'create' }); + }); + + it('should return an update option if selectOnly=true', async () => { + prompts.inject(['2']); - const answer = await enquirer.prompt(promptHandler.generatePrompts(versionlist)); - expect(answer.versionSelection).toBe('1'); + const answer = await promptTerminal(promptHandler.generatePrompts(versionlist, true)); + expect(answer).toStrictEqual({ versionSelection: '2' }); }); }); describe('createOasPrompt()', () => { it('should return a create option if selected', async () => { - enquirer.on('prompt', async prompt => { - await prompt.keypress(null, { name: 'down' }); - await prompt.submit(); - }); + prompts.inject(['create']); - const answer = await enquirer.prompt( + const answer = await promptTerminal( promptHandler.createOasPrompt( [ { @@ -101,19 +79,11 @@ describe('prompt test bed', () => { ) ); - expect(answer.option).toBe('create'); + expect(answer).toStrictEqual({ option: 'create' }); }); it('should return specId if user chooses to update file', async () => { - jest.mock('enquirer'); - enquirer.on('prompt', async prompt => { - await prompt.keypress(null, { name: 'down' }); - await prompt.keypress(null, { name: 'up' }); - await prompt.submit(); - }); - - enquirer.prompt = jest.fn(); - enquirer.prompt.mockReturnValue('spec1'); + prompts.inject(['update', 'spec1']); const parsedDocs = { next: { @@ -126,56 +96,33 @@ describe('prompt test bed', () => { }, }; - const answer = await enquirer.prompt(promptHandler.createOasPrompt(specList, parsedDocs, 1, getSpecs)); + const answer = await promptTerminal(promptHandler.createOasPrompt(specList, parsedDocs, 1, getSpecs)); - expect(answer).toBe('spec1'); + expect(answer).toStrictEqual({ option: 'spec1' }); }); }); describe('createVersionPrompt()', () => { - it('should allow user to choose a fork if flag is not passed', async () => { - const opts = { main: true, beta: true }; - - enquirer.on('prompt', async prompt => { - await prompt.keypress(null, { name: 'down' }); - await prompt.keypress(null, { name: 'up' }); - await prompt.submit(); - if (prompt.name === 'newVersion') { - // eslint-disable-next-line no-param-reassign - prompt.value = '1.2.1'; - await prompt.submit(); - } - }); - const answer = await enquirer.prompt(promptHandler.createVersionPrompt(versionlist, opts)); - expect(answer.is_hidden).toBe(false); - expect(answer.from).toBe('1'); + it('should allow user to choose a fork if flag is not passed (creating version)', async () => { + const opts = { newVersion: '1.2.1' } as VersionCreateOptions; + + prompts.inject(['1', true, true]); + + const answer = await promptTerminal(promptHandler.createVersionPrompt(versionlist, opts)); + expect(answer).toStrictEqual({ from: '1', is_stable: true, is_beta: true }); }); - it('should skip fork prompt if value passed', async () => { - const opts = { - version: '1', - codename: 'test', - fork: '1.0.0', - main: false, - beta: true, - isPublic: false, - }; + it('should skip fork prompt if value passed (updating version)', async () => { + prompts.inject(['1.2.1', false, true, true, false]); - enquirer.on('prompt', async prompt => { - if (prompt.name === 'newVersion') { - // eslint-disable-next-line no-param-reassign - prompt.value = '1.2.1'; - await prompt.submit(); - } - await prompt.submit(); - await prompt.submit(); - await prompt.submit(); + const answer = await promptTerminal(promptHandler.createVersionPrompt(versionlist, {}, { is_stable: false })); + expect(answer).toStrictEqual({ + newVersion: '1.2.1', + is_stable: false, + is_beta: true, + is_hidden: true, + is_deprecated: false, }); - 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/package-lock.json b/package-lock.json index 2fa0ceb26..5d1f70076 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,11 +19,9 @@ "configstore": "^5.0.0", "debug": "^4.3.3", "editor": "^1.0.0", - "enquirer": "^2.3.0", "form-data": "^4.0.0", "gray-matter": "^4.0.1", "ignore": "^5.2.0", - "isemail": "^3.1.3", "mime-types": "^2.1.35", "node-fetch": "^2.6.1", "oas-normalize": "^7.0.0", @@ -31,10 +29,10 @@ "ora": "^5.4.1", "parse-link-header": "^2.0.0", "prompts": "^2.4.2", - "read": "^1.0.7", "semver": "^7.0.0", "tmp-promise": "^3.0.2", - "update-notifier": "^5.1.0" + "update-notifier": "^5.1.0", + "validator": "^13.7.0" }, "bin": { "rdme": "bin/rdme" @@ -52,10 +50,10 @@ "@types/npmcli__ci-detect": "^2.0.0", "@types/parse-link-header": "^2.0.0", "@types/prompts": "^2.0.14", - "@types/read": "^0.0.29", "@types/semver": "^7.3.10", "@types/tmp": "^0.2.3", "@types/update-notifier": "^6.0.1", + "@types/validator": "^13.7.5", "alex": "^10.0.0", "eslint": "^8.21.0", "jest": "^28.1.1", @@ -1916,12 +1914,6 @@ "@types/node": "*" } }, - "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", @@ -2114,6 +2106,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@types/validator": { + "version": "13.7.5", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.5.tgz", + "integrity": "sha512-9rQHeAqz6Jw3gDhttkmWetoriW5FPbxylv/6h6mXtaj2NKRcOvOmvfcswVdLVpbuy10NrO486K3lCoLgoIhiIA==", + "dev": true + }, "node_modules/@types/yargs": { "version": "17.0.10", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz", @@ -2401,14 +2399,6 @@ "string-width": "^4.1.0" } }, - "node_modules/ansi-colors": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", - "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3910,17 +3900,6 @@ "node": ">=10.13.0" } }, - "node_modules/enquirer": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", - "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", - "dependencies": { - "ansi-colors": "^4.1.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -6467,17 +6446,6 @@ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true }, - "node_modules/isemail": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/isemail/-/isemail-3.2.0.tgz", - "integrity": "sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==", - "dependencies": { - "punycode": "2.x.x" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9238,11 +9206,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -10291,17 +10254,6 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true }, - "node_modules/read": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", - "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", - "dependencies": { - "mute-stream": "~0.0.4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -12434,6 +12386,14 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/validator": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", + "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vfile": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.2.tgz", @@ -14344,12 +14304,6 @@ "@types/node": "*" } }, - "@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", @@ -14484,6 +14438,12 @@ } } }, + "@types/validator": { + "version": "13.7.5", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.5.tgz", + "integrity": "sha512-9rQHeAqz6Jw3gDhttkmWetoriW5FPbxylv/6h6mXtaj2NKRcOvOmvfcswVdLVpbuy10NrO486K3lCoLgoIhiIA==", + "dev": true + }, "@types/yargs": { "version": "17.0.10", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.10.tgz", @@ -14661,11 +14621,6 @@ "string-width": "^4.1.0" } }, - "ansi-colors": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", - "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==" - }, "ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -15739,14 +15694,6 @@ "tapable": "^2.2.0" } }, - "enquirer": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", - "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", - "requires": { - "ansi-colors": "^4.1.1" - } - }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -17553,14 +17500,6 @@ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true }, - "isemail": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/isemail/-/isemail-3.2.0.tgz", - "integrity": "sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==", - "requires": { - "punycode": "2.x.x" - } - }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -19545,11 +19484,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" - }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -20341,14 +20275,6 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true }, - "read": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", - "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", - "requires": { - "mute-stream": "~0.0.4" - } - }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -21950,6 +21876,11 @@ "spdx-expression-parse": "^3.0.0" } }, + "validator": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", + "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==" + }, "vfile": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.2.tgz", diff --git a/package.json b/package.json index 6315f8a90..be4d8a98c 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,9 @@ "configstore": "^5.0.0", "debug": "^4.3.3", "editor": "^1.0.0", - "enquirer": "^2.3.0", "form-data": "^4.0.0", "gray-matter": "^4.0.1", "ignore": "^5.2.0", - "isemail": "^3.1.3", "mime-types": "^2.1.35", "node-fetch": "^2.6.1", "oas-normalize": "^7.0.0", @@ -56,10 +54,10 @@ "ora": "^5.4.1", "parse-link-header": "^2.0.0", "prompts": "^2.4.2", - "read": "^1.0.7", "semver": "^7.0.0", "tmp-promise": "^3.0.2", - "update-notifier": "^5.1.0" + "update-notifier": "^5.1.0", + "validator": "^13.7.0" }, "devDependencies": { "@readme/eslint-config": "^10.0.0", @@ -74,10 +72,10 @@ "@types/npmcli__ci-detect": "^2.0.0", "@types/parse-link-header": "^2.0.0", "@types/prompts": "^2.0.14", - "@types/read": "^0.0.29", "@types/semver": "^7.3.10", "@types/tmp": "^0.2.3", "@types/update-notifier": "^6.0.1", + "@types/validator": "^13.7.5", "alex": "^10.0.0", "eslint": "^8.21.0", "jest": "^28.1.1", @@ -91,7 +89,7 @@ }, "scripts": { "build": "tsc", - "debug:bin": "ts-node src/cli.ts", + "debug": "ts-node src/cli.ts", "lint": "eslint . bin/rdme bin/set-version-output --ext .js,.ts", "lint-docs": "alex . && npm run prettier", "prebuild": "rm -rf dist/", diff --git a/src/cmds/login.ts b/src/cmds/login.ts index 3ef15f13d..17bebb69b 100644 --- a/src/cmds/login.ts +++ b/src/cmds/login.ts @@ -1,22 +1,16 @@ 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 isEmail from 'validator/lib/isEmail'; 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'; +import promptTerminal from '../lib/promptWrapper'; export type Options = { - '2fa'?: string; + '2fa'?: boolean; email?: string; password?: string; project?: string; @@ -50,23 +44,39 @@ export default class LoginCommand extends Command { async run(opts: CommandOptions) { super.run(opts); - let { email, password, project, token } = opts; + let { project } = opts; - /* istanbul ignore next */ - async function getCredentials() { - return { - email: await read({ prompt: 'Email:', default: configStore.get('email') }), - password: await read({ prompt: 'Password:', silent: true }), - project: opts.project || (await read({ prompt: 'Project subdomain:', default: configStore.get('project') })), - token: opts['2fa'] && (await read({ prompt: '2fa token:' })), - }; - } + const promptResults = await promptTerminal([ + { + type: 'text', + name: 'email', + message: 'What is your email address?', + initial: configStore.get('email'), + validate(val) { + return isEmail(val) ? true : 'Please provide a valid email address.'; + }, + }, + { + type: 'invisible', + name: 'password', + message: 'What is your password?', + }, + { + type: opts.project ? null : 'text', + name: 'project', + message: 'What project are you logging into?', + initial: configStore.get('project'), + }, + { + type: opts['2fa'] ? 'text' : null, + name: 'token', + message: 'What is your 2FA token?', + }, + ]); - // We only want to prompt for input outside of the test environment - /* istanbul ignore next */ - if (!testing) { - ({ email, password, project, token } = await getCredentials()); - } + const { email, password, token } = promptResults; + + if (promptResults.project) project = promptResults.project; if (!project) { return Promise.reject(new Error('No project subdomain provided. Please use `--project`.')); diff --git a/src/cmds/openapi.ts b/src/cmds/openapi.ts index b43cd4642..d9d810fc6 100644 --- a/src/cmds/openapi.ts +++ b/src/cmds/openapi.ts @@ -3,7 +3,6 @@ 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'; @@ -13,6 +12,7 @@ import fetch, { cleanHeaders, handleRes } from '../lib/fetch'; import { oraOptions } from '../lib/logger'; import prepareOas from '../lib/prepareOas'; import * as promptHandler from '../lib/prompts'; +import promptTerminal from '../lib/promptWrapper'; import streamSpecToRegistry from '../lib/streamSpecToRegistry'; import { getProjectVersion } from '../lib/versionSelect'; @@ -227,12 +227,13 @@ export default class OpenAPICommand extends Command { Command.debug(`api settings list response payload: ${JSON.stringify(apiSettingsBody)}`); if (!apiSettingsBody.length) return createSpec(); - const { option }: { option: 'create' | 'update' } = await prompt( + // @todo: figure out how to add a stricter type here, see: + // https://github.com/readmeio/rdme/pull/570#discussion_r949715913 + const { option } = await promptTerminal( promptHandler.createOasPrompt(apiSettingsBody, parsedDocs, totalPages, getSpecs) ); - Command.debug(`selection result: ${option}`); - if (!option) return null; + return option === 'create' ? createSpec() : updateSpec(option); } diff --git a/src/cmds/versions/create.ts b/src/cmds/versions/create.ts index 2ca2cf9a3..447dee35d 100644 --- a/src/cmds/versions/create.ts +++ b/src/cmds/versions/create.ts @@ -1,20 +1,21 @@ 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'; import fetch, { cleanHeaders, handleRes } from '../../lib/fetch'; import * as promptHandler from '../../lib/prompts'; +import promptTerminal from '../../lib/promptWrapper'; -export type Options = { - beta?: string | boolean; +export type VersionCreateOptions = { fork?: string } & VersionBaseOptions; + +export type VersionBaseOptions = { + beta?: 'true' | 'false'; codename?: string; - fork?: string; - isPublic?: string | boolean; - main?: string | boolean; + isPublic?: 'true' | 'false'; + main?: 'true' | 'false'; }; export default class CreateVersionCommand extends Command { @@ -44,34 +45,15 @@ export default class CreateVersionCommand extends Command { type: String, description: "The semantic version which you'd like to fork from.", }, - { - name: 'codename', - type: String, - description: 'The codename, or nickname, for a particular version.', - }, - { - name: 'main', - type: String, - description: 'Should this version be the primary (default) version for your project?', - }, - { - name: 'beta', - type: String, - description: 'Is this version in beta?', - }, - { - name: 'isPublic', - type: String, - description: 'Would you like to make this version public? Any primary version must be public.', - }, + ...this.getVersionOpts(), ]; } - async run(opts: CommandOptions) { + async run(opts: CommandOptions) { super.run(opts, true); let versionList; - const { key, version, codename, fork, main, beta, isPublic } = opts; + const { key, version, fork, codename, main, beta, isPublic } = opts; if (!version || !semver.valid(semver.coerce(version))) { return Promise.reject( @@ -86,15 +68,12 @@ export default class CreateVersionCommand extends Command { }).then(res => handleRes(res)); } - const versionPrompt = promptHandler.createVersionPrompt(versionList || [{}], { + const versionPrompt = promptHandler.createVersionPrompt(versionList || [], { newVersion: version, ...opts, }); - 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 - ); + const promptResponse = await promptTerminal(versionPrompt); return fetch(`${config.get('host')}/api/v1/version`, { method: 'post', diff --git a/src/cmds/versions/delete.ts b/src/cmds/versions/delete.ts index 7d2d0bf4d..1c4bfdb92 100644 --- a/src/cmds/versions/delete.ts +++ b/src/cmds/versions/delete.ts @@ -36,9 +36,7 @@ export default class DeleteVersionCommand extends Command { const { key, version } = opts; - const selectedVersion = await getProjectVersion(version, key, false).catch(e => { - return Promise.reject(e); - }); + const selectedVersion = await getProjectVersion(version, key, false); Command.debug(`selectedVersion: ${selectedVersion}`); diff --git a/src/cmds/versions/update.ts b/src/cmds/versions/update.ts index 892935d6a..b722b3848 100644 --- a/src/cmds/versions/update.ts +++ b/src/cmds/versions/update.ts @@ -1,22 +1,16 @@ import type { CommandOptions } from '../../lib/baseCommand'; +import type { VersionBaseOptions } from './create'; 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'; import * as promptHandler from '../../lib/prompts'; +import promptTerminal from '../../lib/promptWrapper'; import { getProjectVersion } from '../../lib/versionSelect'; -export type Options = { - beta?: string; - codename?: string; - deprecated?: string; - isPublic?: string; - main?: string; - newVersion?: string; -}; +export type VersionUpdateOptions = { deprecated?: 'true' | 'false'; newVersion?: string } & VersionBaseOptions; export default class UpdateVersionCommand extends Command { constructor() { @@ -36,36 +30,25 @@ export default class UpdateVersionCommand extends Command { }, this.getVersionArg(), { - name: 'codename', + name: 'newVersion', type: String, - description: 'The codename, or nickname, for a particular version.', + description: 'What should the version be renamed to?', }, + ...this.getVersionOpts(), { - name: 'main', + name: 'deprecated', type: String, - description: 'Should this version be the primary (default) version for your project?', - }, - { - name: 'beta', - type: String, - description: 'Is this version in beta?', - }, - { - name: 'isPublic', - type: String, - description: 'Would you like to make this version public? Any primary version must be public.', + description: 'Would you like to deprecate this version?', }, ]; } - async run(opts: CommandOptions) { + async run(opts: CommandOptions) { super.run(opts, true); - const { key, version, codename, newVersion, main, beta, isPublic, deprecated } = opts; + const { key, version, newVersion, codename, main, beta, isPublic, deprecated } = opts; - const selectedVersion = await getProjectVersion(version, key, false).catch(e => { - return Promise.reject(e); - }); + const selectedVersion = await getProjectVersion(version, key, false); Command.debug(`selectedVersion: ${selectedVersion}`); @@ -74,16 +57,7 @@ export default class UpdateVersionCommand extends Command { headers: cleanHeaders(key), }).then(res => handleRes(res)); - 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. - promptHandler.createVersionPrompt([{}], opts, foundVersion) - ); + const promptResponse = await promptTerminal(promptHandler.createVersionPrompt([], opts, foundVersion)); return fetch(`${config.get('host')}/api/v1/version/${selectedVersion}`, { method: 'put', diff --git a/src/lib/baseCommand.ts b/src/lib/baseCommand.ts index 0b74deef8..40546dea4 100644 --- a/src/lib/baseCommand.ts +++ b/src/lib/baseCommand.ts @@ -20,18 +20,55 @@ export enum CommandCategories { } export default class Command { + /** + * The command name + * + * @example openapi + */ command: string; + /** + * Example command usage, used on invidivual command help screens + * + * @example openapi [file] [options] + */ usage: string; + /** + * The command description, used on help screens + * + * @example Upload, or resync, your OpenAPI/Swagger definition to ReadMe. + */ description: string; + /** + * The category that the command belongs to, used on + * the general help screen to group commands together + * and on individual command help screens + * to show related commands + * + * @example CommandCategories.APIS + */ cmdCategory: CommandCategories; + /** + * The order in which to display the command within the `cmdCategory` + * + * @example 1 + */ position: number; + /** + * Arguments to hide from the individual command help screen + * (typically used for hiding default arguments) + * + * @example ['spec'] + */ hiddenArgs: string[] = []; + /** + * All documented arguments for the command + */ args: OptionDefinition[]; run(opts: CommandOptions<{}>, requiresAuth?: boolean): void | Promise { @@ -45,6 +82,9 @@ export default class Command { } } + /** + * Used in any command where `version` is an option. + */ getVersionArg() { return { name: 'version', @@ -54,6 +94,34 @@ export default class Command { }; } + /** + * Used in the `versions:create` and `versions:update` commands. + */ + getVersionOpts() { + return [ + { + name: 'codename', + type: String, + description: 'The codename, or nickname, for a particular version.', + }, + { + name: 'main', + type: String, + description: 'Should this version be the primary (default) version for your project?', + }, + { + name: 'beta', + type: String, + description: 'Is this version in beta?', + }, + { + name: 'isPublic', + type: String, + description: 'Would you like to make this version public? Any primary version must be public.', + }, + ]; + } + static debug(msg: string) { debug(msg); } diff --git a/src/lib/prepareOas.ts b/src/lib/prepareOas.ts index ccdacb0d4..8af5a95b5 100644 --- a/src/lib/prepareOas.ts +++ b/src/lib/prepareOas.ts @@ -2,9 +2,9 @@ import ciDetect from '@npmcli/ci-detect'; import chalk from 'chalk'; import OASNormalize from 'oas-normalize'; import ora from 'ora'; -import prompts from 'prompts'; import { debug, info, oraOptions } from './logger'; +import promptTerminal from './promptWrapper'; import readdirRecursive from './readdirRecursive'; type FileSelection = { @@ -93,7 +93,7 @@ export default async function prepareOas(path: string, command: 'openapi' | 'val fileFindingSpinner.succeed(`${fileFindingSpinner.text} found! 🔍`); - const selection: FileSelection = await prompts({ + const selection: FileSelection = await promptTerminal({ name: 'file', message: `Multiple potential API definitions found! Which file would you like to ${action}?`, type: 'select', diff --git a/src/lib/promptWrapper.ts b/src/lib/promptWrapper.ts new file mode 100644 index 000000000..a5c7f0ed0 --- /dev/null +++ b/src/lib/promptWrapper.ts @@ -0,0 +1,39 @@ +import prompts from 'prompts'; + +/** + * The `prompts` library doesn't always interpret CTRL+C and release the terminal back to the user + * so we need handle this ourselves. This function is just a simple overload of the main `prompts` + * import that we use. + * + * @see {@link https://github.com/terkelg/prompts/issues/252} + */ +export default async function promptTerminal( + questions: prompts.PromptObject | prompts.PromptObject[], + options?: prompts.Options +): Promise> { + const enableTerminalCursor = () => { + process.stdout.write('\x1B[?25h'); + }; + + const onState = (state: { aborted: boolean }) => { + if (state.aborted) { + // If we don't re-enable the terminal cursor before exiting the program, the cursor will + // remain hidden. + enableTerminalCursor(); + process.stdout.write('\n\n'); + process.stdout.write('Thanks for using rdme! See you soon ✌️'); + process.stdout.write('\n\n'); + process.exit(1); + } + }; + + if (Array.isArray(questions)) { + // eslint-disable-next-line no-param-reassign + questions = questions.map(question => ({ ...question, onState })); + } else { + // eslint-disable-next-line no-param-reassign + questions.onState = onState; + } + + return prompts(questions, options); +} diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 0b81b391f..3b80b4b14 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -1,9 +1,13 @@ +import type { VersionCreateOptions } from 'cmds/versions/create'; +import type { VersionUpdateOptions } from 'cmds/versions/update'; import type { Response } from 'node-fetch'; +import type { Choice, PromptObject } from 'prompts'; -import { prompt } from 'enquirer'; import parse from 'parse-link-header'; import semver from 'semver'; +import promptTerminal from './promptWrapper'; + type SpecList = { _id: string; title: string; @@ -24,56 +28,62 @@ type ParsedDocs = { }; }; -export function generatePrompts(versionList: VersionList, selectOnly = false) { +export function generatePrompts(versionList: VersionList, selectOnly = false): PromptObject[] { return [ { - type: 'select', + type: selectOnly ? null : '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' }, + { title: 'Use existing', value: 'update' }, + { title: 'Create a new version', value: 'create' }, ], }, { - type: 'select', + type: (prev, values) => { + return (selectOnly ? false : values.option !== 'update') ? null : '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, + title: v.version, value: v.version, }; }), }, { - type: 'input', + type: (prev, values) => { + return (selectOnly ? true : values.option === 'update') ? null : 'text'; + }, 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) { +function specOptions(specList: SpecList, parsedDocs: ParsedDocs, currPage: number, totalPages: number): Choice[] { const specs = specList.map(s => { return { - message: s.title, + description: `API Definition ID: ${s._id}`, // eslint-disable-line no-underscore-dangle + title: 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' }); + if (parsedDocs?.prev?.page) { + specs.push({ + description: 'Go to the previous page', + title: `< Prev (page ${currPage - 1} of ${totalPages})`, + value: 'prev', + }); + } + if (parsedDocs?.next?.page) { + specs.push({ + description: 'Go to the next page', + title: `Next (page ${currPage + 1} of ${totalPages}) >`, + value: 'next', + }); } return specs; } @@ -84,19 +94,21 @@ const updateOasPrompt = ( currPage: number, totalPages: number, getSpecs: (url: string) => Promise -) => [ +): PromptObject[] => [ { type: 'select', name: 'specId', message: 'Select your desired file to update', choices: specOptions(specList, parsedDocs, currPage, totalPages), - async result(spec: string) { + async format(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( + // @todo: figure out how to add a stricter type here, see: + // https://github.com/readmeio/rdme/pull/570#discussion_r949715913 + const { specId } = await promptTerminal( updateOasPrompt(newSpecList, newParsedDocs, currPage - 1, totalPages, getSpecs) ); return specId; @@ -108,7 +120,9 @@ const updateOasPrompt = ( 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( + // @todo: figure out how to add a stricter type here, see: + // https://github.com/readmeio/rdme/pull/570#discussion_r949715913 + const { specId } = await promptTerminal( updateOasPrompt(newSpecList, newParsedDocs, currPage + 1, totalPages, getSpecs) ); return specId; @@ -127,26 +141,22 @@ export function createOasPrompt( parsedDocs: ParsedDocs, totalPages: number, getSpecs: ((url: string) => Promise) | null -) { +): PromptObject[] { 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' }, + { title: 'Update existing', value: 'update' }, + { title: 'Create a new spec', value: 'create' }, ], - async result(picked: string) { + async format(picked: 'update' | 'create') { if (picked === 'update') { - try { - const { specId }: { specId: string } = await prompt( - updateOasPrompt(specList, parsedDocs, 1, totalPages, getSpecs) - ); - return specId; - } catch (e) { - return null; - } + // @todo: figure out how to add a stricter type here, see: + // https://github.com/readmeio/rdme/pull/570#discussion_r949715913 + const { specId } = await promptTerminal(updateOasPrompt(specList, parsedDocs, 1, totalPages, getSpecs)); + return specId; } return picked; @@ -157,75 +167,56 @@ export function createOasPrompt( export function createVersionPrompt( versionList: VersionList, - opts: { - beta?: string | boolean; - deprecated?: string; - fork?: string; - isPublic?: string | boolean; - main?: string | boolean; - newVersion?: string; - }, + opts: VersionCreateOptions & VersionUpdateOptions, isUpdate?: { - is_stable: string; + is_stable: boolean; } -) { +): PromptObject[] { return [ { - type: 'select', + type: opts.fork || isUpdate ? null : '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, + title: v.version, value: v.version, }; }), }, { - type: 'input', + type: opts.newVersion || !isUpdate ? null : 'text', name: 'newVersion', - message: "What's your new version?", + message: 'What should the version be renamed to?', 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.'); + return semver.valid(semver.coerce(val)) ? true : 'Please specify a semantic version.'; }, }, { - type: 'confirm', + type: opts.main || isUpdate?.is_stable ? null : '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', + type: opts.beta ? null : 'confirm', name: 'is_beta', message: 'Should this version be in beta?', - skip: () => opts.beta, }, { - type: 'confirm', + type: (prev, values) => { + return opts.isPublic || opts.main || values.is_stable ? null : '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', + type: (prev, values) => { + return opts.deprecated || opts.main || !isUpdate || values.is_stable ? null : '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/versionSelect.ts b/src/lib/versionSelect.ts index 014bb7236..aa8080e3d 100644 --- a/src/lib/versionSelect.ts +++ b/src/lib/versionSelect.ts @@ -1,12 +1,12 @@ import ciDetect from '@npmcli/ci-detect'; import config from 'config'; -import { prompt } from 'enquirer'; import { Headers } from 'node-fetch'; import APIError from './apiError'; import fetch, { cleanHeaders, handleRes } from './fetch'; import { warn } from './logger'; import * as promptHandler from './prompts'; +import promptTerminal from './promptWrapper'; /** * Validates and returns a project version. @@ -39,17 +39,11 @@ export async function getProjectVersion(versionFlag: string, key: string, allowN }).then(res => handleRes(res)); if (allowNewVersion) { - const { - option, - versionSelection, - newVersion, - }: { option: 'update' | 'create'; versionSelection: string; newVersion: string } = await prompt( - promptHandler.generatePrompts(versionList) - ); + const { option, versionSelection, newVersion } = await promptTerminal(promptHandler.generatePrompts(versionList)); if (option === 'update') return versionSelection; - await fetch(`${config.get('host')}/api/v1/version`, { + const newVersionFromApi = await fetch(`${config.get('host')}/api/v1/version`, { method: 'post', headers: cleanHeaders(key, new Headers({ 'Content-Type': 'application/json' })), body: JSON.stringify({ @@ -57,14 +51,14 @@ export async function getProjectVersion(versionFlag: string, key: string, allowN version: newVersion, is_stable: false, }), - }); + }) + .then(res => handleRes(res)) + .then(res => res.version); - return newVersion; + return newVersionFromApi; } - const { versionSelection }: { versionSelection: string } = await prompt( - promptHandler.generatePrompts(versionList, true) - ); + const { versionSelection } = await promptTerminal(promptHandler.generatePrompts(versionList, true)); return versionSelection; } catch (err) {