diff --git a/README.md b/README.md index cb4a37332..326d8d817 100644 --- a/README.md +++ b/README.md @@ -207,9 +207,13 @@ rdme docs path-to-markdown-files --version={project-version} This command also has a dry run mode, which can be useful for initial setup and debugging. You can read more about dry run mode [in our docs](https://docs.readme.com/docs/rdme#dry-run-mode). -#### Cleanup +#### Prune -If you wish to delete documents from ReadMe that are no longer present in your local directory, pass the `--cleanup` option to the command. +If you wish to delete documents from ReadMe that are no longer present in your local directory: + +```sh +rdme docs:prune path-to-markdown-files +``` #### Edit a Single ReadMe Doc on Your Local Machine diff --git a/__tests__/cmds/docs/index.test.ts b/__tests__/cmds/docs/index.test.ts index d92d26aff..ae265eea6 100644 --- a/__tests__/cmds/docs/index.test.ts +++ b/__tests__/cmds/docs/index.test.ts @@ -1,5 +1,4 @@ /* eslint-disable no-console */ -import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; @@ -390,124 +389,6 @@ describe('rdme docs', () => { }); }); - describe('cleanup docs', () => { - const folder = `./__tests__/${fixturesBaseDir}/delete-docs`; - const someDocContent = fs.readFileSync(path.join(folder, 'some-doc.md')); - const lastUpdatedHash = crypto.createHash('sha1').update(someDocContent).digest('hex'); - let consoleWarnSpy; - - function getWarningCommandOutput() { - return [consoleWarnSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n'); - } - - beforeEach(() => { - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - }); - - afterEach(() => { - consoleWarnSpy.mockRestore(); - }); - - it('should delete doc if file is missing and --cleanup option is used', async () => { - const versionMock = getAPIMock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - const apiMocks = getAPIMockWithVersionHeader(version) - .get('/api/v1/categories?perPage=20&page=1') - .basicAuth({ user: key }) - .reply(200, [{ slug: 'category1', type: 'guide' }], { 'x-total-count': '1' }) - .get('/api/v1/categories/category1/docs') - .basicAuth({ user: key }) - .reply(200, [{ slug: 'this-doc-should-be-missing-in-folder' }, { slug: 'some-doc' }]) - .delete('/api/v1/docs/this-doc-should-be-missing-in-folder') - .basicAuth({ user: key }) - .reply(204, '') - .get('/api/v1/docs/some-doc') - .basicAuth({ user: key }) - .reply(200, { lastUpdatedHash }); - - await expect( - docs.run({ - folder, - key, - version, - cleanup: true, - }) - ).resolves.toBe( - '🗑️ successfully deleted `this-doc-should-be-missing-in-folder`.\n' + - '`some-doc` was not updated because there were no changes.' - ); - const warningOutput = getWarningCommandOutput(); - expect(warningOutput).toBe( - "⚠️ Warning! We're going to delete from ReadMe any document that isn't found in ./__tests__/__fixtures__/docs/delete-docs." - ); - - apiMocks.done(); - versionMock.done(); - }); - - it('should return doc delete info for dry run', async () => { - const versionMock = getAPIMock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - const apiMocks = getAPIMockWithVersionHeader(version) - .get('/api/v1/categories?perPage=20&page=1') - .basicAuth({ user: key }) - .reply(200, [{ slug: 'category1', type: 'guide' }], { 'x-total-count': '1' }) - .get('/api/v1/categories/category1/docs') - .basicAuth({ user: key }) - .reply(200, [{ slug: 'this-doc-should-be-missing-in-folder' }]) - .get('/api/v1/docs/some-doc') - .basicAuth({ user: key }) - .reply(200, { lastUpdatedHash }); - await expect( - docs.run({ - folder, - key, - version, - cleanup: true, - dryRun: true, - }) - ).resolves.toBe( - '🎭 dry run! This will delete `this-doc-should-be-missing-in-folder`.\n' + - '🎭 dry run! `some-doc` will not be updated because there were no changes.' - ); - const warningOutput = getWarningCommandOutput(); - expect(warningOutput).toBe( - "⚠️ Warning! We're going to delete from ReadMe any document that isn't found in ./__tests__/__fixtures__/docs/delete-docs." - ); - - apiMocks.done(); - versionMock.done(); - }); - - it('should do nothing if using --cleanup but the folder is empty and the user aborted', async () => { - prompts.inject([false]); - - const versionMock = getAPIMock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - await expect( - docs.run({ - folder: './__tests__/__fixtures__/ref-oas', - key, - version, - cleanup: true, - }) - ).rejects.toStrictEqual(new Error('Aborting, no changes were made.')); - - const warningOutput = getWarningCommandOutput(); - expect(warningOutput).toBe(''); - - versionMock.done(); - }); - }); - describe('slug metadata', () => { it('should use provided slug', async () => { const slug = 'new-doc-slug'; diff --git a/__tests__/cmds/docs/prune.test.ts b/__tests__/cmds/docs/prune.test.ts new file mode 100644 index 000000000..e313351be --- /dev/null +++ b/__tests__/cmds/docs/prune.test.ts @@ -0,0 +1,120 @@ +import nock from 'nock'; +import prompts from 'prompts'; + +import DocsPruneCommand from '../../../src/cmds/docs/prune'; +import getAPIMock, { getAPIMockWithVersionHeader } from '../../helpers/get-api-mock'; + +const docsPrune = new DocsPruneCommand(); + +const fixturesBaseDir = '__fixtures__/docs'; + +const key = 'API_KEY'; +const version = '1.0.0'; + +describe('rdme docs:prune', () => { + const folder = `./__tests__/${fixturesBaseDir}/delete-docs`; + + beforeAll(() => nock.disableNetConnect()); + + afterAll(() => nock.cleanAll()); + + it('should prompt for login if no API key provided', async () => { + const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(); + prompts.inject(['this-is-not-an-email', 'password', 'subdomain']); + await expect(docsPrune.run({})).rejects.toStrictEqual(new Error('You must provide a valid email address.')); + consoleInfoSpy.mockRestore(); + }); + + it('should error in CI if no API key provided', async () => { + process.env.TEST_CI = 'true'; + await expect(docsPrune.run({})).rejects.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); + delete process.env.TEST_CI; + }); + + it('should error if no folder provided', () => { + return expect(docsPrune.run({ key, version: '1.0.0' })).rejects.toStrictEqual( + new Error('No folder provided. Usage `rdme docs:prune [options]`.') + ); + }); + + it('should error if the argument is not a folder', async () => { + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect(docsPrune.run({ key, version: '1.0.0', folder: 'not-a-folder' })).rejects.toThrow( + "ENOENT: no such file or directory, scandir 'not-a-folder'" + ); + + versionMock.done(); + }); + + it('should do nothing if the user aborted', async () => { + prompts.inject([false]); + + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + await expect( + docsPrune.run({ + folder, + key, + version, + }) + ).rejects.toStrictEqual(new Error('Aborting, no changes were made.')); + + versionMock.done(); + }); + + it('should delete doc if file is missing', async () => { + prompts.inject([true]); + + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + + const apiMocks = getAPIMockWithVersionHeader(version) + .get('/api/v1/categories?perPage=20&page=1') + .basicAuth({ user: key }) + .reply(200, [{ slug: 'category1', type: 'guide' }], { 'x-total-count': '1' }) + .get('/api/v1/categories/category1/docs') + .basicAuth({ user: key }) + .reply(200, [{ slug: 'this-doc-should-be-missing-in-folder' }, { slug: 'some-doc' }]) + .delete('/api/v1/docs/this-doc-should-be-missing-in-folder') + .basicAuth({ user: key }) + .reply(204, ''); + + await expect( + docsPrune.run({ + folder, + key, + version, + }) + ).resolves.toBe('🗑️ successfully deleted `this-doc-should-be-missing-in-folder`.'); + + apiMocks.done(); + versionMock.done(); + }); + + it('should return doc delete info for dry run', async () => { + prompts.inject([true]); + + const versionMock = getAPIMock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + const apiMocks = getAPIMockWithVersionHeader(version) + .get('/api/v1/categories?perPage=20&page=1') + .basicAuth({ user: key }) + .reply(200, [{ slug: 'category1', type: 'guide' }], { 'x-total-count': '1' }) + .get('/api/v1/categories/category1/docs') + .basicAuth({ user: key }) + .reply(200, [{ slug: 'this-doc-should-be-missing-in-folder' }]); + + await expect( + docsPrune.run({ + folder, + key, + version, + dryRun: true, + }) + ).resolves.toBe('🎭 dry run! This will delete `this-doc-should-be-missing-in-folder`.'); + + apiMocks.done(); + versionMock.done(); + }); +}); diff --git a/__tests__/lib/__snapshots__/commands.test.ts.snap b/__tests__/lib/__snapshots__/commands.test.ts.snap index c2b7d8d98..37a04763d 100644 --- a/__tests__/lib/__snapshots__/commands.test.ts.snap +++ b/__tests__/lib/__snapshots__/commands.test.ts.snap @@ -119,11 +119,17 @@ exports[`utils #listByCategory should list commands by category 1`] = ` "name": "docs", "position": 1, }, + { + "description": "Delete any docs from ReadMe if their slugs are not found in the target folder.", + "hidden": false, + "name": "docs:prune", + "position": 2, + }, { "description": "Edit a single file from your ReadMe project without saving locally. [deprecated]", "hidden": true, "name": "docs:edit", - "position": 2, + "position": 3, }, { "description": "Sync a single Markdown file to your ReadMe project.", diff --git a/src/cmds/docs/edit.ts b/src/cmds/docs/edit.ts index 52db5e04e..71d988843 100644 --- a/src/cmds/docs/edit.ts +++ b/src/cmds/docs/edit.ts @@ -24,7 +24,7 @@ export type Options = { }; @isHidden -export default class EditDocsCommand extends Command { +export default class DocsEditCommand extends Command { constructor() { super(); @@ -32,7 +32,7 @@ export default class EditDocsCommand extends Command { this.usage = 'docs:edit [options]'; this.description = 'Edit a single file from your ReadMe project without saving locally. [deprecated]'; this.cmdCategory = CommandCategories.DOCS; - this.position = 2; + this.position = 3; this.hiddenArgs = ['slug']; this.args = [ diff --git a/src/cmds/docs/index.ts b/src/cmds/docs/index.ts index ba0835075..7f2061c28 100644 --- a/src/cmds/docs/index.ts +++ b/src/cmds/docs/index.ts @@ -5,26 +5,15 @@ import config from 'config'; import Command, { CommandCategories } from '../../lib/baseCommand'; import createGHA from '../../lib/createGHA'; -import deleteDoc from '../../lib/deleteDoc'; -import getDocs from '../../lib/getDocs'; -import * as promptHandler from '../../lib/prompts'; -import promptTerminal from '../../lib/promptWrapper'; import pushDoc from '../../lib/pushDoc'; import readdirRecursive from '../../lib/readdirRecursive'; -import readDoc from '../../lib/readDoc'; import { getProjectVersion } from '../../lib/versionSelect'; export type Options = { dryRun?: boolean; folder?: string; - cleanup?: boolean; }; -function getSlug(filename: string): string { - const { slug } = readDoc(filename); - return slug; -} - export default class DocsCommand extends Command { constructor() { super(); @@ -50,18 +39,13 @@ export default class DocsCommand extends Command { type: Boolean, description: 'Runs the command without creating/updating any docs in ReadMe. Useful for debugging.', }, - { - name: 'cleanup', - type: Boolean, - description: 'Delete any docs from ReadMe if their slugs are not found in the target folder.', - }, ]; } async run(opts: CommandOptions) { await super.run(opts); - const { dryRun, folder, key, version, cleanup } = opts; + const { dryRun, folder, key, version } = opts; if (!folder) { return Promise.reject(new Error(`No folder provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); @@ -81,25 +65,7 @@ export default class DocsCommand extends Command { Command.debug(`number of files: ${files.length}`); - const changes: string[] = []; - if (cleanup) { - if (!files.length) { - const { deleteAll } = await promptTerminal(promptHandler.deleteDocsPrompt(selectedVersion)); - if (!deleteAll) { - return Promise.reject(new Error('Aborting, no changes were made.')); - } - } - - Command.warn(`We're going to delete from ReadMe any document that isn't found in ${folder}.`); - const docs = await getDocs(key, selectedVersion); - const docSlugs = docs.map(({ slug }: { slug: string }) => slug); - const fileSlugs = new Set(files.map(getSlug)); - const slugsToDelete = docSlugs.filter((slug: string) => !fileSlugs.has(slug)); - const deletedDocs = await Promise.all( - slugsToDelete.map((slug: string) => deleteDoc(key, selectedVersion, dryRun, slug, this.cmdCategory)) - ); - changes.push(...deletedDocs); - } else if (!files.length) { + if (!files.length) { return Promise.reject(new Error(`We were unable to locate Markdown files in ${folder}.`)); } @@ -108,8 +74,8 @@ export default class DocsCommand extends Command { return pushDoc(key, selectedVersion, dryRun, filename, this.cmdCategory); }) ); - changes.push(...updatedDocs); - return Promise.resolve(chalk.green(changes.join('\n'))).then(msg => + + return Promise.resolve(chalk.green(updatedDocs.join('\n'))).then(msg => createGHA(msg, this.command, this.args, { ...opts, version: selectedVersion }) ); } diff --git a/src/cmds/docs/prune.ts b/src/cmds/docs/prune.ts new file mode 100644 index 000000000..dc80cf087 --- /dev/null +++ b/src/cmds/docs/prune.ts @@ -0,0 +1,98 @@ +import type { CommandOptions } from '../../lib/baseCommand'; + +import chalk from 'chalk'; +import config from 'config'; + +import Command, { CommandCategories } from '../../lib/baseCommand'; +import createGHA from '../../lib/createGHA'; +import deleteDoc from '../../lib/deleteDoc'; +import getDocs from '../../lib/getDocs'; +import promptTerminal from '../../lib/promptWrapper'; +import readdirRecursive from '../../lib/readdirRecursive'; +import readDoc from '../../lib/readDoc'; +import { getProjectVersion } from '../../lib/versionSelect'; + +export type Options = { + dryRun?: boolean; + folder?: string; +}; + +function getSlug(filename: string): string { + const { slug } = readDoc(filename); + return slug; +} + +export default class DocsPruneCommand extends Command { + constructor() { + super(); + + this.command = 'docs:prune'; + this.usage = 'docs:prune [options]'; + this.description = 'Delete any docs from ReadMe if their slugs are not found in the target folder.'; + this.cmdCategory = CommandCategories.DOCS; + this.position = 2; + + this.hiddenArgs = ['folder']; + this.args = [ + this.getKeyArg(), + this.getVersionArg(), + { + name: 'folder', + type: String, + defaultOption: true, + }, + this.getGitHubArg(), + { + name: 'dryRun', + type: Boolean, + description: 'Runs the command without creating/updating any docs in ReadMe. Useful for debugging.', + }, + ]; + } + + async run(opts: CommandOptions) { + await super.run(opts); + + const { dryRun, folder, key, version } = opts; + + if (!folder) { + return Promise.reject(new Error(`No folder provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); + } + + // TODO: should we allow version selection at all here? + // Let's revisit this once we re-evaluate our category logic in the API. + // Ideally we should ignore this parameter entirely if the category is included. + const selectedVersion = await getProjectVersion(version, key); + + Command.debug(`selectedVersion: ${selectedVersion}`); + + // Strip out non-markdown files + const files = readdirRecursive(folder).filter( + file => file.toLowerCase().endsWith('.md') || file.toLowerCase().endsWith('.markdown') + ); + + Command.debug(`number of files: ${files.length}`); + + const { continueWithDeletion } = await promptTerminal({ + type: 'confirm', + name: 'continueWithDeletion', + message: `This command will delete all guides page from your ReadMe project (version ${selectedVersion}) that are not also in ${folder}, would you like to continue?`, + }); + + if (!continueWithDeletion) { + return Promise.reject(new Error('Aborting, no changes were made.')); + } + + const docs = await getDocs(key, selectedVersion); + const docSlugs = docs.map(({ slug }: { slug: string }) => slug); + const fileSlugs = new Set(files.map(getSlug)); + const slugsToDelete = docSlugs.filter((slug: string) => !fileSlugs.has(slug)); + const deletedDocs = await Promise.all( + slugsToDelete.map((slug: string) => deleteDoc(key, selectedVersion, dryRun, slug, this.cmdCategory)) + ); + + return Promise.resolve(chalk.green(deletedDocs.join('\n'))).then(msg => + createGHA(msg, this.command, this.args, { ...opts, version: selectedVersion }) + ); + } +} diff --git a/src/cmds/index.ts b/src/cmds/index.ts index 3a1e61554..898057ddd 100644 --- a/src/cmds/index.ts +++ b/src/cmds/index.ts @@ -5,7 +5,8 @@ import SingleChangelogCommand from './changelogs/single'; import CustomPagesCommand from './custompages'; import SingleCustomPageCommand from './custompages/single'; import DocsCommand from './docs'; -import EditDocsCommand from './docs/edit'; +import DocsEditCommand from './docs/edit'; +import DocsPruneCommand from './docs/prune'; import SingleDocCommand from './docs/single'; import LoginCommand from './login'; import LogoutCommand from './logout'; @@ -33,7 +34,8 @@ const commands = { 'custompages:single': SingleCustomPageCommand, docs: DocsCommand, - 'docs:edit': EditDocsCommand, + 'docs:prune': DocsPruneCommand, + 'docs:edit': DocsEditCommand, 'docs:single': SingleDocCommand, versions: VersionsCommand, diff --git a/src/lib/getDocs.ts b/src/lib/getDocs.ts index 7c629b1c2..4b64ee1d3 100644 --- a/src/lib/getDocs.ts +++ b/src/lib/getDocs.ts @@ -31,7 +31,7 @@ async function getCategoryDocs(key: string, selectedVersion: string, category: s } /** - * Retrieve the docs under all categories or under a specific one + * Retrieve the docs under all categories * * @param {String} key the project API key * @param {String} selectedVersion the project version diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 6d93d2021..33f1c1627 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -182,11 +182,3 @@ export function createVersionPrompt( }, ]; } - -export function deleteDocsPrompt(version: string): PromptObject { - return { - type: 'confirm', - name: 'deleteAll', - message: `This action will delete all docs under version ${version} from ReadMe, would you like to continue?`, - }; -}