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: add support for loading dotenv files #73

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_';
Expand Down
157 changes: 5 additions & 152 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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);
}

Expand Down
22 changes: 17 additions & 5 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,29 @@ 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.');
}
return val;
}

function parseUrlArgument(v: string) {
export function parseUrlArgument(v: string) {
try {
return new URL(v);
} catch {
throw new InvalidArgumentError('Malformed URL.');
}
}

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.`);
Expand All @@ -33,6 +37,7 @@ export type BaseOptions = {
apiUrl: URL;
apiKey: string;
projectId: number;
env: string;
client: Client;
};

Expand All @@ -45,16 +50,23 @@ export const PROJECT_ID_OPT = new Option(
'-p, --project-id <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 <url>',
'The url of Tolgee API.'
)
.env('TOLGEE_API_URL')
.default(DEFAULT_API_URL)
.argParser(parseUrlArgument);

export const ENV_OPT = new Option(
'--env <filename>',
`Environment file to load variable from.`
).default(DEFAULT_ENV_FILE);

export const EXTRACTOR = new Option(
'-e, --extractor <extractor>',
`A path to a custom extractor to use instead of the default one.`
Expand Down
Loading
Loading