diff --git a/package-lock.json b/package-lock.json index e662e030..cf2df1c4 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 a3fceb0e..df8befeb 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/constants.ts b/src/constants.ts index eed1e8dd..a1772817 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/index.ts b/src/index.ts index ff5529ad..a15bca56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,160 +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, PROJECT_ID_OPT } 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'; - -const NO_KEY_COMMANDS = ['login', 'logout', 'extract']; +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) { - 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); -} - -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', preHandler); - -// Global options -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() { - 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()); @@ -183,12 +36,12 @@ async function handleHttpError(e: HttpError) { } async function run() { + const program = createProgram(); try { - await loadConfig(); await program.parseAsync(); } catch (e: any) { if (e instanceof HttpError) { - await handleHttpError(e); + await handleHttpError(program, e); process.exit(1); } diff --git a/src/options.ts b/src/options.ts index a7ce2378..8a9addb2 100644 --- a/src/options.ts +++ b/src/options.ts @@ -2,9 +2,13 @@ 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'; -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 +16,7 @@ function parseProjectId(v: string) { return val; } -function parseUrlArgument(v: string) { +export function parseUrlArgument(v: string) { try { return new URL(v); } catch { @@ -20,7 +24,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 +37,7 @@ export type BaseOptions = { apiUrl: URL; apiKey: string; projectId: number; + env: string; client: Client; }; @@ -45,16 +50,23 @@ export const PROJECT_ID_OPT = new Option( '-p, --project-id ', 'Project ID. Only required when using a Personal Access Token.' ) - .default(-1) + .env('TOLGEE_PROJECT_ID') + .default(DEFAULT_PROJECT_ID) .argParser(parseProjectId); 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(DEFAULT_ENV_FILE); + export const EXTRACTOR = new Option( '-e, --extractor ', `A path to a custom extractor to use instead of the default one.` diff --git a/src/program.ts b/src/program.ts new file mode 100644 index 00000000..c964931f --- /dev/null +++ b/src/program.ts @@ -0,0 +1,194 @@ +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 }); + + /** 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) { + setOptionValue('apiKey', process.env.TOLGEE_API_KEY); + } + if (process.env.TOLGEE_API_URL) { + setOptionValue('apiUrl', parseUrlArgument(process.env.TOLGEE_API_URL)); + } + if (process.env.TOLGEE_PROJECT_ID) { + 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)) { + if (program.getOptionValueSourceWithGlobals(key) !== 'cli') { + program.setOptionValue(key, value); + } + } + } +} diff --git a/test/__fixtures__/dotenvFileWithTolgeerc/.env b/test/__fixtures__/dotenvFileWithTolgeerc/.env new file mode 100644 index 00000000..b6d8b980 --- /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 00000000..4dfdd28d --- /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 00000000..b6d8b980 --- /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 00000000..769e092d --- /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 be24d8c1..125d45cb 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,82 @@ 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); + }); + + 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); + }); +});