Skip to content

Commit

Permalink
fix: improve error messages (#150)
Browse files Browse the repository at this point in the history
* fix: improve error messages

* chore: fix test

* fix: improve error message

* fix: add `login --list` command

* fix: add `login --list` command

* feat: add hint about `login --list` to not authenticated error

* fix: add "No records" message
  • Loading branch information
stepan662 authored Feb 19, 2025
1 parent f3ce84a commit c3f5fad
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 28 deletions.
26 changes: 19 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { getSingleOption } from './utils/getSingleOption.js';
import { Schema } from './schema.js';
import { createTolgeeClient } from './client/TolgeeClient.js';
import { projectIdFromKey } from './client/ApiClient.js';
import { printApiKeyLists } from './utils/apiKeyList.js';

const NO_KEY_COMMANDS = ['login', 'logout', 'extract'];

Expand Down Expand Up @@ -98,24 +99,35 @@ function loadProjectId(cmd: Command) {
}
}

function validateOptions(cmd: Command) {
async function validateOptions(cmd: Command) {
const opts = cmd.optsWithGlobals();

if (!opts.apiKey) {
exitWithError(
'No API key has been provided. You must either provide one via --api-key, or login via `tolgee login`.'
);
}

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(
'If you provide Project Api Key (PAK) via `--api-key`, Project ID is derived automatically.'
);
info(
'Learn more about configuring the CLI here: https://tolgee.io/tolgee-cli/project-configuration'
);
process.exit(1);
}

if (!opts.apiKey) {
error(
`Not authenticated for host ${ansi.blue(opts.apiUrl.hostname)} and project ${ansi.blue(opts.projectId)}.`
);
info(
`You must either provide api key via --api-key or login via \`tolgee login\` (for correct api url and project)`
);

console.log('\nYou are logged into these projects:');
await printApiKeyLists();

process.exit(1);
}
}

const preHandler = (config: Schema) =>
Expand Down
7 changes: 6 additions & 1 deletion src/client/getApiKeyInformation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { paths } from './internal/schema.generated.js';
import { handleLoadableError } from './TolgeeClient.js';
import { exitWithError } from './../utils/logger.js';

export type ApiKeyProject = {
name: string;
id: number;
};

export type ApiKeyInfoPat = {
type: 'PAT';
key: string;
Expand All @@ -16,7 +21,7 @@ export type ApiKeyInfoPak = {
type: 'PAK';
key: string;
username: string;
project: { id: number; name: string };
project: ApiKeyProject;
expires: number;
};

Expand Down
22 changes: 17 additions & 5 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import { Command } from 'commander';
import ansi from 'ansi-colors';

import {
saveApiKey,
removeApiKeys,
clearAuthStore,
} from '../config/credentials.js';
import { success } from '../utils/logger.js';
import { exitWithError, success } from '../utils/logger.js';
import { createTolgeeClient } from '../client/TolgeeClient.js';
import { printApiKeyLists } from '../utils/apiKeyList.js';

type Options = {
apiUrl: URL;
all: boolean;
list: boolean;
};

async function loginHandler(this: Command, key: string) {
async function loginHandler(this: Command, key?: string) {
const opts: Options = this.optsWithGlobals();

if (opts.list) {
printApiKeyLists();
return;
} else if (!key) {
exitWithError('Missing argument [API Key]');
}

const keyInfo = await createTolgeeClient({
baseUrl: opts.apiUrl.toString(),
apiKey: key,
Expand All @@ -23,8 +34,8 @@ async function loginHandler(this: Command, key: string) {
await saveApiKey(opts.apiUrl, keyInfo);
success(
keyInfo.type === 'PAK'
? `Logged in as ${keyInfo.username} on ${opts.apiUrl.hostname} for project ${keyInfo.project.name} (#${keyInfo.project.id}). Welcome back!`
: `Logged in as ${keyInfo.username} on ${opts.apiUrl.hostname}. Welcome back!`
? `Logged in as ${keyInfo.username} on ${ansi.blue(opts.apiUrl.hostname)} for project ${ansi.blue(String(keyInfo.project.id))} (${keyInfo.project.name}). Welcome back!`
: `Logged in as ${keyInfo.username} on ${ansi.blue(opts.apiUrl.hostname)}. Welcome back!`
);
}

Expand All @@ -48,8 +59,9 @@ export const Login = new Command()
.description(
'Login to Tolgee with an API key. You can be logged into multiple Tolgee instances at the same time by using --api-url'
)
.option('-l, --list', 'List existing api keys')
.argument(
'<API Key>',
'[API Key]',
'The API key. Can be either a personal access token, or a project key'
)
.action(loginHandler);
Expand Down
46 changes: 32 additions & 14 deletions src/config/credentials.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { join, dirname } from 'path';
import { mkdir, readFile, writeFile } from 'fs/promises';

import type { ApiKeyInfo } from '../client/getApiKeyInformation.js';
import type {
ApiKeyInfo,
ApiKeyProject,
} from '../client/getApiKeyInformation.js';
import { warn } from '../utils/logger.js';
import { CONFIG_PATH } from '../constants.js';

type Token = { token: string; expires: number };
export type Token = { token: string; expires: number };
export type ProjectDetails = { name: string };

type Store = {
export type Store = {
[scope: string]: {
user?: Token;
// keys cannot be numeric values in JSON
projects?: Record<string, Token | undefined>;
projectDetails?: Record<string, ProjectDetails>;
};
};

Expand All @@ -27,7 +32,7 @@ async function ensureConfigPath() {
}
}

async function loadStore(): Promise<Store> {
export async function loadStore(): Promise<Store> {
try {
await ensureConfigPath();
const storeData = await readFile(API_TOKENS_FILE, 'utf8');
Expand Down Expand Up @@ -62,7 +67,7 @@ async function storePat(store: Store, instance: URL, pat?: Token) {
async function storePak(
store: Store,
instance: URL,
projectId: number,
project: ApiKeyProject,
pak?: Token
) {
return saveStore({
Expand All @@ -71,20 +76,34 @@ async function storePak(
...(store[instance.hostname] || {}),
projects: {
...(store[instance.hostname]?.projects || {}),
[projectId.toString(10)]: pak,
[project.id.toString(10)]: pak,
},
projectDetails: {
...(store[instance.hostname]?.projectDetails || {}),
[project.id.toString(10)]: { name: project.name },
},
},
});
}

async function removePak(store: Store, instance: URL, projectId: number) {
delete store[instance.hostname].projects?.[projectId.toString(10)];
delete store[instance.hostname].projectDetails?.[projectId.toString(10)];
return saveStore(store);
}

export async function savePat(instance: URL, pat?: Token) {
const store = await loadStore();
return storePat(store, instance, pat);
}

export async function savePak(instance: URL, projectId: number, pak?: Token) {
export async function savePak(
instance: URL,
project: ApiKeyProject,
pak?: Token
) {
const store = await loadStore();
return storePak(store, instance, projectId, pak);
return storePak(store, instance, project, pak);
}

export async function getApiKey(
Expand Down Expand Up @@ -120,7 +139,7 @@ export async function getApiKey(
warn(
`Your project API key for project #${projectId} on ${instance.hostname} expired.`
);
await storePak(store, instance, projectId, undefined);
await removePak(store, instance, projectId);
return null;
}

Expand All @@ -140,18 +159,17 @@ export async function saveApiKey(instance: URL, token: ApiKeyInfo) {
});
}

return storePak(store, instance, token.project.id, {
return storePak(store, instance, token.project, {
token: token.key,
expires: token.expires,
});
}

export async function removeApiKeys(api: URL) {
const store = await loadStore();
return saveStore({
...store,
[api.hostname]: {},
});
delete store[api.hostname];

return saveStore(store);
}

export async function clearAuthStore() {
Expand Down
61 changes: 61 additions & 0 deletions src/utils/apiKeyList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import ansi from 'ansi-colors';

import { loadStore, ProjectDetails, Token } from '../config/credentials.js';

function getProjectName(
projectId: string,
projectDetails?: Record<string, ProjectDetails>
) {
return projectDetails?.[projectId]?.name;
}

function printToken(
type: 'PAT' | 'PAK',
token: Token,
projectId?: string,
projectDetails?: Record<string, ProjectDetails>
) {
let result = type === 'PAK' ? ansi.green('PAK') : ansi.blue('PAT');

if (projectId !== undefined) {
const projectName = getProjectName(projectId, projectDetails);
result += '\t ' + ansi.red(`#${projectId}` + ' ' + (projectName ?? ''));
} else {
result += '\t ' + ansi.yellow('<all projects>');
}

if (token.expires) {
result +=
'\t ' +
ansi.grey('expires ' + new Date(token.expires).toLocaleDateString());
} else {
result += '\t ' + ansi.grey('never expires');
}

console.log(result);
}

export async function printApiKeyLists() {
const store = await loadStore();
const list = Object.entries(store);

if (list.length === 0) {
console.log(ansi.gray('No records\n'));
}

for (const [origin, server] of list) {
console.log(ansi.white('[') + ansi.red(origin) + ansi.white(']'));
if (server.user) {
printToken('PAT', server.user);
}
if (server.projects) {
for (const [project, token] of Object.entries(server.projects)) {
if (token) {
printToken('PAK', token, project, server.projectDetails);
}
}
}
console.log('\n');
}
return;
}
2 changes: 1 addition & 1 deletion test/e2e/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('Project 1', () => {

expect(out.code).toBe(0);
expect(out.stdout).toMatch(
'Logged in as admin on localhost for project Project 1'
/Logged in as admin on localhost for project [\d]+ \(Project 1\)/gm
);
expect(out.stderr).toBe('');

Expand Down

0 comments on commit c3f5fad

Please sign in to comment.