From 7043486411b184c95235b05a7fdf3db5ef6d65f5 Mon Sep 17 00:00:00 2001 From: cunoe Date: Tue, 31 Mar 2026 16:37:58 +0800 Subject: [PATCH 1/2] feat(auth): add login/logout/whoami commands and require auth for push/pull Implement CLI authentication with LEAPERone's auth system: - Add credentials module (~/.envx/credentials.json) for token storage - Add `envx login` with browser and device flow support - Add `envx logout` to clear credentials - Add `envx whoami` to show current user - Require auth token for push/pull, with clear error on 401 Closes #3, closes #4 --- src/commands/login.ts | 245 +++++++++++++++++++++++++++++++++++++++ src/commands/logout.ts | 13 +++ src/commands/pull.ts | 17 ++- src/commands/push.ts | 17 ++- src/commands/whoami.ts | 71 ++++++++++++ src/index.ts | 6 + src/utils/credentials.ts | 56 +++++++++ 7 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 src/commands/login.ts create mode 100644 src/commands/logout.ts create mode 100644 src/commands/whoami.ts create mode 100644 src/utils/credentials.ts diff --git a/src/commands/login.ts b/src/commands/login.ts new file mode 100644 index 0000000..4fc31e2 --- /dev/null +++ b/src/commands/login.ts @@ -0,0 +1,245 @@ +import { Command } from 'commander'; +import http from 'node:http'; +import { exec } from 'node:child_process'; +import chalk from 'chalk'; +import ora from 'ora'; +import { + saveCredentials, + loadCredentials, + getAuthBaseUrl, + CREDENTIALS_FILE, +} from '@/utils/credentials'; + +function openBrowser(url: string): void { + const cmd = + process.platform === 'darwin' + ? 'open' + : process.platform === 'win32' + ? 'start' + : 'xdg-open'; + exec(`${cmd} "${url}"`); +} + +function browserLogin(baseUrl: string): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url!, `http://localhost`); + + if (url.pathname === '/callback') { + const code = url.searchParams.get('code'); + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end('

Missing authorization code.

'); + server.close(); + reject(new Error('No authorization code received')); + return; + } + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` +
+

Authorization successful!

+

You can close this tab and return to your terminal.

+
+ `); + + fetch(new URL('/api/v1/cli/auth/exchange', baseUrl).toString(), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }) + .then(async (r) => { + const text = await r.text(); + try { + return JSON.parse(text); + } catch { + throw new Error( + `Exchange API returned non-JSON (HTTP ${r.status}): ${text.slice(0, 200)}` + ); + } + }) + .then((data) => { + server.close(); + if (data.success && data.data?.token) { + resolve(data.data.token); + } else { + reject(new Error(data.error || 'Failed to exchange code')); + } + }) + .catch((err) => { + server.close(); + reject(err); + }); + } else { + res.writeHead(404); + res.end(); + } + }); + + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') { + reject(new Error('Failed to start local server')); + return; + } + const port = addr.port; + const authUrl = `${baseUrl}/auth/cli?port=${port}`; + + console.log(`Opening browser to authorize...`); + console.log(` ${chalk.underline(authUrl)}`); + console.log(); + + openBrowser(authUrl); + }); + + setTimeout(() => { + server.close(); + reject(new Error('Authorization timed out (3 minutes)')); + }, 3 * 60 * 1000); + }); +} + +async function deviceLogin(baseUrl: string): Promise { + const codeRes = await fetch(new URL('/api/auth/device/code', baseUrl).toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: 'envx-cli' }), + }); + + if (!codeRes.ok) { + const body = (await codeRes.json().catch(() => ({}))) as { message?: string }; + throw new Error(body.message || `Failed to request device code (HTTP ${codeRes.status})`); + } + + const codeData = (await codeRes.json()) as { + user_code: string; + device_code: string; + verification_uri: string; + verification_uri_complete?: string; + interval: number; + expires_in: number; + }; + + console.log(); + console.log(` Your device code: ${chalk.bold(codeData.user_code)}`); + console.log(); + const verifyUrl = + codeData.verification_uri_complete || + `${baseUrl}${codeData.verification_uri}?user_code=${encodeURIComponent(codeData.user_code)}`; + console.log(` Open this URL to authorize:`); + console.log(` ${chalk.underline(verifyUrl)}`); + console.log(); + + openBrowser(verifyUrl); + + const spinner = ora('Waiting for authorization...').start(); + const interval = (codeData.interval || 5) * 1000; + const deadline = Date.now() + codeData.expires_in * 1000; + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, interval)); + + const tokenRes = await fetch(new URL('/api/auth/device/token', baseUrl).toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: codeData.device_code, + client_id: 'envx-cli', + }), + }); + + const tokenData = (await tokenRes.json()) as { + access_token?: string; + error?: string; + }; + + if (tokenData.access_token) { + spinner.stop(); + return tokenData.access_token; + } + + if (tokenData.error === 'authorization_pending' || tokenData.error === 'slow_down') { + continue; + } + + spinner.stop(); + + if (tokenData.error === 'expired_token') { + throw new Error('Device code expired. Please try again.'); + } + if (tokenData.error === 'access_denied') { + throw new Error('Authorization was denied.'); + } + if (tokenData.error) { + throw new Error(`Device flow error: ${tokenData.error}`); + } + } + + spinner.stop(); + throw new Error('Device code expired. Please try again.'); +} + +export function loginCommand(program: Command): void { + program + .command('login') + .description('Authenticate with LEAPERone to enable push/pull') + .option('--device', 'Use device flow (no localhost server needed)') + .option('--base-url ', 'Override base URL for authentication') + .action(async (opts: { device?: boolean; baseUrl?: string }) => { + try { + const baseUrl = opts.baseUrl || getAuthBaseUrl(); + + let token: string; + if (opts.device) { + token = await deviceLogin(baseUrl); + } else { + token = await browserLogin(baseUrl); + } + + // Verify the token + const spinner = ora('Verifying...').start(); + + const res = await fetch(new URL('/api/v1/cli/me', baseUrl).toString(), { + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': '@leaperone/envx', + }, + }); + + if (!res.ok) { + spinner.stop(); + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error || `Verification failed (HTTP ${res.status})`); + } + + const data = (await res.json()) as { + success: boolean; + data: { id: string; name?: string; email?: string }; + }; + + if (!data.success) { + spinner.stop(); + throw new Error('Verification failed'); + } + + // Save token + const credentials = loadCredentials(); + credentials.token = token; + credentials.baseUrl = baseUrl; + saveCredentials(credentials); + + spinner.stop(); + + console.log( + chalk.green( + `\u2705 Authenticated as ${data.data.name || data.data.email || data.data.id}` + ) + ); + console.log(` Credentials saved to ${chalk.dim(CREDENTIALS_FILE)}`); + } catch (err) { + console.error(chalk.red(`\u274c Login failed: ${(err as Error).message}`)); + process.exit(1); + } + }); +} diff --git a/src/commands/logout.ts b/src/commands/logout.ts new file mode 100644 index 0000000..05ca301 --- /dev/null +++ b/src/commands/logout.ts @@ -0,0 +1,13 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { clearCredentials } from '@/utils/credentials'; + +export function logoutCommand(program: Command): void { + program + .command('logout') + .description('Remove stored credentials') + .action(() => { + clearCredentials(); + console.log(chalk.green('\u2705 Logged out. Credentials removed.')); + }); +} diff --git a/src/commands/pull.ts b/src/commands/pull.ts index f5f6f1b..8f6f471 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -9,6 +9,7 @@ import { buildPullUrl, } from '@/utils/url'; import { detectDefaultShell, exportEnv } from '@/utils/env'; +import { getCredential } from '@/utils/credentials'; // env file updates will be handled via writeEnvs interface PullOptions { @@ -132,10 +133,12 @@ export function pullCommand(program: Command): void { const headers: Record = { 'Content-Type': 'application/json', }; - const apiKey = devConfigResult.config.apiKey || process.env.ENVX_API_KEY; - if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}`; + const apiKey = devConfigResult.config.apiKey || process.env.ENVX_API_KEY || getCredential(); + if (!apiKey) { + console.error(chalk.red('❌ Not authenticated. Run `envx login` first, or set ENVX_API_KEY.')); + process.exit(1); } + headers['Authorization'] = `Bearer ${apiKey}`; const response = await fetchFn(fullUrl, { method: 'GET', @@ -149,8 +152,12 @@ export function pullCommand(program: Command): void { }; if (!response.ok) { - console.error(chalk.red(`❌ Error: Remote server returned ${response.status}`)); - console.error(chalk.red(`Message: ${responseData.msg || 'Unknown error'}`)); + if (response.status === 401) { + console.error(chalk.red('❌ Authentication failed. Run `envx login` to re-authenticate.')); + } else { + console.error(chalk.red(`❌ Error: Remote server returned ${response.status}`)); + console.error(chalk.red(`Message: ${responseData.msg || 'Unknown error'}`)); + } if (options.verbose && responseData.data) { console.error(chalk.gray('Response data:')); console.error(chalk.gray(JSON.stringify(responseData.data, null, 2))); diff --git a/src/commands/push.ts b/src/commands/push.ts index ea2bbe3..abb51b1 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -5,6 +5,7 @@ import { join } from 'path'; import { ConfigManager } from '@/utils/config'; import { getEnvs } from '@/utils/com'; import { parseRef, buildPushUrl } from '@/utils/url'; +import { getCredential } from '@/utils/credentials'; interface PushOptions { verbose?: boolean; @@ -111,10 +112,12 @@ export function pushCommand(program: Command): void { const headers: Record = { 'Content-Type': 'application/json', }; - const apiKey = devConfigResult.config.apiKey || process.env.ENVX_API_KEY; - if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}`; + const apiKey = devConfigResult.config.apiKey || process.env.ENVX_API_KEY || getCredential(); + if (!apiKey) { + console.error(chalk.red('❌ Not authenticated. Run `envx login` first, or set ENVX_API_KEY.')); + process.exit(1); } + headers['Authorization'] = `Bearer ${apiKey}`; const response = await fetchFn(remoteUrl, { method: 'POST', @@ -129,8 +132,12 @@ export function pushCommand(program: Command): void { }; if (!response.ok) { - console.error(chalk.red(`❌ Error: Remote server returned ${response.status}`)); - console.error(chalk.red(`Message: ${responseData.msg || 'Unknown error'}`)); + if (response.status === 401) { + console.error(chalk.red('❌ Authentication failed. Run `envx login` to re-authenticate.')); + } else { + console.error(chalk.red(`❌ Error: Remote server returned ${response.status}`)); + console.error(chalk.red(`Message: ${responseData.msg || 'Unknown error'}`)); + } if (options.verbose && responseData.data) { console.error(chalk.gray('Response data:')); console.error(chalk.gray(JSON.stringify(responseData.data, null, 2))); diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts new file mode 100644 index 0000000..07a481a --- /dev/null +++ b/src/commands/whoami.ts @@ -0,0 +1,71 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import { getCredential, getAuthBaseUrl } from '@/utils/credentials'; + +export function whoamiCommand(program: Command): void { + program + .command('whoami') + .description('Show current authenticated user') + .action(async () => { + const credential = getCredential(); + + if (!credential) { + console.log('Not authenticated. Run `envx login` to get started.'); + return; + } + + const baseUrl = getAuthBaseUrl(); + const spinner = ora('Checking...').start(); + + try { + const res = await fetch(new URL('/api/v1/cli/me', baseUrl).toString(), { + headers: { + Authorization: `Bearer ${credential}`, + 'User-Agent': '@leaperone/envx', + }, + }); + + if (!res.ok) { + spinner.stop(); + console.log( + chalk.yellow( + 'Credential configured but could not verify. Try `envx login` to re-authenticate.' + ) + ); + return; + } + + const data = (await res.json()) as { + success: boolean; + data: { id: string; name?: string; email?: string; role?: string }; + }; + + spinner.stop(); + + if (!data.success) { + console.log(chalk.yellow('Could not verify credentials.')); + return; + } + + const user = data.data; + const source = process.env.ENVX_API_KEY ? 'api-key (env)' : 'session token'; + + console.log('Authenticated:'); + console.log(chalk.gray(` Name: ${user.name || '-'}`)); + console.log(chalk.gray(` Email: ${user.email || '-'}`)); + if (user.role) { + console.log(chalk.gray(` Role: ${user.role}`)); + } + console.log(chalk.gray(` Source: ${source}`)); + console.log(chalk.gray(` API: ${baseUrl}`)); + } catch { + spinner.stop(); + console.log( + chalk.yellow( + 'Could not reach the server. Check your network or try `envx login` again.' + ) + ); + } + }); +} diff --git a/src/index.ts b/src/index.ts index 58633f9..d9b5ac9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,9 @@ import { loadCommand } from './commands/load.js'; import { tagCommand } from './commands/tag.js'; import { pushCommand } from './commands/push.js'; import { pullCommand } from './commands/pull.js'; +import { loginCommand } from './commands/login.js'; +import { logoutCommand } from './commands/logout.js'; +import { whoamiCommand } from './commands/whoami.js'; const require = createRequire(import.meta.url); const { version } = require('../package.json'); @@ -38,6 +41,9 @@ loadCommand(program); tagCommand(program); pushCommand(program); pullCommand(program); +loginCommand(program); +logoutCommand(program); +whoamiCommand(program); // 默认命令 program diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts new file mode 100644 index 0000000..39d89a3 --- /dev/null +++ b/src/utils/credentials.ts @@ -0,0 +1,56 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +interface Credentials { + token?: string; + baseUrl?: string; +} + +const CREDENTIALS_DIR = path.join(os.homedir(), '.envx'); +const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json'); + +function ensureDir(): void { + if (!fs.existsSync(CREDENTIALS_DIR)) { + fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); + } +} + +export function loadCredentials(): Credentials { + try { + if (fs.existsSync(CREDENTIALS_FILE)) { + const raw = fs.readFileSync(CREDENTIALS_FILE, 'utf-8'); + return JSON.parse(raw); + } + } catch { + // Ignore parse errors + } + return {}; +} + +export function saveCredentials(credentials: Credentials): void { + ensureDir(); + fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { + mode: 0o600, + }); +} + +export function clearCredentials(): void { + const credentials = loadCredentials(); + delete credentials.token; + saveCredentials(credentials); +} + +/** + * Returns the credential to use for requests. + * Priority: ENVX_API_KEY env var > stored session token. + */ +export function getCredential(): string | undefined { + return process.env.ENVX_API_KEY || loadCredentials().token; +} + +export function getAuthBaseUrl(): string { + return process.env.ENVX_BASEURL || loadCredentials().baseUrl || 'https://leaper.one'; +} + +export { CREDENTIALS_DIR, CREDENTIALS_FILE }; From f51bac1b259032e110ed7cb028717bd9f95276a4 Mon Sep 17 00:00:00 2001 From: cunoe Date: Wed, 1 Apr 2026 21:53:35 +0800 Subject: [PATCH 2/2] fix(login): unref timeout timer so process exits after login --- src/commands/login.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index 4fc31e2..6226104 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -92,10 +92,11 @@ function browserLogin(baseUrl: string): Promise { openBrowser(authUrl); }); - setTimeout(() => { + const timer = setTimeout(() => { server.close(); reject(new Error('Authorization timed out (3 minutes)')); }, 3 * 60 * 1000); + timer.unref(); }); }