Skip to content
Merged
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
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)`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`You must either provide api key via --api-key or login via \`tolgee login\` (for correct api url and project)`
`You must either provide api key via --api-key or login via \`tolgee login\` for specific API URL and project`

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI should also have a command which prints out for which projects (with their names) is the user currently logged in. I would also print this list when this error is printed out.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, done

);

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
Loading