Skip to content

Commit ce94af6

Browse files
authored
Merge pull request #22 from Mermaid-Chart/feat/support-loading-tokens-from-environment-variables
feat(cli): support reading auth token in CLI with `--auth-token` and in ENV with `MERMAID_CHART_AUTH_TOKEN`
2 parents 0e3299b + e16a4b1 commit ce94af6

File tree

4 files changed

+130
-41
lines changed

4 files changed

+130
-41
lines changed

cSpell.json

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"Cataa",
66
"colour",
77
"Cookiebot",
8+
"ENONET",
89
"gantt",
910
"jsnext",
1011
"lintstagedrc",

packages/cli/src/commander.test.ts

+38-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeAll, beforeEach, describe, expect, vi, it, type Mock } from 'vitest';
1+
import { afterEach, beforeAll, beforeEach, describe, expect, vi, it, type Mock } from 'vitest';
22
import { createCommanderCommand } from './commander.js';
33
import { copyFile, mkdir, readFile, rm } from 'node:fs/promises';
44
import type { Command, CommanderError, OutputConfiguration } from '@commander-js/extra-typings';
@@ -106,6 +106,10 @@ describe('--version', () => {
106106
});
107107

108108
describe('whoami', () => {
109+
afterEach(() => {
110+
vi.unstubAllEnvs();
111+
});
112+
109113
it('should error if --config file does not exist', async () => {
110114
const { program } = mockedProgram();
111115

@@ -136,6 +140,21 @@ describe('whoami', () => {
136140
).rejects.toThrowError('Invalid access token.');
137141
});
138142

143+
it('--auth-token should override config file and environment variables', async () => {
144+
const { program } = mockedProgram();
145+
146+
const myCliAuthToken = 'my-cli-auth-token';
147+
148+
vi.stubEnv('MERMAID_CHART_AUTH_TOKEN', 'my-env-auth-token');
149+
150+
await program.parseAsync(
151+
['--config', CONFIG_AUTHED, '--auth-token', myCliAuthToken, 'whoami'],
152+
{ from: 'user' },
153+
);
154+
155+
expect(vi.mocked(MermaidChart.prototype.setAccessToken)).toHaveBeenCalledWith(myCliAuthToken);
156+
});
157+
139158
it('should print email of logged in user', async () => {
140159
const { program } = mockedProgram();
141160

@@ -144,6 +163,21 @@ describe('whoami', () => {
144163

145164
expect(consoleLogSpy).toBeCalledWith(mockedMCUser.emailAddress);
146165
});
166+
167+
it('should support loading auth from environment variables', async () => {
168+
const { program } = mockedProgram();
169+
170+
const authToken = 'my-api-key-from-env-var';
171+
vi.stubEnv('MERMAID_CHART_AUTH_TOKEN', authToken);
172+
vi.stubEnv('MERMAID_CHART_BASE_URL', 'https://test.mermaidchart.invalid');
173+
174+
const consoleLogSpy = vi.spyOn(global.console, 'log');
175+
await program.parseAsync(['--config', CONFIG_AUTHED, 'whoami'], { from: 'user' });
176+
177+
// environment variables should override config file
178+
expect(vi.mocked(MermaidChart.prototype.setAccessToken)).toHaveBeenCalledWith(authToken);
179+
expect(consoleLogSpy).toBeCalledWith(mockedMCUser.emailAddress);
180+
});
147181
});
148182

149183
describe('login', () => {
@@ -189,7 +223,9 @@ describe('logout', () => {
189223

190224
await expect(readFile(configFile, { encoding: 'utf8' })).resolves.not.toContain('my-api-key');
191225

192-
expect(consoleLogSpy).toBeCalledWith(`API token removed from ${configFile}`);
226+
expect(consoleLogSpy).toBeCalledWith(
227+
`API token for ${mockedMCUser.emailAddress} removed from ${configFile}`,
228+
);
193229
});
194230
});
195231

packages/cli/src/commander.ts

+78-39
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import {
33
CommanderError,
44
createCommand,
55
InvalidArgumentError,
6+
Option,
67
} from '@commander-js/extra-typings';
78
import { readFile, writeFile } from 'fs/promises';
89
import { MermaidChart } from '@mermaidchart/sdk';
910
import { createRequire } from 'node:module';
1011
import confirm from '@inquirer/confirm';
1112
import input from '@inquirer/input';
1213
import select, { Separator } from '@inquirer/select';
13-
import { type Config, defaultConfigPath, readConfig, writeConfig } from './config.js';
14+
import { defaultConfigPath, readConfig, writeConfig, optionNameMap } from './config.js';
1415
import { type Cache, link, type LinkOptions, pull, push } from './methods.js';
1516
import { processMarkdown } from './remark.js';
1617

@@ -25,31 +26,40 @@ import { processMarkdown } from './remark.js';
2526
*/
2627
type CommonOptions = ReturnType<ReturnType<typeof createCommanderCommand>['opts']>;
2728

28-
async function createClient(options: CommonOptions, config?: Config) {
29-
if (config === undefined) {
30-
try {
31-
config = await readConfig(options['config']);
32-
} catch (error) {
33-
if (
34-
error instanceof Error &&
35-
'errno' in error &&
36-
(error as NodeJS.ErrnoException).code === 'ENOENT' &&
37-
options['config'] === defaultConfigPath()
38-
) {
39-
config = {};
40-
}
41-
throw new InvalidArgumentError(
42-
`Failed to load config file ${options['config']} due to: ${error}`,
43-
);
29+
/**
30+
* Reads the config file from the given `--config <configPath>` argument, ignoring
31+
* ENONET errors if `ignoreENONET` is `true`.
32+
*
33+
* @param configPath - The path to the config file.
34+
* @param ignoreENONET - Whether to ignore ENONET errors.
35+
* @throws {@link InvalidArgumentError}
36+
* Thrown if:
37+
* - The config file exists, but is not a valid TOML file.
38+
* - The config file does not exist, and `ignoreENONET` is `false`.
39+
*/
40+
async function readConfigFromConfigArg(configPath: string, ignoreENONET: boolean = false) {
41+
try {
42+
return await readConfig(configPath);
43+
} catch (error) {
44+
if (
45+
error instanceof Error &&
46+
'errno' in error &&
47+
(error as NodeJS.ErrnoException).code === 'ENOENT' &&
48+
ignoreENONET
49+
) {
50+
return {};
4451
}
52+
throw new InvalidArgumentError(`Failed to load config file ${configPath} due to: ${error}`);
4553
}
54+
}
4655

56+
async function createClient(options: CommonOptions) {
4757
const client = new MermaidChart({
4858
clientID: '018bf0ff-7e8c-7952-ab4e-a7bd7c7f54f3',
49-
baseURL: new URL(config.base_url ?? 'https://mermaidchart.com').toString(),
59+
baseURL: options.baseUrl,
5060
});
5161

52-
if (config.auth_token === undefined) {
62+
if (options.authToken === undefined) {
5363
throw new CommanderError(
5464
/*exitCode=*/ 1,
5565
'ENEEDAUTH',
@@ -58,7 +68,7 @@ async function createClient(options: CommonOptions, config?: Config) {
5868
}
5969

6070
try {
61-
await client.setAccessToken(config.auth_token);
71+
await client.setAccessToken(options.authToken);
6272
} catch (error) {
6373
if (error instanceof Error && error.message.includes('401')) {
6474
throw new CommanderError(
@@ -90,32 +100,22 @@ function login() {
90100
.description('Login to a Mermaid Chart account')
91101
.action(async (_options, command) => {
92102
const optsWithGlobals = command.optsWithGlobals<CommonOptions>();
93-
let config;
94-
try {
95-
config = await readConfig(optsWithGlobals['config']);
96-
} catch (error) {
97-
if (
98-
error instanceof Error &&
99-
'errno' in error &&
100-
(error as NodeJS.ErrnoException).code === 'ENOENT'
101-
) {
102-
config = {};
103-
} else {
104-
throw error;
105-
}
106-
}
107103

108-
const baseURL = new URL(config.base_url ?? 'https://mermaidchart.com').toString();
104+
// empty if default config file doesn't exist
105+
const config = await readConfigFromConfigArg(
106+
optsWithGlobals['config'],
107+
/*ignoreENONET=*/ true,
108+
);
109109

110110
const client = new MermaidChart({
111111
clientID: '018bf0ff-7e8c-7952-ab4e-a7bd7c7f54f3',
112-
baseURL: baseURL,
112+
baseURL: optsWithGlobals.baseUrl,
113113
});
114114

115115
const answer = await input({
116116
message: `Enter your API token. You can generate one at ${new URL(
117117
'/app/user/settings',
118-
baseURL,
118+
optsWithGlobals.baseUrl,
119119
)}`,
120120
async validate(key) {
121121
try {
@@ -149,7 +149,7 @@ function logout() {
149149
await writeConfig(optsWithGlobals['config'], config);
150150

151151
try {
152-
const user = await (await createClient(optsWithGlobals, config)).getUser();
152+
const user = await (await createClient(optsWithGlobals)).getUser();
153153
console.log(`API token for ${user.emailAddress} removed from ${optsWithGlobals['config']}`);
154154
} catch (error) {
155155
// API token might have been expired
@@ -297,7 +297,46 @@ export function createCommanderCommand() {
297297
'-c, --config <config_file>',
298298
'The path to the config file to use.',
299299
defaultConfigPath(),
300-
);
300+
)
301+
.addOption(
302+
new Option('--base-url <base_url>', 'The base URL of the Mermaid Chart instance to use.')
303+
.default('https://mermaidchart.com')
304+
.env('MERMAID_CHART_BASE_URL'),
305+
)
306+
.addOption(
307+
new Option('--auth-token <auth_token>', 'The Mermaid Chart API token to use.').env(
308+
'MERMAID_CHART_AUTH_TOKEN',
309+
),
310+
)
311+
.hook('preSubcommand', async (command, actionCommand) => {
312+
const configPath = command.getOptionValue('config');
313+
/**
314+
* config file is allowed to not exist if:
315+
* - the user is running the `login` command, we'll create a new config file if it doesn't exist
316+
*/
317+
const ignoreENONET = configPath === defaultConfigPath() || actionCommand.name() === 'login';
318+
319+
const config = await readConfigFromConfigArg(configPath, ignoreENONET);
320+
for (const key in config) {
321+
if (!(key in optionNameMap)) {
322+
console.warn(`Warning: Ignoring unrecognized config key: ${key} in ${configPath}`);
323+
continue;
324+
}
325+
const optionCommanderName = optionNameMap[key as keyof typeof optionNameMap];
326+
// config values only override default/implied values
327+
if (
328+
[undefined, 'default', 'implied'].includes(
329+
command.getOptionValueSource(optionCommanderName),
330+
)
331+
) {
332+
command.setOptionValueWithSource(
333+
optionNameMap[key as keyof typeof optionNameMap],
334+
config[key],
335+
'config',
336+
);
337+
}
338+
}
339+
});
301340

302341
return program
303342
.addCommand(whoami())

packages/cli/src/config.ts

+13
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ export function defaultConfigPath() {
3838
return join(userConfigDir(), 'mermaid-chart.toml');
3939
}
4040

41+
/**
42+
* Maps the TOML keys to the Commander option names.
43+
*
44+
* Commander uses:
45+
* - kebab-case for CLI options, but
46+
* - camelCase for option names in JavaScript,
47+
* while TOML uses snake_case.
48+
*/
49+
export const optionNameMap = {
50+
auth_token: 'authToken',
51+
base_url: 'baseUrl',
52+
} as const;
53+
4154
export interface Config extends JsonMap {
4255
/**
4356
* Mermaid-Chart API token.

0 commit comments

Comments
 (0)