Skip to content

Commit 7e22c15

Browse files
feat(cli): add replexica auth command for easier cli <> api authentication (#42)
* feat: add basic auth cmd structure * feat(cli): add authentication with ~/.replexicarc as storage * refactor(cli): move ini mgr into settings service * feat(cli): add `replexica auth` command implementation * chore: changeset
1 parent 663609c commit 7e22c15

File tree

9 files changed

+635
-31
lines changed

9 files changed

+635
-31
lines changed

.changeset/strange-poems-sin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"replexica": minor
3+
---
4+
5+
add `replexica auth` cli command for cli<>api authentication

packages/cli/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@
2626
"@inquirer/prompts": "^4.3.1",
2727
"@paralleldrive/cuid2": "^2.2.2",
2828
"commander": "^12.0.0",
29+
"cors": "^2.8.5",
2930
"dotenv": "^16.4.5",
31+
"express": "^4.19.2",
3032
"flat": "^6.0.1",
33+
"ini": "^4.1.2",
3134
"lodash": "^4.17.21",
3235
"open": "^10.1.0",
3336
"ora": "^8.0.1",
@@ -37,6 +40,9 @@
3740
"zod": "^3.22.4"
3841
},
3942
"devDependencies": {
43+
"@types/cors": "^2.8.17",
44+
"@types/express": "^4.17.21",
45+
"@types/ini": "^4.1.0",
4046
"@types/lodash": "^4.17.0",
4147
"@types/node": "^20"
4248
}

packages/cli/src/auth.ts

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,74 @@
11
import { Command } from "commander";
2+
import Ora from 'ora';
3+
import express from 'express';
4+
import cors from 'cors';
5+
import open from 'open';
6+
import readline from 'readline/promises';
7+
import { loadSettings, saveSettings } from "./services/settings.js";
8+
import { getEnv } from "./services/env.js";
9+
import { checkAuth } from "./services/check-auth.js";
10+
import { saveApiKey } from "./services/api-key.js";
211

312
export default new Command()
413
.command("auth")
5-
.description("Authenticate with Replexica")
14+
.description("Authenticate with Replexica API")
615
.helpOption("-h, --help", "Show help")
7-
.action(async () => {
8-
const message = `
9-
To obtain Replexica API key, please follow the following link:
16+
.option("-d, --delete", "Delete existing authentication")
17+
.option("-l, --login", "Authenticate with Replexica API")
18+
.action(async (options) => {
19+
const env = getEnv();
20+
let config = await loadSettings();
1021

11-
https://replexica.com/app/settings
22+
if (options.delete) {
23+
await logout();
24+
}
25+
if (options.login) {
26+
await login(env.REPLEXICA_WEB_URL);
27+
config = await loadSettings();
28+
}
1229

13-
Once you have your API key, please save it in the .env file in the root of your project.
14-
`.trim();
30+
await checkAuth();
31+
});
32+
33+
async function logout() {
34+
const spinner = Ora().start('Logging out');
35+
await saveApiKey(null);
36+
spinner.succeed('Logged out');
37+
}
38+
39+
async function login(apiUrl: string) {
40+
await readline.createInterface({
41+
input: process.stdin,
42+
output: process.stdout,
43+
}).question('Press Enter to open the browser for authentication\n');
44+
45+
const spinner = Ora().start('Waiting for the API key');
46+
const apiKey = await waitForApiKey(async (port) => {
47+
await open(`${apiUrl}/app/cli?port=${port}`, { wait: false });
48+
});
49+
spinner.succeed('API key received');
50+
await saveApiKey(apiKey);
51+
}
52+
53+
async function waitForApiKey(cb: (port: string) => void): Promise<string> {
54+
// start a sever on an ephemeral port and return the port number
55+
// from the function
56+
const app = express();
57+
app.use(express.json());
58+
app.use(cors());
59+
60+
return new Promise((resolve) => {
61+
const server = app.listen(0, async () => {
62+
const port = (server.address() as any).port;
63+
cb(port.toString());
64+
});
1565

16-
console.log(message);
66+
app.post("/", (req, res) => {
67+
const apiKey = req.body.apiKey;
68+
res.end();
69+
server.close(() => {
70+
resolve(apiKey);
71+
});
72+
});
1773
});
74+
}

packages/cli/src/i18n.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { getEnv } from './services/env.js';
44
import path from 'path';
55
import fs from 'fs/promises';
66
import { createId } from '@paralleldrive/cuid2';
7+
import { checkAuth } from './services/check-auth.js';
8+
import { loadApiKey } from './services/api-key.js';
79

810
const buildDataDir = path.resolve(process.cwd(), 'node_modules', '@replexica/translations');
911
const buildDataFilePath = path.resolve(buildDataDir, '.replexica.json');
@@ -13,6 +15,11 @@ export default new Command()
1315
.description('Process i18n with Replexica')
1416
.helpOption('-h, --help', 'Show help')
1517
.action(async () => {
18+
const authStatus = await checkAuth();
19+
if (!authStatus) {
20+
return process.exit(1);
21+
}
22+
1623
const spinner = Ora();
1724
spinner.start('Loading Replexica build data...');
1825
const buildData = await loadBuildData();
@@ -77,12 +84,13 @@ async function processI18n(
7784
data: any,
7885
) {
7986
const env = getEnv();
87+
const apiKey = await loadApiKey();
8088

8189
const res = await fetch(`${env.REPLEXICA_API_URL}/i18n`, {
8290
method: 'POST',
8391
headers: {
8492
'Content-Type': 'application/json',
85-
Authorization: `Bearer ${env.REPLEXICA_API_KEY}`,
93+
Authorization: `Bearer ${apiKey}`,
8694
},
8795
body: JSON.stringify({
8896
params,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { getEnv } from "./env.js";
2+
import { loadSettings, saveSettings } from "./settings.js";
3+
4+
export async function saveApiKey(apiKey: string | null) {
5+
const settings = await loadSettings();
6+
settings.auth.apiKey = apiKey;
7+
await saveSettings(settings);
8+
}
9+
10+
export async function loadApiKey() {
11+
const env = getEnv();
12+
const settings = await loadSettings();
13+
return env.REPLEXICA_API_KEY || settings.auth.apiKey;
14+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import Ora from "ora";
2+
3+
import { getEnv } from "./env.js";
4+
import { loadSettings } from "./settings.js";
5+
6+
export async function checkAuth() {
7+
const env = getEnv();
8+
const settings = await loadSettings();
9+
10+
const finalApiKey = env.REPLEXICA_API_KEY || settings.auth.apiKey;
11+
const isApiKeyFromEnv = !!env.REPLEXICA_API_KEY;
12+
13+
const spinner = Ora().start('Checking login status');
14+
15+
const whoami = await fetchWhoami(env.REPLEXICA_API_URL, finalApiKey);
16+
if (!whoami) {
17+
spinner.warn('Not logged in. Please run `replexica auth --login` to authenticate.');
18+
return false;
19+
}
20+
21+
let msg = `Logged in as ${whoami.email}`;
22+
if (isApiKeyFromEnv) {
23+
msg += ' (via REPLEXICA_API_KEY from environment)';
24+
}
25+
spinner.succeed(msg);
26+
27+
return true;
28+
}
29+
30+
async function fetchWhoami(apiUrl: string, apiKey: string | null) {
31+
const res = await fetch(`${apiUrl}/whoami`, {
32+
method: "POST",
33+
headers: {
34+
Authorization: `Bearer ${apiKey}`,
35+
ContentType: "application/json",
36+
},
37+
});
38+
39+
if (res.ok) {
40+
return res.json();
41+
}
42+
43+
return null;
44+
}

packages/cli/src/services/env.ts

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,13 @@
11
import Z from 'zod';
2+
import dotenv from 'dotenv';
23

34
const EnvSchema = Z.object({
4-
REPLEXICA_API_KEY: Z.string(),
5-
REPLEXICA_API_URL: Z.string().optional().default('https://engine.replexica.com'),
5+
REPLEXICA_API_KEY: Z.string().optional(),
6+
REPLEXICA_API_URL: Z.string().default('https://engine.replexica.com'),
7+
REPLEXICA_WEB_URL: Z.string().default('https://replexica.com'),
68
});
79

810
export function getEnv() {
9-
try {
10-
return EnvSchema.parse(process.env);
11-
} catch (error) {
12-
if (error instanceof Z.ZodError) {
13-
// handle missing api key
14-
if (
15-
error.errors.some((err) => err.path.find((p) => p === 'REPLEXICA_API_KEY') &&
16-
err.message.toLowerCase().includes('required'))
17-
) {
18-
console.log(`REPLEXICA_API_KEY is missing in env variables. Did you forget to run 'replexica auth'?`);
19-
return process.exit(1);
20-
} else {
21-
throw error;
22-
}
23-
} else {
24-
throw error;
25-
}
26-
}
11+
dotenv.config();
12+
return EnvSchema.parse(process.env);
2713
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Z from 'zod';
2+
import fs from 'fs';
3+
import Ini from 'ini';
4+
import os from 'os';
5+
import path from 'path';
6+
7+
const settingsFile = ".replexicarc";
8+
const homedir = os.homedir();
9+
const settingsFilePath = path.join(homedir, settingsFile);
10+
11+
const SettingsFileSchema = Z.object({
12+
auth: Z.object({
13+
apiKey: Z.string().nullable(),
14+
}),
15+
});
16+
17+
export async function loadSettings() {
18+
const authFileExists = fs.existsSync(settingsFilePath);
19+
let rawSettings = createEmptySettings();
20+
21+
if (authFileExists) {
22+
const fileContents = fs.readFileSync(settingsFilePath, "utf8");
23+
rawSettings = Ini.parse(fileContents) as any;
24+
}
25+
const settings = SettingsFileSchema.parse(rawSettings);
26+
27+
return settings;
28+
}
29+
30+
function createEmptySettings(): Z.infer<typeof SettingsFileSchema> {
31+
return {
32+
auth: {
33+
apiKey: null,
34+
},
35+
};
36+
}
37+
38+
export async function saveSettings(config: Z.infer<typeof SettingsFileSchema>) {
39+
const serialized = Ini.stringify(config);
40+
fs.writeFileSync(settingsFilePath, serialized);
41+
}

0 commit comments

Comments
 (0)