From 3e1914982c767757f0a81957d4fa07df05348427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lehoczky=20Zolt=C3=A1n?= Date: Sat, 24 Feb 2024 13:27:45 +0100 Subject: [PATCH 1/4] feat: automatically load environmental variables --- package-lock.json | 17 +++++++++++++++++ package.json | 1 + src/index.ts | 48 ++++++++++++++++++++++++++++++++++++++++++----- src/options.ts | 14 +++++++++++--- 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index e662e03..cf2df1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "base32-decode": "^1.0.0", "commander": "^11.0.0", "cosmiconfig": "^8.2.0", + "dotenv": "^16.4.5", "form-data": "^4.0.0", "glob": "^10.3.3", "json5": "^2.2.3", @@ -3551,6 +3552,17 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -14826,6 +14838,11 @@ "is-obj": "^2.0.0" } }, + "dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==" + }, "duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", diff --git a/package.json b/package.json index 604ecbb..0e83983 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "base32-decode": "^1.0.0", "commander": "^11.0.0", "cosmiconfig": "^8.2.0", + "dotenv": "^16.4.5", "form-data": "^4.0.0", "glob": "^10.3.3", "json5": "^2.2.3", diff --git a/src/index.ts b/src/index.ts index ff5529a..6a0da0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,15 @@ import { error, } from './utils/logger.js'; -import { API_KEY_OPT, API_URL_OPT, PROJECT_ID_OPT } from './options.js'; +import { + API_KEY_OPT, + API_URL_OPT, + BaseOptions, + ENV_OPT, + PROJECT_ID_OPT, + parseProjectId, + parseUrlArgument, +} from './options.js'; import { API_KEY_PAK_PREFIX, API_KEY_PAT_PREFIX, @@ -29,8 +37,9 @@ import PullCommand from './commands/pull.js'; import ExtractCommand from './commands/extract.js'; import CompareCommand from './commands/sync/compare.js'; import SyncCommand from './commands/sync/sync.js'; - -const NO_KEY_COMMANDS = ['login', 'logout', 'extract']; +import path from 'path'; +import fs from 'fs'; +import dotenv from 'dotenv'; ansi.enabled = process.stdout.isTTY; @@ -107,6 +116,8 @@ function validateOptions(cmd: Command) { } async function preHandler(prog: Command, cmd: Command) { + const NO_KEY_COMMANDS = ['login', 'logout', 'extract']; + if (!NO_KEY_COMMANDS.includes(topLevelName(cmd))) { await loadApiKey(cmd); loadProjectId(cmd); @@ -126,14 +137,42 @@ async function preHandler(prog: Command, cmd: Command) { setDebug(prog.opts().verbose); } +function loadEnvironmentalVariables(program: Command) { + const options: BaseOptions = program.optsWithGlobals(); + const envFilePath = path.resolve(process.cwd(), options.env); + + if (fs.existsSync(envFilePath)) { + dotenv.config({ path: envFilePath }); + + if (process.env.TOLGEE_API_KEY) { + program.setOptionValue('apiKey', process.env.TOLGEE_API_KEY); + } + if (process.env.TOLGEE_API_URL) { + program.setOptionValue( + 'apiUrl', + parseUrlArgument(process.env.TOLGEE_API_URL) + ); + } + if (process.env.TOLGEE_PROJECT_ID) { + program.setOptionValue( + 'projectId', + parseProjectId(process.env.TOLGEE_PROJECT_ID) + ); + } + } +} + const program = new Command('tolgee') .version(VERSION) .configureOutput({ writeErr: error }) .description('Command Line Interface to interact with the Tolgee Platform') .option('-v, --verbose', 'Enable verbose logging.') + .hook('preAction', loadEnvironmentalVariables) + .hook('preAction', loadConfig) .hook('preAction', preHandler); // Global options +program.addOption(ENV_OPT); program.addOption(API_URL_OPT); program.addOption(API_KEY_OPT); program.addOption(PROJECT_ID_OPT); @@ -147,7 +186,7 @@ program.addCommand(ExtractCommand); program.addCommand(CompareCommand); program.addCommand(SyncCommand); -async function loadConfig() { +async function loadConfig(program: Command) { const tgConfig = await loadTolgeeRc(); if (tgConfig) { for (const [key, value] of Object.entries(tgConfig)) { @@ -184,7 +223,6 @@ async function handleHttpError(e: HttpError) { async function run() { try { - await loadConfig(); await program.parseAsync(); } catch (e: any) { if (e instanceof HttpError) { diff --git a/src/options.ts b/src/options.ts index a7ce237..3805d3f 100644 --- a/src/options.ts +++ b/src/options.ts @@ -4,7 +4,7 @@ import { resolve } from 'path'; import { Option, InvalidArgumentError } from 'commander'; import { DEFAULT_API_URL } from './constants.js'; -function parseProjectId(v: string) { +export function parseProjectId(v: string) { const val = Number(v); if (!Number.isInteger(val) || val < 1) { throw new InvalidArgumentError('Not a valid project ID.'); @@ -12,7 +12,7 @@ function parseProjectId(v: string) { return val; } -function parseUrlArgument(v: string) { +export function parseUrlArgument(v: string) { try { return new URL(v); } catch { @@ -20,7 +20,7 @@ function parseUrlArgument(v: string) { } } -function parsePath(v: string) { +export function parsePath(v: string) { const path = resolve(v); if (!existsSync(path)) { throw new InvalidArgumentError(`The specified path "${v}" does not exist.`); @@ -33,6 +33,7 @@ export type BaseOptions = { apiUrl: URL; apiKey: string; projectId: number; + env: string; client: Client; }; @@ -45,6 +46,7 @@ export const PROJECT_ID_OPT = new Option( '-p, --project-id ', 'Project ID. Only required when using a Personal Access Token.' ) + .env('TOLGEE_PROJECT_ID') .default(-1) .argParser(parseProjectId); @@ -52,9 +54,15 @@ export const API_URL_OPT = new Option( '-au, --api-url ', 'The url of Tolgee API.' ) + .env('TOLGEE_API_URL') .default(DEFAULT_API_URL) .argParser(parseUrlArgument); +export const ENV_OPT = new Option( + '--env ', + `Environment file to load variable from.` +).default('.env'); + export const EXTRACTOR = new Option( '-e, --extractor ', `A path to a custom extractor to use instead of the default one.` From 288cf5d715319c62f3f09eab8407541714ac40b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lehoczky=20Zolt=C3=A1n?= Date: Mon, 26 Feb 2024 20:11:23 +0100 Subject: [PATCH 2/4] refactor: move program creation to separate file for easier testing --- src/index.ts | 195 ++----------------------------------------------- src/program.ts | 188 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 190 deletions(-) create mode 100644 src/program.ts diff --git a/src/index.ts b/src/index.ts index 6a0da0a..a15bca5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,199 +3,13 @@ import { Command } from 'commander'; import ansi from 'ansi-colors'; -import { getApiKey, savePak, savePat } from './config/credentials.js'; -import loadTolgeeRc from './config/tolgeerc.js'; - -import RestClient from './client/index.js'; import { HttpError } from './client/errors.js'; -import { - setDebug, - isDebugEnabled, - debug, - info, - error, -} from './utils/logger.js'; - -import { - API_KEY_OPT, - API_URL_OPT, - BaseOptions, - ENV_OPT, - PROJECT_ID_OPT, - parseProjectId, - parseUrlArgument, -} from './options.js'; -import { - API_KEY_PAK_PREFIX, - API_KEY_PAT_PREFIX, - VERSION, -} from './constants.js'; - -import { Login, Logout } from './commands/login.js'; -import PushCommand from './commands/push.js'; -import PullCommand from './commands/pull.js'; -import ExtractCommand from './commands/extract.js'; -import CompareCommand from './commands/sync/compare.js'; -import SyncCommand from './commands/sync/sync.js'; -import path from 'path'; -import fs from 'fs'; -import dotenv from 'dotenv'; +import { isDebugEnabled, debug, info, error } from './utils/logger.js'; +import { createProgram } from './program.js'; ansi.enabled = process.stdout.isTTY; -function topLevelName(command: Command): string { - return command.parent && command.parent.parent - ? topLevelName(command.parent) - : command.name(); -} - -async function loadApiKey(cmd: Command) { - const opts = cmd.optsWithGlobals(); - - // API Key is already loaded - if (opts.apiKey) return; - - // Attempt to load --api-key from config store if not specified - // This is not done as part of the init routine or via the mandatory flag, as this is dependent on the API URL. - const key = await getApiKey(opts.apiUrl, opts.projectId); - - // No key in store, stop here. - if (!key) return; - - cmd.setOptionValue('apiKey', key); - program.setOptionValue('_removeApiKeyFromStore', () => { - if (key.startsWith(API_KEY_PAT_PREFIX)) { - savePat(opts.apiUrl); - } else { - savePak(opts.apiUrl, opts.projectId); - } - }); -} - -function loadProjectId(cmd: Command) { - const opts = cmd.optsWithGlobals(); - - if (opts.apiKey?.startsWith(API_KEY_PAK_PREFIX)) { - // Parse the key and ensure we can access the specified Project ID - const projectId = RestClient.projectIdFromKey(opts.apiKey); - program.setOptionValue('projectId', projectId); - - if (opts.projectId !== -1 && opts.projectId !== projectId) { - error( - 'The specified API key cannot be used to perform operations on the specified project.' - ); - info( - `The API key you specified is tied to project #${projectId}, you tried to perform operations on project #${opts.projectId}.` - ); - info( - 'Learn more about how API keys in Tolgee work here: https://tolgee.io/platform/account_settings/api_keys_and_pat_tokens' - ); - process.exit(1); - } - } -} - -function validateOptions(cmd: Command) { - const opts = cmd.optsWithGlobals(); - if (opts.projectId === -1) { - error( - 'No Project ID have been specified. You must either provide one via --project-id, or by setting up a `.tolgeerc` file.' - ); - info( - 'Learn more about configuring the CLI here: https://tolgee.io/tolgee-cli/project-configuration' - ); - process.exit(1); - } - - if (!opts.apiKey) { - error( - 'No API key has been provided. You must either provide one via --api-key, or login via `tolgee login`.' - ); - process.exit(1); - } -} - -async function preHandler(prog: Command, cmd: Command) { - const NO_KEY_COMMANDS = ['login', 'logout', 'extract']; - - if (!NO_KEY_COMMANDS.includes(topLevelName(cmd))) { - await loadApiKey(cmd); - loadProjectId(cmd); - validateOptions(cmd); - - const opts = cmd.optsWithGlobals(); - const client = new RestClient({ - apiUrl: opts.apiUrl, - apiKey: opts.apiKey, - projectId: opts.projectId, - }); - - cmd.setOptionValue('client', client); - } - - // Apply verbosity - setDebug(prog.opts().verbose); -} - -function loadEnvironmentalVariables(program: Command) { - const options: BaseOptions = program.optsWithGlobals(); - const envFilePath = path.resolve(process.cwd(), options.env); - - if (fs.existsSync(envFilePath)) { - dotenv.config({ path: envFilePath }); - - if (process.env.TOLGEE_API_KEY) { - program.setOptionValue('apiKey', process.env.TOLGEE_API_KEY); - } - if (process.env.TOLGEE_API_URL) { - program.setOptionValue( - 'apiUrl', - parseUrlArgument(process.env.TOLGEE_API_URL) - ); - } - if (process.env.TOLGEE_PROJECT_ID) { - program.setOptionValue( - 'projectId', - parseProjectId(process.env.TOLGEE_PROJECT_ID) - ); - } - } -} - -const program = new Command('tolgee') - .version(VERSION) - .configureOutput({ writeErr: error }) - .description('Command Line Interface to interact with the Tolgee Platform') - .option('-v, --verbose', 'Enable verbose logging.') - .hook('preAction', loadEnvironmentalVariables) - .hook('preAction', loadConfig) - .hook('preAction', preHandler); - -// Global options -program.addOption(ENV_OPT); -program.addOption(API_URL_OPT); -program.addOption(API_KEY_OPT); -program.addOption(PROJECT_ID_OPT); - -// Register commands -program.addCommand(Login); -program.addCommand(Logout); -program.addCommand(PushCommand); -program.addCommand(PullCommand); -program.addCommand(ExtractCommand); -program.addCommand(CompareCommand); -program.addCommand(SyncCommand); - -async function loadConfig(program: Command) { - const tgConfig = await loadTolgeeRc(); - if (tgConfig) { - for (const [key, value] of Object.entries(tgConfig)) { - program.setOptionValue(key, value); - } - } -} - -async function handleHttpError(e: HttpError) { +async function handleHttpError(program: Command, e: HttpError) { error('An error occurred while requesting the API.'); error(`${e.request.method} ${e.request.path}`); error(e.getErrorText()); @@ -222,11 +36,12 @@ async function handleHttpError(e: HttpError) { } async function run() { + const program = createProgram(); try { await program.parseAsync(); } catch (e: any) { if (e instanceof HttpError) { - await handleHttpError(e); + await handleHttpError(program, e); process.exit(1); } diff --git a/src/program.ts b/src/program.ts new file mode 100644 index 0000000..25c1a0b --- /dev/null +++ b/src/program.ts @@ -0,0 +1,188 @@ +import { Command } from 'commander'; + +import { getApiKey, savePak, savePat } from './config/credentials.js'; +import loadTolgeeRc from './config/tolgeerc.js'; + +import RestClient from './client/index.js'; +import { setDebug, info, error } from './utils/logger.js'; + +import { + API_KEY_OPT, + API_URL_OPT, + BaseOptions, + ENV_OPT, + PROJECT_ID_OPT, + parseProjectId, + parseUrlArgument, +} from './options.js'; +import { + API_KEY_PAK_PREFIX, + API_KEY_PAT_PREFIX, + VERSION, +} from './constants.js'; + +import { Login, Logout } from './commands/login.js'; +import PushCommand from './commands/push.js'; +import PullCommand from './commands/pull.js'; +import ExtractCommand from './commands/extract.js'; +import CompareCommand from './commands/sync/compare.js'; +import SyncCommand from './commands/sync/sync.js'; +import path from 'path'; +import fs from 'fs'; +import dotenv from 'dotenv'; + +function topLevelName(command: Command): string { + return command.parent && command.parent.parent + ? topLevelName(command.parent) + : command.name(); +} + +async function loadApiKey(program: Command, cmd: Command) { + const opts = cmd.optsWithGlobals(); + + // API Key is already loaded + if (opts.apiKey) return; + + // Attempt to load --api-key from config store if not specified + // This is not done as part of the init routine or via the mandatory flag, as this is dependent on the API URL. + const key = await getApiKey(opts.apiUrl, opts.projectId); + + // No key in store, stop here. + if (!key) return; + + cmd.setOptionValue('apiKey', key); + program.setOptionValue('_removeApiKeyFromStore', () => { + if (key.startsWith(API_KEY_PAT_PREFIX)) { + savePat(opts.apiUrl); + } else { + savePak(opts.apiUrl, opts.projectId); + } + }); +} + +function loadProjectId(program: Command, cmd: Command) { + const opts = cmd.optsWithGlobals(); + + if (opts.apiKey?.startsWith(API_KEY_PAK_PREFIX)) { + // Parse the key and ensure we can access the specified Project ID + const projectId = RestClient.projectIdFromKey(opts.apiKey); + program.setOptionValue('projectId', projectId); + + if (opts.projectId !== -1 && opts.projectId !== projectId) { + error( + 'The specified API key cannot be used to perform operations on the specified project.' + ); + info( + `The API key you specified is tied to project #${projectId}, you tried to perform operations on project #${opts.projectId}.` + ); + info( + 'Learn more about how API keys in Tolgee work here: https://tolgee.io/platform/account_settings/api_keys_and_pat_tokens' + ); + process.exit(1); + } + } +} + +function validateOptions(cmd: Command) { + const opts = cmd.optsWithGlobals(); + if (opts.projectId === -1) { + error( + 'No Project ID have been specified. You must either provide one via --project-id, or by setting up a `.tolgeerc` file.' + ); + info( + 'Learn more about configuring the CLI here: https://tolgee.io/tolgee-cli/project-configuration' + ); + process.exit(1); + } + + if (!opts.apiKey) { + error( + 'No API key has been provided. You must either provide one via --api-key, or login via `tolgee login`.' + ); + process.exit(1); + } +} + +async function preHandler(prog: Command, cmd: Command) { + const NO_KEY_COMMANDS = ['login', 'logout', 'extract']; + + if (!NO_KEY_COMMANDS.includes(topLevelName(cmd))) { + await loadApiKey(prog, cmd); + loadProjectId(prog, cmd); + validateOptions(cmd); + + const opts = cmd.optsWithGlobals(); + const client = new RestClient({ + apiUrl: opts.apiUrl, + apiKey: opts.apiKey, + projectId: opts.projectId, + }); + + cmd.setOptionValue('client', client); + } + + // Apply verbosity + setDebug(prog.opts().verbose); +} + +export function loadEnvironmentalVariables(program: Command) { + const options: BaseOptions = program.optsWithGlobals(); + const envFilePath = path.resolve(process.cwd(), options.env); + + if (fs.existsSync(envFilePath)) { + dotenv.config({ path: envFilePath }); + + if (process.env.TOLGEE_API_KEY) { + program.setOptionValue('apiKey', process.env.TOLGEE_API_KEY); + } + if (process.env.TOLGEE_API_URL) { + program.setOptionValue( + 'apiUrl', + parseUrlArgument(process.env.TOLGEE_API_URL) + ); + } + if (process.env.TOLGEE_PROJECT_ID) { + program.setOptionValue( + 'projectId', + parseProjectId(process.env.TOLGEE_PROJECT_ID) + ); + } + } +} + +export function createProgram() { + const program = new Command('tolgee') + .version(VERSION) + .configureOutput({ writeErr: error }) + .description('Command Line Interface to interact with the Tolgee Platform') + .option('-v, --verbose', 'Enable verbose logging.') + .hook('preAction', loadEnvironmentalVariables) + .hook('preAction', loadConfig) + .hook('preAction', preHandler); + + // Global options + program.addOption(ENV_OPT); + program.addOption(API_URL_OPT); + program.addOption(API_KEY_OPT); + program.addOption(PROJECT_ID_OPT); + + // Register commands + program.addCommand(Login); + program.addCommand(Logout); + program.addCommand(PushCommand); + program.addCommand(PullCommand); + program.addCommand(ExtractCommand); + program.addCommand(CompareCommand); + program.addCommand(SyncCommand); + + return program; +} + +export async function loadConfig(program: Command) { + const tgConfig = await loadTolgeeRc(); + if (tgConfig) { + for (const [key, value] of Object.entries(tgConfig)) { + program.setOptionValue(key, value); + } + } +} From 044869364216303f4bca10441a31fe2a1f23e782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lehoczky=20Zolt=C3=A1n?= Date: Mon, 26 Feb 2024 20:12:20 +0100 Subject: [PATCH 3/4] test: add test for environmental variable loading --- test/__fixtures__/dotenvFileWithTolgeerc/.env | 3 + .../dotenvFileWithTolgeerc/.tolgeerc | 5 ++ test/__fixtures__/dotenvFiles/.env | 3 + test/__fixtures__/dotenvFiles/.env.test | 3 + test/unit/config.test.ts | 66 ++++++++++++++++++- 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 test/__fixtures__/dotenvFileWithTolgeerc/.env create mode 100644 test/__fixtures__/dotenvFileWithTolgeerc/.tolgeerc create mode 100644 test/__fixtures__/dotenvFiles/.env create mode 100644 test/__fixtures__/dotenvFiles/.env.test diff --git a/test/__fixtures__/dotenvFileWithTolgeerc/.env b/test/__fixtures__/dotenvFileWithTolgeerc/.env new file mode 100644 index 0000000..b6d8b98 --- /dev/null +++ b/test/__fixtures__/dotenvFileWithTolgeerc/.env @@ -0,0 +1,3 @@ +TOLGEE_API_KEY="test" +TOLGEE_API_URL="https://test.tolgee.io" +TOLGEE_PROJECT_ID="99" diff --git a/test/__fixtures__/dotenvFileWithTolgeerc/.tolgeerc b/test/__fixtures__/dotenvFileWithTolgeerc/.tolgeerc new file mode 100644 index 0000000..4dfdd28 --- /dev/null +++ b/test/__fixtures__/dotenvFileWithTolgeerc/.tolgeerc @@ -0,0 +1,5 @@ +{ + "apiUrl": "https://app.tolgee.io", + "projectId": 1337, + "delimiter": null +} diff --git a/test/__fixtures__/dotenvFiles/.env b/test/__fixtures__/dotenvFiles/.env new file mode 100644 index 0000000..b6d8b98 --- /dev/null +++ b/test/__fixtures__/dotenvFiles/.env @@ -0,0 +1,3 @@ +TOLGEE_API_KEY="test" +TOLGEE_API_URL="https://test.tolgee.io" +TOLGEE_PROJECT_ID="99" diff --git a/test/__fixtures__/dotenvFiles/.env.test b/test/__fixtures__/dotenvFiles/.env.test new file mode 100644 index 0000000..769e092 --- /dev/null +++ b/test/__fixtures__/dotenvFiles/.env.test @@ -0,0 +1,3 @@ +TOLGEE_API_KEY="test2" +TOLGEE_API_URL="https://test2.tolgee.io" +TOLGEE_PROJECT_ID="992" diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index be24d8c..2e2cbba 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -6,6 +6,9 @@ import { join } from 'path'; import { rm, readFile } from 'fs/promises'; import { saveApiKey, getApiKey } from '../../src/config/credentials.js'; import loadTolgeeRc from '../../src/config/tolgeerc.js'; +import { loadConfig, loadEnvironmentalVariables } from '../../src/program.js'; +import { Command } from 'commander'; +import { API_URL_OPT, ENV_OPT } from '../../src/options.js'; const FIXTURES_PATH = new URL('../__fixtures__/', import.meta.url); const AUTH_FILE = join(tmpdir(), 'authentication.json'); @@ -72,7 +75,7 @@ describe('credentials', () => { expect(saved).toContain(PAT_1.key); }); - it('stores can store different tokens for different instances', async () => { + it('can store different tokens for different instances', async () => { await saveApiKey(TG_1, PAT_1); await saveApiKey(TG_2, PAT_2); const saved1 = await readFile(AUTH_FILE, 'utf8'); @@ -200,3 +203,64 @@ describe('.tolgeerc', () => { return expect(loadTolgeeRc()).rejects.toThrow('sdk'); }); }); + +describe('dotenv files', () => { + const ORIGINAL_ENV = process.env; + let cwd: jest.SpiedFunction; + let program: Command; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + cwd = jest.spyOn(process, 'cwd'); + program = new Command('test') + .addOption(ENV_OPT) + .addOption(API_URL_OPT) + .hook('preAction', loadEnvironmentalVariables) + .hook('preAction', loadConfig) + .addCommand(new Command('test').action(() => {})); + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it('loads env variables from the `.env` file by default', async () => { + const testWd = fileURLToPath(new URL('./dotenvFiles', FIXTURES_PATH)); + cwd.mockReturnValue(testWd); + + await program.parseAsync(['test'], { from: 'user' }); + const options = program.optsWithGlobals(); + + expect(options.env).toEqual('.env'); + expect(options.apiKey).toEqual('test'); + expect(options.apiUrl.toString()).toEqual('https://test.tolgee.io/'); + expect(options.projectId).toEqual(99); + }); + + it('can load env variables from custom dotenv file', async () => { + const testWd = fileURLToPath(new URL('./dotenvFiles', FIXTURES_PATH)); + cwd.mockReturnValue(testWd); + + await program.parseAsync(['test', '--env', '.env.test'], { from: 'user' }); + const options = program.optsWithGlobals(); + + expect(options.env).toEqual('.env.test'); + expect(options.apiKey).toEqual('test2'); + expect(options.apiUrl.toString()).toEqual('https://test2.tolgee.io/'); + expect(options.projectId).toEqual(992); + }); + + it('prioritizes configuration from the `.tolgeerc` over env variables', async () => { + const testWd = fileURLToPath( + new URL('./dotenvFileWithTolgeerc', FIXTURES_PATH) + ); + cwd.mockReturnValue(testWd); + + await program.parseAsync(['test'], { from: 'user' }); + const options = program.optsWithGlobals(); + + expect(options.apiKey).toEqual('test'); + expect(options.apiUrl.toString()).toEqual('https://app.tolgee.io/'); + expect(options.projectId).toEqual(1337); + }); +}); From aa038ab5d13ea17a621269cb9babe00a69114d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lehoczky=20Zolt=C3=A1n?= Date: Mon, 26 Feb 2024 20:53:50 +0100 Subject: [PATCH 4/4] fix: prioritize user-defined options --- src/constants.ts | 2 ++ src/options.ts | 10 +++++++--- src/program.ts | 20 +++++++++++++------- test/unit/config.test.ts | 18 ++++++++++++++++++ 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index eed1e8d..a177281 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,6 +10,8 @@ export const VERSION = JSON.parse(pkg).version; export const USER_AGENT = `Tolgee-CLI/${VERSION} (+https://github.com/tolgee/tolgee-cli)`; export const DEFAULT_API_URL = new URL('https://app.tolgee.io'); +export const DEFAULT_PROJECT_ID = -1; +export const DEFAULT_ENV_FILE = '.env'; export const API_KEY_PAT_PREFIX = 'tgpat_'; export const API_KEY_PAK_PREFIX = 'tgpak_'; diff --git a/src/options.ts b/src/options.ts index 3805d3f..8a9addb 100644 --- a/src/options.ts +++ b/src/options.ts @@ -2,7 +2,11 @@ import type Client from './client/index.js'; import { existsSync } from 'fs'; import { resolve } from 'path'; import { Option, InvalidArgumentError } from 'commander'; -import { DEFAULT_API_URL } from './constants.js'; +import { + DEFAULT_API_URL, + DEFAULT_ENV_FILE, + DEFAULT_PROJECT_ID, +} from './constants.js'; export function parseProjectId(v: string) { const val = Number(v); @@ -47,7 +51,7 @@ export const PROJECT_ID_OPT = new Option( 'Project ID. Only required when using a Personal Access Token.' ) .env('TOLGEE_PROJECT_ID') - .default(-1) + .default(DEFAULT_PROJECT_ID) .argParser(parseProjectId); export const API_URL_OPT = new Option( @@ -61,7 +65,7 @@ export const API_URL_OPT = new Option( export const ENV_OPT = new Option( '--env ', `Environment file to load variable from.` -).default('.env'); +).default(DEFAULT_ENV_FILE); export const EXTRACTOR = new Option( '-e, --extractor ', diff --git a/src/program.ts b/src/program.ts index 25c1a0b..c964931 100644 --- a/src/program.ts +++ b/src/program.ts @@ -132,17 +132,21 @@ export function loadEnvironmentalVariables(program: Command) { if (fs.existsSync(envFilePath)) { dotenv.config({ path: envFilePath }); + /** Sets the option value if it was not specified by the user. */ + const setOptionValue = (key: string, value: unknown) => { + if (program.getOptionValueSourceWithGlobals(key) !== 'cli') { + program.setOptionValue(key, value); + } + }; + if (process.env.TOLGEE_API_KEY) { - program.setOptionValue('apiKey', process.env.TOLGEE_API_KEY); + setOptionValue('apiKey', process.env.TOLGEE_API_KEY); } if (process.env.TOLGEE_API_URL) { - program.setOptionValue( - 'apiUrl', - parseUrlArgument(process.env.TOLGEE_API_URL) - ); + setOptionValue('apiUrl', parseUrlArgument(process.env.TOLGEE_API_URL)); } if (process.env.TOLGEE_PROJECT_ID) { - program.setOptionValue( + setOptionValue( 'projectId', parseProjectId(process.env.TOLGEE_PROJECT_ID) ); @@ -182,7 +186,9 @@ export async function loadConfig(program: Command) { const tgConfig = await loadTolgeeRc(); if (tgConfig) { for (const [key, value] of Object.entries(tgConfig)) { - program.setOptionValue(key, value); + if (program.getOptionValueSourceWithGlobals(key) !== 'cli') { + program.setOptionValue(key, value); + } } } } diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 2e2cbba..125d45c 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -212,6 +212,7 @@ describe('dotenv files', () => { beforeEach(() => { process.env = { ...ORIGINAL_ENV }; cwd = jest.spyOn(process, 'cwd'); + program = new Command('test') .addOption(ENV_OPT) .addOption(API_URL_OPT) @@ -263,4 +264,21 @@ describe('dotenv files', () => { expect(options.apiUrl.toString()).toEqual('https://app.tolgee.io/'); expect(options.projectId).toEqual(1337); }); + + it('prioritizes user-defined options over env variables', async () => { + const testWd = fileURLToPath( + new URL('./dotenvFileWithTolgeerc', FIXTURES_PATH) + ); + cwd.mockReturnValue(testWd); + + await program.parseAsync( + ['test', '--api-url', 'https://from-user.tolgee.io/'], + { from: 'user' } + ); + const options = program.optsWithGlobals(); + + expect(options.apiKey).toEqual('test'); + expect(options.apiUrl.toString()).toEqual('https://from-user.tolgee.io/'); + expect(options.projectId).toEqual(1337); + }); });