Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ability to pass in API key via env vars #709

Merged
merged 11 commits into from
Dec 9, 2022
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
kanadgupta marked this conversation as resolved.
Show resolved Hide resolved

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
Expand Down
63 changes: 59 additions & 4 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,16 @@ describe('cli', () => {
await expect(cli([])).resolves.toContain('OpenAPI / Swagger');
});

describe('stored API key', () => {
describe('stored API key via configstore', () => {
kanadgupta marked this conversation as resolved.
Show resolved Hide resolved
let consoleInfoSpy;
const key = '123456';
const getCommandOutput = () => {
return [consoleInfoSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n');
};

beforeEach(() => {
conf.set('email', '[email protected]');
conf.set('project', 'project-owlbert');
conf.set('email', 'owlbert-store@readme.io');
conf.set('project', 'project-owlbert-store');
conf.set('apiKey', key);
consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation();
});
Expand Down Expand Up @@ -134,7 +134,7 @@ describe('cli', () => {
);

expect(getCommandOutput()).toMatch(
'[email protected] is currently logged in, using the stored API key for this project: project-owlbert'
'owlbert-store@readme.io is currently logged in, using the stored API key for this project: project-owlbert-store'
);
});

Expand All @@ -147,6 +147,61 @@ describe('cli', () => {
});
});

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 = '[email protected]';
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 <path> [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('');
});
});

it('should error with `rdme oas` arguments passed in', async () => {
await expect(cli(['oas', 'endpoint'])).rejects.toThrow(/.*/);
});
Expand Down
9 changes: 4 additions & 5 deletions src/cmds/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import config from 'config';
import open from 'open';

import Command, { CommandCategories } from '../lib/baseCommand';
import configStore from '../lib/configstore';
import getStoredConfig from '../lib/getStoredConfig';
import { getProjectVersion } from '../lib/versionSelect';

export interface Options {
Expand Down Expand Up @@ -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 } = getStoredConfig();
Command.debug(`project: ${project}`);

if (!project) {
Expand All @@ -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 {
Expand Down
10 changes: 5 additions & 5 deletions src/cmds/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import chalk from 'chalk';
import config from 'config';

import Command, { CommandCategories } from '../lib/baseCommand';
import configStore from '../lib/configstore';
import getStoredConfig from '../lib/getStoredConfig';

export default class WhoAmICommand extends Command {
constructor() {
Expand All @@ -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 } = getStoredConfig();

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.`
);
}
}
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 getStoredConfig from './lib/getStoredConfig';

/**
* @param {Array} processArgv - An array of arguments from the current process. Can be used to mock
Expand Down Expand Up @@ -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 } = getStoredConfig();

cmdArgv = { key, ...cmdArgv };

return bin.run(cmdArgv).then((msg: string) => {
if (bin.supportsGHA) {
Expand Down
11 changes: 7 additions & 4 deletions src/lib/baseCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { OptionDefinition } from 'command-line-usage';
import chalk from 'chalk';

import configstore from './configstore';
import getStoredConfig from './getStoredConfig';
import isCI from './isCI';
import { debug, info, warn } from './logger';
import loginFlow from './loginFlow';
Expand Down Expand Up @@ -88,12 +89,13 @@ export default class Command {
Command.debug(`opts: ${JSON.stringify(opts)}`);

if (this.args.some(arg => arg.name === 'key')) {
const { email, project } = getStoredConfig();

// 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 }
);
Expand All @@ -106,6 +108,7 @@ export default class Command {
info("Looks like you're missing a ReadMe API key, let's fix that! 🦉", { includeEmojiPrefix: false });
const result = await loginFlow();
info(result, { includeEmojiPrefix: false });
// if someone is logging in, we want to grab the value from the stored config, not the env
// eslint-disable-next-line no-param-reassign
opts.key = configstore.get('apiKey');
}
Expand Down
13 changes: 13 additions & 0 deletions src/lib/getStoredConfig.ts
Original file line number Diff line number Diff line change
@@ -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 getStoredConfig(): { apiKey?: string; email?: string; project?: string } {
kanadgupta marked this conversation as resolved.
Show resolved Hide resolved
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 };
}
6 changes: 4 additions & 2 deletions src/lib/loginFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import isEmail from 'validator/lib/isEmail';

import configStore from './configstore';
import fetch, { handleRes } from './fetch';
import getStoredConfig from './getStoredConfig';
import { debug } from './logger';
import promptTerminal from './promptWrapper';

Expand All @@ -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 = getStoredConfig();
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.';
},
Expand All @@ -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,
},
]);

Expand Down