diff --git a/README.md b/README.md index 0a9af61..4645899 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,10 @@ RESULTS=$(valyu answer "latest rate decision" --quiet) VALYU_API_KEY=your_key valyu search web "query" --json ``` +> **Key precedence:** `--api-key` flag → your `valyu login` key → `VALYU_API_KEY`. A logged-in +> key takes precedence over the env var, so you don't need to unset `VALYU_API_KEY` after +> running `valyu login`. `VALYU_API_KEY` still applies when you haven't logged in (e.g. CI). + ### Multiple profiles ```bash diff --git a/package.json b/package.json index c7ef4f2..0e381fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@valyu/cli", - "version": "1.2.0", + "version": "1.2.1", "description": "The search CLI for knowledge workers", "license": "MIT", "repository": { diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 1a480ea..0ef2626 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,24 +1,46 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { resolveApiKey, maskKey } from '../lib/config.js'; describe('resolveApiKey', () => { const originalEnv = process.env.VALYU_API_KEY; + const originalXdg = process.env.XDG_CONFIG_HOME; + let tmpRoot: string; + + // Point the config dir at a fresh empty temp dir so tests are hermetic and + // never read the developer's real ~/.config/valyu/credentials.json. + beforeEach(() => { + tmpRoot = mkdtempSync(join(tmpdir(), 'valyu-cli-test-')); + process.env.XDG_CONFIG_HOME = tmpRoot; + delete process.env.VALYU_API_KEY; + }); afterEach(() => { - if (originalEnv === undefined) { - delete process.env.VALYU_API_KEY; - } else { - process.env.VALYU_API_KEY = originalEnv; - } + if (originalEnv === undefined) delete process.env.VALYU_API_KEY; + else process.env.VALYU_API_KEY = originalEnv; + if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalXdg; + rmSync(tmpRoot, { recursive: true, force: true }); }); + // Write a credentials.json into the temp config dir (mimics `valyu login`). + const writeLogin = (apiKey: string, profile = 'default', active = profile) => { + const dir = join(tmpRoot, 'valyu'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'credentials.json'), + JSON.stringify({ active_profile: active, profiles: { [profile]: { api_key: apiKey } } }), + ); + }; + it('returns flag value when provided', () => { - delete process.env.VALYU_API_KEY; const result = resolveApiKey('flag-key-value-here-1234'); expect(result).toEqual({ key: 'flag-key-value-here-1234', source: 'flag' }); }); - it('returns env value when VALYU_API_KEY is set', () => { + it('returns env value when VALYU_API_KEY is set and there is no login', () => { process.env.VALYU_API_KEY = 'env-key-value-here-5678'; const result = resolveApiKey(undefined); expect(result).toEqual({ key: 'env-key-value-here-5678', source: 'env' }); @@ -30,6 +52,33 @@ describe('resolveApiKey', () => { expect(result?.source).toBe('flag'); expect(result?.key).toBe('flag-key-value-here-1234'); }); + + it('prefers the logged-in key (config) over VALYU_API_KEY', () => { + // The headline fix: a `valyu login` key must win over an ambient env var so + // users do not have to unset VALYU_API_KEY. + writeLogin('val_login_key_1234567890'); + process.env.VALYU_API_KEY = 'env-key-value-here-5678'; + const result = resolveApiKey(undefined); + expect(result).toEqual({ key: 'val_login_key_1234567890', source: 'config' }); + }); + + it('prefers flag over both the logged-in key and the env var', () => { + writeLogin('val_login_key_1234567890'); + process.env.VALYU_API_KEY = 'env-key-value-here-5678'; + const result = resolveApiKey('flag-key-value-here-1234'); + expect(result).toEqual({ key: 'flag-key-value-here-1234', source: 'flag' }); + }); + + it('falls back to env when the requested profile has no stored key', () => { + writeLogin('val_login_key_1234567890', 'default'); + process.env.VALYU_API_KEY = 'env-key-value-here-5678'; + const result = resolveApiKey(undefined, 'ci'); + expect(result).toEqual({ key: 'env-key-value-here-5678', source: 'env' }); + }); + + it('returns null when no flag, no login, and no env var', () => { + expect(resolveApiKey(undefined)).toBeNull(); + }); }); describe('maskKey', () => { diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 0e207e9..d0a363d 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -19,6 +19,18 @@ const API_KEYS_URL = `${PLATFORM_URL}/user/account/apikeys`; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +// Reassure the user that a pre-existing VALYU_API_KEY no longer shadows the key +// they just minted (the logged-in key takes precedence - see resolveApiKey). +function noteEnvPrecedence(): void { + if (process.env.VALYU_API_KEY) { + console.log( + pc.dim( + ' Note: VALYU_API_KEY is set in your environment - your logged-in key now takes precedence and will be used.', + ), + ); + } +} + export const loginCommand = new Command('login') .description('Authenticate the CLI (device flow by default; mints a scoped Valyu key)') .option('--key ', 'Authenticate with an existing API key (manual / CI mode)') @@ -149,6 +161,7 @@ async function deviceLogin( p.outro( `Logged in as ${pc.cyan(token.key_prefix)} - key stored for profile ${pc.cyan(`'${profile}'`)}`, ); + noteEnvPrecedence(); return; } @@ -254,5 +267,6 @@ async function manualLogin( outputResult({ success: true, config_path: configPath, profile: profileName }, { json: true }); } else { p.outro(`Logged in. API key stored for profile ${pc.cyan(`'${profileName}'`)}`); + noteEnvPrecedence(); } } diff --git a/src/lib/config.ts b/src/lib/config.ts index 96c7f82..2a3b0e2 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -65,15 +65,24 @@ export function writeCredentials(data: CredentialsFile): void { } export function resolveApiKey(flagValue?: string, profile?: string): ResolvedKey | null { + // Precedence: explicit flag > local login (config) > ambient VALYU_API_KEY env. + // + // A `valyu login` is a stronger, deliberate signal of intent than an ambient + // env var, so the logged-in key wins - otherwise a user who already has + // VALYU_API_KEY set would run `valyu login`, mint a scoped key, and silently + // keep using the old env-var key. The env var stays as the fallback for the + // no-login case (CI, unauthenticated), so those flows are unchanged. `--api-key` + // remains the ultimate override for one-off / scripted use. if (flagValue) return { key: flagValue, source: 'flag' }; - if (process.env.VALYU_API_KEY) return { key: process.env.VALYU_API_KEY, source: 'env' }; const creds = readCredentials(); - if (!creds) return null; + if (creds) { + const profileName = profile ?? creds.active_profile ?? 'default'; + const p = creds.profiles[profileName]; + if (p?.api_key) return { key: p.api_key, source: 'config' }; + } - const profileName = profile ?? creds.active_profile ?? 'default'; - const p = creds.profiles[profileName]; - if (p?.api_key) return { key: p.api_key, source: 'config' }; + if (process.env.VALYU_API_KEY) return { key: process.env.VALYU_API_KEY, source: 'env' }; return null; }