Skip to content

Commit 16216d1

Browse files
authored
feat: ability to pass in API key via env vars (#709)
* feat: env vars for API key, etc. * fix: confine tests to configstore * test: add tests for env var-based keys * docs: some docs explaining auth precedence * fix: only use configstore in warning * chore: remove unnecessary var definition * fix: use configstore value * fix: lint * revert: i changed my mind about this ugh it's unlikely that the user won't have a key and that the env will mess with this, so let's just use this logic to keep everything tidy * test: single describe block feedback: #709 (comment) * chore: rename func feedback: #709 (comment)
1 parent 75aaba2 commit 16216d1

File tree

8 files changed

+151
-70
lines changed

8 files changed

+151
-70
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ For local CLI usage with a single project, you can authenticate `rdme` to your R
9898
> **Warning**
9999
> 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.
100100
101+
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`:
102+
103+
1. The `--key` option. If that isn't present, we look for...
104+
1. The `RDME_API_KEY` environmental variable. If that isn't present, we look for...
105+
1. The API key value stored in your local configuration file (i.e., the one set via `rdme login`)
106+
101107
`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`.
102108

103109
### Proxy

__tests__/index.test.ts

Lines changed: 108 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -93,57 +93,114 @@ describe('cli', () => {
9393
});
9494

9595
describe('stored API key', () => {
96-
let consoleInfoSpy;
97-
const key = '123456';
98-
const getCommandOutput = () => {
99-
return [consoleInfoSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n');
100-
};
101-
102-
beforeEach(() => {
103-
conf.set('email', 'owlbert@readme.io');
104-
conf.set('project', 'project-owlbert');
105-
conf.set('apiKey', key);
106-
consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation();
107-
});
108-
109-
afterEach(() => {
110-
consoleInfoSpy.mockRestore();
111-
conf.clear();
112-
});
113-
114-
it('should add stored apiKey to opts', async () => {
115-
expect.assertions(1);
116-
const version = '1.0.0';
117-
118-
const versionMock = getAPIMock()
119-
.get(`/api/v1/version/${version}`)
120-
.basicAuth({ user: key })
121-
.reply(200, { version });
122-
123-
await expect(cli(['docs', `--version=${version}`])).rejects.toStrictEqual(
124-
new Error('No path provided. Usage `rdme docs <path> [options]`.')
125-
);
126-
127-
conf.clear();
128-
versionMock.done();
129-
});
130-
131-
it('should inform a logged in user which project is being updated', async () => {
132-
await expect(cli(['openapi', '--create', '--update'])).rejects.toThrow(
133-
'The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!'
134-
);
135-
136-
expect(getCommandOutput()).toMatch(
137-
'owlbert@readme.io is currently logged in, using the stored API key for this project: project-owlbert'
138-
);
139-
});
140-
141-
it('should not inform a logged in user when they pass their own key', async () => {
142-
await expect(cli(['openapi', '--create', '--update', '--key=asdf'])).rejects.toThrow(
143-
'The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!'
144-
);
145-
146-
expect(getCommandOutput()).toBe('');
96+
describe('stored API key via configstore', () => {
97+
let consoleInfoSpy;
98+
const key = '123456';
99+
const getCommandOutput = () => {
100+
return [consoleInfoSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n');
101+
};
102+
103+
beforeEach(() => {
104+
conf.set('email', 'owlbert-store@readme.io');
105+
conf.set('project', 'project-owlbert-store');
106+
conf.set('apiKey', key);
107+
consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation();
108+
});
109+
110+
afterEach(() => {
111+
consoleInfoSpy.mockRestore();
112+
conf.clear();
113+
});
114+
115+
it('should add stored apiKey to opts', async () => {
116+
expect.assertions(1);
117+
const version = '1.0.0';
118+
119+
const versionMock = getAPIMock()
120+
.get(`/api/v1/version/${version}`)
121+
.basicAuth({ user: key })
122+
.reply(200, { version });
123+
124+
await expect(cli(['docs', `--version=${version}`])).rejects.toStrictEqual(
125+
new Error('No path provided. Usage `rdme docs <path> [options]`.')
126+
);
127+
128+
conf.clear();
129+
versionMock.done();
130+
});
131+
132+
it('should inform a logged in user which project is being updated', async () => {
133+
await expect(cli(['openapi', '--create', '--update'])).rejects.toThrow(
134+
'The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!'
135+
);
136+
137+
expect(getCommandOutput()).toMatch(
138+
'owlbert-store@readme.io is currently logged in, using the stored API key for this project: project-owlbert-store'
139+
);
140+
});
141+
142+
it('should not inform a logged in user when they pass their own key', async () => {
143+
await expect(cli(['openapi', '--create', '--update', '--key=asdf'])).rejects.toThrow(
144+
'The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!'
145+
);
146+
147+
expect(getCommandOutput()).toBe('');
148+
});
149+
});
150+
151+
describe('stored API key via env vars', () => {
152+
let consoleInfoSpy;
153+
const key = '123456-env';
154+
const getCommandOutput = () => {
155+
return [consoleInfoSpy.mock.calls.join('\n\n')].filter(Boolean).join('\n\n');
156+
};
157+
158+
beforeEach(() => {
159+
process.env.RDME_API_KEY = key;
160+
process.env.RDME_EMAIL = 'owlbert-env@readme.io';
161+
process.env.RDME_PROJECT = 'project-owlbert-env';
162+
consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation();
163+
});
164+
165+
afterEach(() => {
166+
consoleInfoSpy.mockRestore();
167+
delete process.env.RDME_API_KEY;
168+
delete process.env.RDME_EMAIL;
169+
delete process.env.RDME_PROJECT;
170+
});
171+
172+
it('should add stored apiKey to opts', async () => {
173+
expect.assertions(1);
174+
const version = '1.0.0';
175+
176+
const versionMock = getAPIMock()
177+
.get(`/api/v1/version/${version}`)
178+
.basicAuth({ user: key })
179+
.reply(200, { version });
180+
181+
await expect(cli(['docs', `--version=${version}`])).rejects.toStrictEqual(
182+
new Error('No path provided. Usage `rdme docs <path> [options]`.')
183+
);
184+
185+
conf.clear();
186+
versionMock.done();
187+
});
188+
189+
it('should not inform a logged in user which project is being updated', async () => {
190+
await expect(cli(['openapi', '--create', '--update'])).rejects.toThrow(
191+
'The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!'
192+
);
193+
194+
expect(getCommandOutput()).toBe('');
195+
});
196+
197+
it('should not inform a logged in user when they pass their own key', async () => {
198+
await expect(cli(['openapi', '--create', '--update', '--key=asdf'])).rejects.toThrow(
199+
'The `--create` and `--update` options cannot be used simultaneously. Please use one or the other!'
200+
);
201+
202+
expect(getCommandOutput()).toBe('');
203+
});
147204
});
148205
});
149206

src/cmds/open.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import config from 'config';
55
import open from 'open';
66

77
import Command, { CommandCategories } from '../lib/baseCommand';
8-
import configStore from '../lib/configstore';
8+
import getCurrentConfig from '../lib/getCurrentConfig';
99
import { getProjectVersion } from '../lib/versionSelect';
1010

1111
export interface Options {
@@ -35,7 +35,7 @@ export default class OpenCommand extends Command {
3535
await super.run(opts);
3636

3737
const { dash } = opts;
38-
const project = configStore.get('project');
38+
const { apiKey, project } = getCurrentConfig();
3939
Command.debug(`project: ${project}`);
4040

4141
if (!project) {
@@ -45,12 +45,11 @@ export default class OpenCommand extends Command {
4545
let url: string;
4646

4747
if (dash) {
48-
const key = configStore.get('apiKey');
49-
if (!key) {
48+
if (!apiKey) {
5049
return Promise.reject(new Error(`Please login using \`${config.get('cli')} login\`.`));
5150
}
5251

53-
const selectedVersion = await getProjectVersion(undefined, key, true);
52+
const selectedVersion = await getProjectVersion(undefined, apiKey, true);
5453
const dashURL: string = config.get('host');
5554
url = `${dashURL}/project/${project}/v${selectedVersion}/overview`;
5655
} else {

src/cmds/whoami.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import chalk from 'chalk';
44
import config from 'config';
55

66
import Command, { CommandCategories } from '../lib/baseCommand';
7-
import configStore from '../lib/configstore';
7+
import getCurrentConfig from '../lib/getCurrentConfig';
88

99
export default class WhoAmICommand extends Command {
1010
constructor() {
@@ -21,14 +21,14 @@ export default class WhoAmICommand extends Command {
2121
async run(opts: CommandOptions<{}>) {
2222
await super.run(opts);
2323

24-
if (!configStore.has('email') || !configStore.has('project')) {
24+
const { email, project } = getCurrentConfig();
25+
26+
if (!email || !project) {
2527
return Promise.reject(new Error(`Please login using \`${config.get('cli')} login\`.`));
2628
}
2729

2830
return Promise.resolve(
29-
`You are currently logged in as ${chalk.green(configStore.get('email'))} to the ${chalk.blue(
30-
configStore.get('project')
31-
)} project.`
31+
`You are currently logged in as ${chalk.green(email)} to the ${chalk.blue(project)} project.`
3232
);
3333
}
3434
}

src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ process.env.NODE_CONFIG_DIR = configDir;
2121
import { version } from '../package.json';
2222

2323
import * as commands from './lib/commands';
24-
import configStore from './lib/configstore';
2524
import * as help from './lib/help';
2625
import { debug } from './lib/logger';
2726
import createGHA from './lib/createGHA';
2827
import type Command from './lib/baseCommand';
2928
import type { CommandOptions } from './lib/baseCommand';
29+
import getCurrentConfig from './lib/getCurrentConfig';
3030

3131
/**
3232
* @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']) {
117117
cmdArgv = cliArgs(bin.args, { partial: true, argv: processArgv.slice(1) });
118118
}
119119

120-
cmdArgv = { key: configStore.get('apiKey'), ...cmdArgv };
120+
const { apiKey: key } = getCurrentConfig();
121+
122+
cmdArgv = { key, ...cmdArgv };
121123

122124
return bin.run(cmdArgv).then((msg: string) => {
123125
if (bin.supportsGHA) {

src/lib/baseCommand.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { OptionDefinition } from 'command-line-usage';
66
import chalk from 'chalk';
77

88
import configstore from './configstore';
9+
import getCurrentConfig from './getCurrentConfig';
910
import isCI from './isCI';
1011
import { debug, info, warn } from './logger';
1112
import loginFlow from './loginFlow';
@@ -88,12 +89,13 @@ export default class Command {
8889
Command.debug(`opts: ${JSON.stringify(opts)}`);
8990

9091
if (this.args.some(arg => arg.name === 'key')) {
92+
const { apiKey, email, project } = getCurrentConfig();
93+
94+
// We only want to log this if the API key is stored in the configstore, **not** in an env var.
9195
if (opts.key && configstore.get('apiKey') === opts.key) {
9296
info(
93-
`🔑 ${chalk.green(
94-
configstore.get('email')
95-
)} is currently logged in, using the stored API key for this project: ${chalk.blue(
96-
configstore.get('project')
97+
`🔑 ${chalk.green(email)} is currently logged in, using the stored API key for this project: ${chalk.blue(
98+
project
9799
)}`,
98100
{ includeEmojiPrefix: false }
99101
);
@@ -107,7 +109,7 @@ export default class Command {
107109
const result = await loginFlow();
108110
info(result, { includeEmojiPrefix: false });
109111
// eslint-disable-next-line no-param-reassign
110-
opts.key = configstore.get('apiKey');
112+
opts.key = apiKey;
111113
}
112114
}
113115

src/lib/getCurrentConfig.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import configstore from './configstore';
2+
3+
/**
4+
* Retrieves stored user data values from env variables or configstore,
5+
* with env variables taking precedent
6+
*/
7+
export default function getCurrentConfig(): { apiKey?: string; email?: string; project?: string } {
8+
const apiKey = process.env.RDME_API_KEY || configstore.get('apiKey');
9+
const email = process.env.RDME_EMAIL || configstore.get('email');
10+
const project = process.env.RDME_PROJECT || configstore.get('project');
11+
12+
return { apiKey, email, project };
13+
}

src/lib/loginFlow.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import isEmail from 'validator/lib/isEmail';
44

55
import configStore from './configstore';
66
import fetch, { handleRes } from './fetch';
7+
import getCurrentConfig from './getCurrentConfig';
78
import { debug } from './logger';
89
import promptTerminal from './promptWrapper';
910

@@ -29,12 +30,13 @@ function loginFetch(body: LoginBody) {
2930
* @returns A Promise-wrapped string with the logged-in user's credentials
3031
*/
3132
export default async function loginFlow() {
33+
const storedConfig = getCurrentConfig();
3234
const { email, password, project } = await promptTerminal([
3335
{
3436
type: 'text',
3537
name: 'email',
3638
message: 'What is your email address?',
37-
initial: configStore.get('email'),
39+
initial: storedConfig.email,
3840
validate(val) {
3941
return isEmail(val) ? true : 'Please provide a valid email address.';
4042
},
@@ -48,7 +50,7 @@ export default async function loginFlow() {
4850
type: 'text',
4951
name: 'project',
5052
message: 'What project are you logging into?',
51-
initial: configStore.get('project'),
53+
initial: storedConfig.project,
5254
},
5355
]);
5456

0 commit comments

Comments
 (0)