diff --git a/README.md b/README.md index 2d4719d62..15b171ee1 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,12 @@ For local CLI usage with a single project, you can authenticate `rdme` to your R > **Warning** > For security reasons, we strongly recommend providing a project API key via the `--key` option in automations or CI environments (GitHub Actions, CircleCI, Travis CI, etc.). It's also recommended if you're working with multiple ReadMe projects to avoid accidentally overwriting existing data. +You can also pass in your API key via the `RDME_API_KEY` environmental variable. Here is the order of precedence when passing your API key into `rdme`: + +1. The `--key` option. If that isn't present, we look for... +1. The `RDME_API_KEY` environmental variable. If that isn't present, we look for... +1. The API key value stored in your local configuration file (i.e., the one set via `rdme login`) + `rdme whoami` is also available to you to determine who is logged in, and to what project. You can clear your stored credentials with `rdme logout`. ### Proxy diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 41a99db9f..379f35e3b 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -93,57 +93,114 @@ describe('cli', () => { }); describe('stored API key', () => { - let consoleInfoSpy; - const key = '123456'; - const getCommandOutput = () => { - return [consoleInfoSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n'); - }; - - beforeEach(() => { - conf.set('email', 'owlbert@readme.io'); - conf.set('project', 'project-owlbert'); - conf.set('apiKey', key); - consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(); - }); - - afterEach(() => { - consoleInfoSpy.mockRestore(); - conf.clear(); - }); - - it('should add stored apiKey to opts', async () => { - expect.assertions(1); - const version = '1.0.0'; - - const versionMock = getAPIMock() - .get(`/api/v1/version/${version}`) - .basicAuth({ user: key }) - .reply(200, { version }); - - await expect(cli(['docs', `--version=${version}`])).rejects.toStrictEqual( - new Error('No path provided. Usage `rdme docs [options]`.') - ); - - conf.clear(); - versionMock.done(); - }); - - it('should inform a logged in user which project is being updated', async () => { - await expect(cli(['openapi', '--create', '--update'])).rejects.toThrow( - 'The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!' - ); - - expect(getCommandOutput()).toMatch( - 'owlbert@readme.io is currently logged in, using the stored API key for this project: project-owlbert' - ); - }); - - it('should not inform a logged in user when they pass their own key', async () => { - await expect(cli(['openapi', '--create', '--update', '--key=asdf'])).rejects.toThrow( - 'The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!' - ); - - expect(getCommandOutput()).toBe(''); + describe('stored API key via configstore', () => { + let consoleInfoSpy; + const key = '123456'; + const getCommandOutput = () => { + return [consoleInfoSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n'); + }; + + beforeEach(() => { + conf.set('email', 'owlbert-store@readme.io'); + conf.set('project', 'project-owlbert-store'); + conf.set('apiKey', key); + consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(); + }); + + afterEach(() => { + consoleInfoSpy.mockRestore(); + conf.clear(); + }); + + it('should add stored apiKey to opts', async () => { + expect.assertions(1); + const version = '1.0.0'; + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect(cli(['docs', `--version=${version}`])).rejects.toStrictEqual( + new Error('No path provided. Usage `rdme docs [options]`.') + ); + + conf.clear(); + versionMock.done(); + }); + + it('should inform a logged in user which project is being updated', async () => { + await expect(cli(['openapi', '--create', '--update'])).rejects.toThrow( + 'The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!' + ); + + expect(getCommandOutput()).toMatch( + 'owlbert-store@readme.io is currently logged in, using the stored API key for this project: project-owlbert-store' + ); + }); + + it('should not inform a logged in user when they pass their own key', async () => { + await expect(cli(['openapi', '--create', '--update', '--key=asdf'])).rejects.toThrow( + 'The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!' + ); + + expect(getCommandOutput()).toBe(''); + }); + }); + + describe('stored API key via env vars', () => { + let consoleInfoSpy; + const key = '123456-env'; + const getCommandOutput = () => { + return [consoleInfoSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n'); + }; + + beforeEach(() => { + process.env.RDME_API_KEY = key; + process.env.RDME_EMAIL = 'owlbert-env@readme.io'; + process.env.RDME_PROJECT = 'project-owlbert-env'; + consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(); + }); + + afterEach(() => { + consoleInfoSpy.mockRestore(); + delete process.env.RDME_API_KEY; + delete process.env.RDME_EMAIL; + delete process.env.RDME_PROJECT; + }); + + it('should add stored apiKey to opts', async () => { + expect.assertions(1); + const version = '1.0.0'; + + const versionMock = getAPIMock() + .get(`/api/v1/version/${version}`) + .basicAuth({ user: key }) + .reply(200, { version }); + + await expect(cli(['docs', `--version=${version}`])).rejects.toStrictEqual( + new Error('No path provided. Usage `rdme docs [options]`.') + ); + + conf.clear(); + versionMock.done(); + }); + + it('should not inform a logged in user which project is being updated', async () => { + await expect(cli(['openapi', '--create', '--update'])).rejects.toThrow( + 'The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!' + ); + + expect(getCommandOutput()).toBe(''); + }); + + it('should not inform a logged in user when they pass their own key', async () => { + await expect(cli(['openapi', '--create', '--update', '--key=asdf'])).rejects.toThrow( + 'The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!' + ); + + expect(getCommandOutput()).toBe(''); + }); }); }); diff --git a/src/cmds/open.ts b/src/cmds/open.ts index 131f83c45..a8805adc8 100644 --- a/src/cmds/open.ts +++ b/src/cmds/open.ts @@ -5,7 +5,7 @@ import config from 'config'; import open from 'open'; import Command, { CommandCategories } from '../lib/baseCommand'; -import configStore from '../lib/configstore'; +import getCurrentConfig from '../lib/getCurrentConfig'; import { getProjectVersion } from '../lib/versionSelect'; export interface Options { @@ -35,7 +35,7 @@ export default class OpenCommand extends Command { await super.run(opts); const { dash } = opts; - const project = configStore.get('project'); + const { apiKey, project } = getCurrentConfig(); Command.debug(`project: ${project}`); if (!project) { @@ -45,12 +45,11 @@ export default class OpenCommand extends Command { let url: string; if (dash) { - const key = configStore.get('apiKey'); - if (!key) { + if (!apiKey) { return Promise.reject(new Error(`Please login using \`${config.get('cli')} login\`.`)); } - const selectedVersion = await getProjectVersion(undefined, key, true); + const selectedVersion = await getProjectVersion(undefined, apiKey, true); const dashURL: string = config.get('host'); url = `${dashURL}/project/${project}/v${selectedVersion}/overview`; } else { diff --git a/src/cmds/whoami.ts b/src/cmds/whoami.ts index ebf2588fa..decdca639 100644 --- a/src/cmds/whoami.ts +++ b/src/cmds/whoami.ts @@ -4,7 +4,7 @@ import chalk from 'chalk'; import config from 'config'; import Command, { CommandCategories } from '../lib/baseCommand'; -import configStore from '../lib/configstore'; +import getCurrentConfig from '../lib/getCurrentConfig'; export default class WhoAmICommand extends Command { constructor() { @@ -21,14 +21,14 @@ export default class WhoAmICommand extends Command { async run(opts: CommandOptions<{}>) { await super.run(opts); - if (!configStore.has('email') || !configStore.has('project')) { + const { email, project } = getCurrentConfig(); + + if (!email || !project) { return Promise.reject(new Error(`Please login using \`${config.get('cli')} login\`.`)); } return Promise.resolve( - `You are currently logged in as ${chalk.green(configStore.get('email'))} to the ${chalk.blue( - configStore.get('project') - )} project.` + `You are currently logged in as ${chalk.green(email)} to the ${chalk.blue(project)} project.` ); } } diff --git a/src/index.ts b/src/index.ts index bf16c92e3..44d9bdc43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,12 +21,12 @@ process.env.NODE_CONFIG_DIR = configDir; import { version } from '../package.json'; import * as commands from './lib/commands'; -import configStore from './lib/configstore'; import * as help from './lib/help'; import { debug } from './lib/logger'; import createGHA from './lib/createGHA'; import type Command from './lib/baseCommand'; import type { CommandOptions } from './lib/baseCommand'; +import getCurrentConfig from './lib/getCurrentConfig'; /** * @param {Array} processArgv - An array of arguments from the current process. Can be used to mock @@ -117,7 +117,9 @@ export default function rdme(processArgv: NodeJS.Process['argv']) { cmdArgv = cliArgs(bin.args, { partial: true, argv: processArgv.slice(1) }); } - cmdArgv = { key: configStore.get('apiKey'), ...cmdArgv }; + const { apiKey: key } = getCurrentConfig(); + + cmdArgv = { key, ...cmdArgv }; return bin.run(cmdArgv).then((msg: string) => { if (bin.supportsGHA) { diff --git a/src/lib/baseCommand.ts b/src/lib/baseCommand.ts index b766c6e06..21eeed2f0 100644 --- a/src/lib/baseCommand.ts +++ b/src/lib/baseCommand.ts @@ -6,6 +6,7 @@ import type { OptionDefinition } from 'command-line-usage'; import chalk from 'chalk'; import configstore from './configstore'; +import getCurrentConfig from './getCurrentConfig'; import isCI from './isCI'; import { debug, info, warn } from './logger'; import loginFlow from './loginFlow'; @@ -88,12 +89,13 @@ export default class Command { Command.debug(`opts: ${JSON.stringify(opts)}`); if (this.args.some(arg => arg.name === 'key')) { + const { apiKey, email, project } = getCurrentConfig(); + + // We only want to log this if the API key is stored in the configstore, **not** in an env var. if (opts.key && configstore.get('apiKey') === opts.key) { info( - `🔑 ${chalk.green( - configstore.get('email') - )} is currently logged in, using the stored API key for this project: ${chalk.blue( - configstore.get('project') + `🔑 ${chalk.green(email)} is currently logged in, using the stored API key for this project: ${chalk.blue( + project )}`, { includeEmojiPrefix: false } ); @@ -107,7 +109,7 @@ export default class Command { const result = await loginFlow(); info(result, { includeEmojiPrefix: false }); // eslint-disable-next-line no-param-reassign - opts.key = configstore.get('apiKey'); + opts.key = apiKey; } } diff --git a/src/lib/getCurrentConfig.ts b/src/lib/getCurrentConfig.ts new file mode 100644 index 000000000..721af526a --- /dev/null +++ b/src/lib/getCurrentConfig.ts @@ -0,0 +1,13 @@ +import configstore from './configstore'; + +/** + * Retrieves stored user data values from env variables or configstore, + * with env variables taking precedent + */ +export default function getCurrentConfig(): { apiKey?: string; email?: string; project?: string } { + const apiKey = process.env.RDME_API_KEY || configstore.get('apiKey'); + const email = process.env.RDME_EMAIL || configstore.get('email'); + const project = process.env.RDME_PROJECT || configstore.get('project'); + + return { apiKey, email, project }; +} diff --git a/src/lib/loginFlow.ts b/src/lib/loginFlow.ts index af0784e91..d0e0c22d3 100644 --- a/src/lib/loginFlow.ts +++ b/src/lib/loginFlow.ts @@ -4,6 +4,7 @@ import isEmail from 'validator/lib/isEmail'; import configStore from './configstore'; import fetch, { handleRes } from './fetch'; +import getCurrentConfig from './getCurrentConfig'; import { debug } from './logger'; import promptTerminal from './promptWrapper'; @@ -29,12 +30,13 @@ function loginFetch(body: LoginBody) { * @returns A Promise-wrapped string with the logged-in user's credentials */ export default async function loginFlow() { + const storedConfig = getCurrentConfig(); const { email, password, project } = await promptTerminal([ { type: 'text', name: 'email', message: 'What is your email address?', - initial: configStore.get('email'), + initial: storedConfig.email, validate(val) { return isEmail(val) ? true : 'Please provide a valid email address.'; }, @@ -48,7 +50,7 @@ export default async function loginFlow() { type: 'text', name: 'project', message: 'What project are you logging into?', - initial: configStore.get('project'), + initial: storedConfig.project, }, ]);