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) {