diff --git a/__tests__/cmds/versions/create.test.ts b/__tests__/cmds/versions/create.test.ts index 879c03a26..4c6e0ab47 100644 --- a/__tests__/cmds/versions/create.test.ts +++ b/__tests__/cmds/versions/create.test.ts @@ -52,7 +52,6 @@ describe('rdme versions:create', () => { .reply(200, [{ version }, { version: '1.1.0' }]) .post('/api/v1/version', { version: newVersion, - codename: '', is_stable: false, is_beta: true, from: '1.0.0', @@ -75,7 +74,10 @@ describe('rdme versions:create', () => { version: newVersion, codename: 'test', from: '1.0.0', + is_beta: false, + is_deprecated: false, is_hidden: false, + is_stable: false, }) .basicAuth({ user: key }) .reply(201, { version: newVersion }); @@ -86,6 +88,7 @@ describe('rdme versions:create', () => { version: newVersion, fork: version, beta: 'false', + deprecated: 'false', main: 'false', codename: 'test', isPublic: 'true', @@ -108,4 +111,62 @@ describe('rdme versions:create', () => { await expect(createVersion.run({ key, version, fork: '0.0.5' })).rejects.toStrictEqual(new APIError(errorResponse)); mockRequest.done(); }); + + describe('bad flag values', () => { + it('should throw if non-boolean `beta` flag is passed', () => { + const newVersion = '1.0.1'; + + return expect( + createVersion.run({ + key, + version: newVersion, + fork: version, + // @ts-expect-error deliberately passing a bad value here + beta: 'test', + }) + ).rejects.toStrictEqual(new Error("Invalid option passed for 'beta'. Must be 'true' or 'false'.")); + }); + + it('should throw if non-boolean `deprecated` flag is passed', () => { + const newVersion = '1.0.1'; + + return expect( + createVersion.run({ + key, + version: newVersion, + fork: version, + // @ts-expect-error deliberately passing a bad value here + deprecated: 'test', + }) + ).rejects.toStrictEqual(new Error("Invalid option passed for 'deprecated'. Must be 'true' or 'false'.")); + }); + + it('should throw if non-boolean `isPublic` flag is passed', () => { + const newVersion = '1.0.1'; + + return expect( + createVersion.run({ + key, + version: newVersion, + fork: version, + // @ts-expect-error deliberately passing a bad value here + isPublic: 'test', + }) + ).rejects.toStrictEqual(new Error("Invalid option passed for 'isPublic'. Must be 'true' or 'false'.")); + }); + + it('should throw if non-boolean `main` flag is passed', () => { + const newVersion = '1.0.1'; + + return expect( + createVersion.run({ + key, + version: newVersion, + fork: version, + // @ts-expect-error deliberately passing a bad value here + main: 'test', + }) + ).rejects.toStrictEqual(new Error("Invalid option passed for 'main'. Must be 'true' or 'false'.")); + }); + }); }); diff --git a/__tests__/cmds/versions/update.test.ts b/__tests__/cmds/versions/update.test.ts index 0848e9504..4e5e646a5 100644 --- a/__tests__/cmds/versions/update.test.ts +++ b/__tests__/cmds/versions/update.test.ts @@ -36,7 +36,6 @@ describe('rdme versions:update', () => { prompts.inject([versionToChange, renamedVersion, false, true, true, false]); const updatedVersionObject = { - codename: '', version: renamedVersion, is_stable: false, is_beta: true, @@ -67,7 +66,9 @@ describe('rdme versions:update', () => { codename: 'updated-test', version: renamedVersion, is_beta: true, + is_deprecated: true, is_hidden: false, + is_stable: false, }; const mockRequest = getAPIMock() @@ -86,6 +87,7 @@ describe('rdme versions:update', () => { key, version: versionToChange, newVersion: renamedVersion, + deprecated: 'true', beta: 'true', main: 'false', codename: 'updated-test', @@ -95,6 +97,120 @@ describe('rdme versions:update', () => { mockRequest.done(); }); + it("should update a specific version object using flags that contain the string 'false'", async () => { + const versionToChange = '1.1.0'; + const renamedVersion = '1.1.0-update'; + + const updatedVersionObject = { + codename: 'updated-test', + version: renamedVersion, + is_beta: false, + is_deprecated: false, + is_hidden: false, + is_stable: false, + }; + + const mockRequest = getAPIMock() + .get(`/api/v1/version/${versionToChange}`) + .basicAuth({ user: key }) + .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: versionToChange, + newVersion: renamedVersion, + beta: 'false', + deprecated: 'false', + main: 'false', + codename: 'updated-test', + isPublic: 'true', + }) + ).resolves.toBe(`Version ${versionToChange} updated successfully.`); + mockRequest.done(); + }); + + it("should update a specific version object using flags that contain the string 'false' and a prompt", async () => { + const versionToChange = '1.1.0'; + const renamedVersion = '1.1.0-update'; + // prompt for beta flag + prompts.inject([false]); + + const updatedVersionObject = { + codename: 'updated-test', + version: renamedVersion, + is_beta: false, + is_hidden: false, + is_stable: false, + }; + + const mockRequest = getAPIMock() + .get(`/api/v1/version/${versionToChange}`) + .basicAuth({ user: key }) + .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: versionToChange, + newVersion: renamedVersion, + main: 'false', + codename: 'updated-test', + isPublic: 'true', + }) + ).resolves.toBe(`Version ${versionToChange} updated successfully.`); + mockRequest.done(); + }); + + it('should update a specific version object even if user bypasses prompt for new version name', async () => { + const versionToChange = '1.1.0'; + // simulating user entering nothing for the prompt to enter a new version name + prompts.inject(['']); + + const updatedVersionObject = { + codename: 'updated-test', + is_beta: false, + is_hidden: false, + is_stable: false, + version: versionToChange, + }; + + const mockRequest = getAPIMock() + .get(`/api/v1/version/${versionToChange}`) + .basicAuth({ user: key }) + .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: versionToChange, + beta: 'false', + 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 @@ -104,10 +220,10 @@ describe('rdme versions:update', () => { const renamedVersion = '1.0.0-update'; const updatedVersionObject = { - codename: '', version: renamedVersion, is_beta: true, is_hidden: true, + is_stable: false, }; prompts.inject([renamedVersion, true]); @@ -133,4 +249,94 @@ describe('rdme versions:update', () => { await expect(updateVersion.run({ key, version, main: 'false' })).rejects.toStrictEqual(new APIError(errorResponse)); mockRequest.done(); }); + + describe('bad flag values', () => { + it('should throw if non-boolean `beta` flag is passed', async () => { + const versionToChange = '1.1.0'; + + const mockRequest = getAPIMock() + .get(`/api/v1/version/${versionToChange}`) + .basicAuth({ user: key }) + .reply(200, { version: versionToChange }) + .get(`/api/v1/version/${versionToChange}`) + .basicAuth({ user: key }) + .reply(200, { version: versionToChange }); + + await expect( + updateVersion.run({ + key, + version: versionToChange, + // @ts-expect-error deliberately passing a bad value here + beta: 'hi', + }) + ).rejects.toStrictEqual(new Error("Invalid option passed for 'beta'. Must be 'true' or 'false'.")); + mockRequest.done(); + }); + + it('should throw if non-boolean `deprecated` flag is passed', async () => { + const versionToChange = '1.1.0'; + + const mockRequest = getAPIMock() + .get(`/api/v1/version/${versionToChange}`) + .basicAuth({ user: key }) + .reply(200, { version: versionToChange }) + .get(`/api/v1/version/${versionToChange}`) + .basicAuth({ user: key }) + .reply(200, { version: versionToChange }); + + await expect( + updateVersion.run({ + key, + version: versionToChange, + // @ts-expect-error deliberately passing a bad value here + deprecated: 'hi', + }) + ).rejects.toStrictEqual(new Error("Invalid option passed for 'deprecated'. Must be 'true' or 'false'.")); + mockRequest.done(); + }); + + it('should throw if non-boolean `isPublic` flag is passed', async () => { + const versionToChange = '1.1.0'; + + const mockRequest = getAPIMock() + .get(`/api/v1/version/${versionToChange}`) + .basicAuth({ user: key }) + .reply(200, { version: versionToChange }) + .get(`/api/v1/version/${versionToChange}`) + .basicAuth({ user: key }) + .reply(200, { version: versionToChange }); + + await expect( + updateVersion.run({ + key, + version: versionToChange, + // @ts-expect-error deliberately passing a bad value here + isPublic: 'hi', + }) + ).rejects.toStrictEqual(new Error("Invalid option passed for 'isPublic'. Must be 'true' or 'false'.")); + mockRequest.done(); + }); + + it('should throw if non-boolean `main` flag is passed', async () => { + const versionToChange = '1.1.0'; + + const mockRequest = getAPIMock() + .get(`/api/v1/version/${versionToChange}`) + .basicAuth({ user: key }) + .reply(200, { version: versionToChange }) + .get(`/api/v1/version/${versionToChange}`) + .basicAuth({ user: key }) + .reply(200, { version: versionToChange }); + + await expect( + updateVersion.run({ + key, + version: versionToChange, + // @ts-expect-error deliberately passing a bad value here + main: 'hi', + }) + ).rejects.toStrictEqual(new Error("Invalid option passed for 'main'. Must be 'true' or 'false'.")); + mockRequest.done(); + }); + }); }); diff --git a/__tests__/lib/prompts.test.ts b/__tests__/lib/prompts.test.ts index 32bdeb689..fb0b06d95 100644 --- a/__tests__/lib/prompts.test.ts +++ b/__tests__/lib/prompts.test.ts @@ -1,4 +1,3 @@ -import type { Options as VersionUpdateOptions } from '../../src/cmds/versions/update'; import type { Response } from 'node-fetch'; import prompts from 'prompts'; @@ -81,25 +80,23 @@ describe('prompt test bed', () => { }); }); - describe('createVersionPrompt()', () => { + describe('versionPrompt()', () => { it('should allow user to choose a fork if flag is not passed (creating version)', async () => { - const opts: VersionUpdateOptions = { newVersion: '1.2.1' }; - prompts.inject(['1', true, true]); - const answer = await promptTerminal(promptHandler.createVersionPrompt(versionlist, opts)); + const answer = await promptTerminal(promptHandler.versionPrompt(versionlist)); expect(answer).toStrictEqual({ from: '1', is_stable: true, is_beta: true }); }); it('should skip fork prompt if value passed (updating version)', async () => { prompts.inject(['1.2.1', false, true, true, false]); - const answer = await promptTerminal(promptHandler.createVersionPrompt(versionlist, {}, { is_stable: false })); + const answer = await promptTerminal(promptHandler.versionPrompt(versionlist, { is_stable: false })); expect(answer).toStrictEqual({ newVersion: '1.2.1', is_stable: false, is_beta: true, - is_hidden: true, + is_public: true, is_deprecated: false, }); }); diff --git a/src/cmds/versions/create.ts b/src/cmds/versions/create.ts index 84a5757ea..850d7cb35 100644 --- a/src/cmds/versions/create.ts +++ b/src/cmds/versions/create.ts @@ -3,9 +3,11 @@ import type { CommandOptions } from '../../lib/baseCommand'; import config from 'config'; import { Headers } from 'node-fetch'; +import prompts from 'prompts'; import semver from 'semver'; import Command, { CommandCategories } from '../../lib/baseCommand'; +import castStringOptToBool from '../../lib/castStringOptToBool'; import * as promptHandler from '../../lib/prompts'; import promptTerminal from '../../lib/promptWrapper'; import readmeAPIFetch, { cleanHeaders, handleRes } from '../../lib/readmeAPIFetch'; @@ -17,6 +19,7 @@ export interface Options extends CommonOptions { export interface CommonOptions { beta?: 'true' | 'false'; codename?: string; + deprecated?: 'true' | 'false'; isPublic?: 'true' | 'false'; main?: 'true' | 'false'; } @@ -33,11 +36,6 @@ export default class CreateVersionCommand extends Command { this.hiddenArgs = ['version']; this.args = [ this.getKeyArg(), - { - name: 'version', - type: String, - defaultOption: true, - }, { name: 'fork', type: String, @@ -51,7 +49,7 @@ export default class CreateVersionCommand extends Command { await super.run(opts); let versionList; - const { key, version, fork, codename, main, beta, isPublic } = opts; + const { key, version, fork, codename, main, beta, deprecated, isPublic } = opts; if (!version || !semver.valid(semver.coerce(version))) { return Promise.reject( @@ -66,20 +64,26 @@ export default class CreateVersionCommand extends Command { }).then(handleRes); } - const versionPrompt = promptHandler.createVersionPrompt(versionList || [], { - newVersion: version, - ...opts, + const versionPrompt = promptHandler.versionPrompt(versionList || []); + + prompts.override({ + from: fork, + is_beta: castStringOptToBool(beta, 'beta'), + is_deprecated: castStringOptToBool(deprecated, 'deprecated'), + is_public: castStringOptToBool(isPublic, 'isPublic'), + is_stable: castStringOptToBool(main, 'main'), }); const promptResponse = await promptTerminal(versionPrompt); const body: Version = { + codename, version, - codename: codename || '', - is_stable: main === 'true' || promptResponse.is_stable, - is_beta: beta === 'true' || promptResponse.is_beta, - from: fork || promptResponse.from, - is_hidden: promptResponse.is_stable ? false : !(isPublic === 'true' || promptResponse.is_hidden), + from: promptResponse.from, + is_beta: promptResponse.is_beta, + is_deprecated: promptResponse.is_deprecated, + is_hidden: !promptResponse.is_public, + is_stable: promptResponse.is_stable, }; return readmeAPIFetch('/api/v1/version', { diff --git a/src/cmds/versions/update.ts b/src/cmds/versions/update.ts index 4293c739e..fe484933e 100644 --- a/src/cmds/versions/update.ts +++ b/src/cmds/versions/update.ts @@ -3,15 +3,16 @@ import type { CommonOptions } from './create'; import type { CommandOptions } from '../../lib/baseCommand'; import { Headers } from 'node-fetch'; +import prompts from 'prompts'; import Command, { CommandCategories } from '../../lib/baseCommand'; +import castStringOptToBool from '../../lib/castStringOptToBool'; import * as promptHandler from '../../lib/prompts'; import promptTerminal from '../../lib/promptWrapper'; import readmeAPIFetch, { cleanHeaders, handleRes } from '../../lib/readmeAPIFetch'; import { getProjectVersion } from '../../lib/versionSelect'; export interface Options extends CommonOptions { - deprecated?: 'true' | 'false'; newVersion?: string; } @@ -27,22 +28,12 @@ export default class UpdateVersionCommand extends Command { this.hiddenArgs = ['version']; this.args = [ this.getKeyArg(), - { - name: 'version', - type: String, - defaultOption: true, - }, { name: 'newVersion', type: String, description: 'What should the version be renamed to?', }, ...this.getVersionOpts(), - { - name: 'deprecated', - type: String, - description: "Would you like to deprecate this version? (Must be 'true' or 'false')", - }, ]; } @@ -55,20 +46,31 @@ export default class UpdateVersionCommand extends Command { Command.debug(`selectedVersion: ${selectedVersion}`); + // TODO: I think this fetch here is unnecessary but + // it will require a bigger refactor of getProjectVersion const foundVersion = await readmeAPIFetch(`/api/v1/version/${selectedVersion}`, { method: 'get', headers: cleanHeaders(key), }).then(handleRes); - const promptResponse = await promptTerminal(promptHandler.createVersionPrompt([], opts, foundVersion)); + prompts.override({ + is_beta: castStringOptToBool(beta, 'beta'), + is_deprecated: castStringOptToBool(deprecated, 'deprecated'), + is_public: castStringOptToBool(isPublic, 'isPublic'), + is_stable: castStringOptToBool(main, 'main'), + newVersion, + }); + + const promptResponse = await promptTerminal(promptHandler.versionPrompt([], foundVersion)); const body: Version = { - codename: codename || '', - version: newVersion || promptResponse.newVersion, - is_stable: foundVersion.is_stable || main === 'true' || promptResponse.is_stable, - is_beta: beta === 'true' || promptResponse.is_beta, - is_deprecated: deprecated === 'true' || promptResponse.is_deprecated, - is_hidden: promptResponse.is_stable ? false : !(isPublic === 'true' || promptResponse.is_hidden), + codename, + // fall back to current version if user didn't enter one + version: promptResponse.newVersion || version, + is_beta: promptResponse.is_beta, + is_deprecated: promptResponse.is_deprecated, + is_hidden: !promptResponse.is_public, + is_stable: promptResponse.is_stable, }; return readmeAPIFetch(`/api/v1/version/${selectedVersion}`, { diff --git a/src/lib/baseCommand.ts b/src/lib/baseCommand.ts index cece69a42..d554919de 100644 --- a/src/lib/baseCommand.ts +++ b/src/lib/baseCommand.ts @@ -172,6 +172,11 @@ export default class Command { */ getVersionOpts(): OptionDefinition[] { return [ + { + name: 'version', + type: String, + defaultOption: true, + }, { name: 'codename', type: String, @@ -188,6 +193,11 @@ export default class Command { type: String, description: "Is this version in beta? (Must be 'true' or 'false')", }, + { + name: 'deprecated', + type: String, + description: "Would you like to deprecate this version? (Must be 'true' or 'false')", + }, { name: 'isPublic', type: String, diff --git a/src/lib/castStringOptToBool.ts b/src/lib/castStringOptToBool.ts new file mode 100644 index 000000000..591f3ac65 --- /dev/null +++ b/src/lib/castStringOptToBool.ts @@ -0,0 +1,19 @@ +import type { Options as CreateOptions } from '../cmds/versions/create'; +import type { Options as UpdateOptions } from '../cmds/versions/update'; + +/** + * Takes a CLI flag that is expected to be a 'true' or 'false' string + * and casts it to a boolean. + */ +export default function castStringOptToBool(opt: 'true' | 'false', optName: keyof CreateOptions | keyof UpdateOptions) { + if (!opt) { + return undefined; + } + if (opt === 'true') { + return true; + } + if (opt === 'false') { + return false; + } + throw new Error(`Invalid option passed for '${optName}'. Must be 'true' or 'false'.`); +} diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index b56be1482..27fe1377d 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -1,6 +1,4 @@ import type { Version } from '../cmds/versions'; -import type { Options as VersionCreateOptions } from 'cmds/versions/create'; -import type { Options as VersionUpdateOptions } from 'cmds/versions/update'; import type { Response } from 'node-fetch'; import type { Choice, PromptObject } from 'prompts'; @@ -130,16 +128,22 @@ export function createOasPrompt( ]; } -export function createVersionPrompt( +/** + * Series of prompts to construct a version object, + * used in our `versions:create` and `versions:update` commands + */ +export function versionPrompt( + /** list of versions, used for prompt about which version to fork */ versionList: Version[], - opts: VersionCreateOptions & VersionUpdateOptions, + /** existing version if we're performing an update */ isUpdate?: { is_stable: boolean; } ): PromptObject[] { return [ { - type: opts.fork || isUpdate ? null : 'select', + // only runs for versions:create command + type: isUpdate ? null : 'select', name: 'from', message: 'Which version would you like to fork from?', choices: versionList.map(v => { @@ -150,35 +154,43 @@ export function createVersionPrompt( }), }, { - type: opts.newVersion || !isUpdate ? null : 'text', + // only runs for versions:update command + type: !isUpdate ? null : 'text', name: 'newVersion', message: 'What should the version be renamed to?', - initial: opts.newVersion || false, hint: '1.0.0', validate(val: string) { + // allow empty string, in which case the version won't be renamed + if (!val) return true; return semver.valid(semver.coerce(val)) ? true : 'Please specify a semantic version.'; }, }, { - type: opts.main || isUpdate?.is_stable ? null : 'confirm', + // if the existing version being updated is already the main version, + // we can't switch that so we skip this question + type: isUpdate?.is_stable ? null : 'confirm', name: 'is_stable', message: 'Would you like to make this version the main version for this project?', }, { - type: opts.beta ? null : 'confirm', + type: 'confirm', name: 'is_beta', message: 'Should this version be in beta?', }, { type: (prev, values) => { - return opts.isPublic || opts.main || values.is_stable ? null : 'confirm'; + // if user previously wanted this version to be the main version + // it can't also be hidden. + return values.is_stable ? null : 'confirm'; }, - name: 'is_hidden', + name: 'is_public', message: 'Would you like to make this version public?', }, { type: (prev, values) => { - return opts.deprecated || opts.main || !isUpdate || values.is_stable ? null : 'confirm'; + // if user previously wanted this version to be the main version + // it can't also be deprecated. + return values.is_stable ? null : 'confirm'; }, name: 'is_deprecated', message: 'Would you like to deprecate this version?',