-
Notifications
You must be signed in to change notification settings - Fork 10
Add ai setup command to configure openrouter key #126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import type { Command } from 'commander'; | ||
| import { registerAiSetupCommand } from './setup.js'; | ||
|
|
||
| export function registerAiCommands(aiCmd: Command): void { | ||
| registerAiSetupCommand(aiCmd); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; | ||
| import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { runAiSetup, ensureLocalEnvIgnored } from './setup.js'; | ||
|
|
||
| vi.mock('../../lib/api/ai.js', () => ({ | ||
| getOpenRouterApiKey: vi.fn(async () => ({ | ||
| apiKey: 'sk-or-secret', | ||
| maskedKey: 'sk-or-****cret', | ||
| })), | ||
| })); | ||
|
|
||
| vi.mock('../../lib/config.js', () => ({ | ||
| getProjectConfig: vi.fn(() => ({ | ||
| project_id: 'p1', | ||
| project_name: 'demo', | ||
| org_id: 'o1', | ||
| appkey: 'app', | ||
| region: 'us-east', | ||
| api_key: 'ik_test', | ||
| oss_host: 'https://app.us-east.insforge.app', | ||
| })), | ||
| })); | ||
|
|
||
| vi.mock('../../lib/analytics.js', () => ({ | ||
| captureEvent: vi.fn(), | ||
| shutdownAnalytics: vi.fn(async () => {}), | ||
| })); | ||
|
|
||
| let dir: string; | ||
| let originalCwd: string; | ||
|
|
||
| beforeEach(() => { | ||
| originalCwd = process.cwd(); | ||
| dir = mkdtempSync(join(tmpdir(), 'cli-ai-setup-')); | ||
| process.chdir(dir); | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| process.chdir(originalCwd); | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe('runAiSetup', () => { | ||
| it('writes OPENROUTER_API_KEY to .env.local and ignores local env files', async () => { | ||
| const result = await runAiSetup({ json: true }); | ||
|
|
||
| expect(readFileSync(join(dir, '.env.local'), 'utf-8')).toBe( | ||
| 'OPENROUTER_API_KEY=sk-or-secret\n', | ||
| ); | ||
| expect(readFileSync(join(dir, '.gitignore'), 'utf-8')).toContain('.env*.local'); | ||
| expect(result).toEqual({ | ||
| envFile: '.env.local', | ||
| added: ['OPENROUTER_API_KEY'], | ||
| skipped: [], | ||
| mismatched: [], | ||
| gitignoreUpdated: true, | ||
| maskedKey: 'sk-or-****cret', | ||
| }); | ||
| }); | ||
|
|
||
| it('does not return the raw key in the setup result', async () => { | ||
| const result = await runAiSetup({ json: true }); | ||
| expect(JSON.stringify(result)).not.toContain('sk-or-secret'); | ||
| }); | ||
|
|
||
| it('does not overwrite an existing different OpenRouter key', async () => { | ||
| writeFileSync(join(dir, '.env.local'), 'OPENROUTER_API_KEY=sk-or-existing\n'); | ||
|
|
||
| const result = await runAiSetup({ json: true }); | ||
|
|
||
| expect(readFileSync(join(dir, '.env.local'), 'utf-8')).toBe( | ||
| 'OPENROUTER_API_KEY=sk-or-existing\n', | ||
| ); | ||
| expect(result.added).toEqual([]); | ||
| expect(result.mismatched).toEqual(['OPENROUTER_API_KEY']); | ||
| }); | ||
|
|
||
| it('skips an existing matching OpenRouter key', async () => { | ||
| writeFileSync(join(dir, '.env.local'), 'OPENROUTER_API_KEY=sk-or-secret\n'); | ||
|
|
||
| const result = await runAiSetup({ json: true }); | ||
|
|
||
| expect(readFileSync(join(dir, '.env.local'), 'utf-8')).toBe( | ||
| 'OPENROUTER_API_KEY=sk-or-secret\n', | ||
| ); | ||
| expect(result.added).toEqual([]); | ||
| expect(result.skipped).toEqual(['OPENROUTER_API_KEY']); | ||
| }); | ||
|
|
||
| it('respects --env-file paths and does not add non-local env files to gitignore', async () => { | ||
| const result = await runAiSetup({ json: true, envFile: '.env' }); | ||
|
|
||
| expect(readFileSync(join(dir, '.env'), 'utf-8')).toBe( | ||
| 'OPENROUTER_API_KEY=sk-or-secret\n', | ||
| ); | ||
| expect(existsSync(join(dir, '.gitignore'))).toBe(false); | ||
| expect(result.envFile).toBe('.env'); | ||
| expect(result.gitignoreUpdated).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('ensureLocalEnvIgnored', () => { | ||
| it('does not add .env*.local when .env* is already ignored', () => { | ||
| writeFileSync(join(dir, '.gitignore'), '.env*\n'); | ||
| expect(ensureLocalEnvIgnored(dir, '.env.local')).toBe(false); | ||
| expect(readFileSync(join(dir, '.gitignore'), 'utf-8')).toBe('.env*\n'); | ||
| }); | ||
|
|
||
| it('adds .env*.local for non-default local env files when only .env.local is ignored', () => { | ||
| writeFileSync(join(dir, '.gitignore'), '.env.local\n'); | ||
| expect(ensureLocalEnvIgnored(dir, '.env.staging.local')).toBe(true); | ||
| expect(readFileSync(join(dir, '.gitignore'), 'utf-8')).toBe( | ||
| '.env.local\n\n# Local environment secrets\n.env*.local\n', | ||
| ); | ||
| }); | ||
|
|
||
| it('does not update gitignore for env files outside the project', () => { | ||
| expect(ensureLocalEnvIgnored(dir, join(tmpdir(), '.env.local'))).toBe(false); | ||
| expect(existsSync(join(dir, '.gitignore'))).toBe(false); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| import type { Command } from 'commander'; | ||
| import { appendFileSync, existsSync, readFileSync } from 'node:fs'; | ||
| import { isAbsolute, join, relative, resolve } from 'node:path'; | ||
| import * as clack from '@clack/prompts'; | ||
| import pc from 'picocolors'; | ||
| import { captureEvent, shutdownAnalytics } from '../../lib/analytics.js'; | ||
| import { getOpenRouterApiKey } from '../../lib/api/ai.js'; | ||
| import { getProjectConfig } from '../../lib/config.js'; | ||
| import { getRootOpts, handleError, ProjectNotLinkedError } from '../../lib/errors.js'; | ||
| import { upsertEnvFile } from '../../lib/env-writer.js'; | ||
| import { outputInfo, outputJson, outputSuccess } from '../../lib/output.js'; | ||
| import { isInteractive } from '../../lib/prompts.js'; | ||
|
|
||
| const DEFAULT_ENV_FILE = '.env.local'; | ||
| const OPENROUTER_ENV_KEY = 'OPENROUTER_API_KEY'; | ||
|
|
||
| export interface AiSetupResult { | ||
| envFile: string; | ||
| added: string[]; | ||
| skipped: string[]; | ||
| mismatched: string[]; | ||
| gitignoreUpdated: boolean; | ||
| maskedKey?: string; | ||
| } | ||
|
|
||
| interface RunAiSetupOptions { | ||
| envFile?: string; | ||
| json: boolean; | ||
| } | ||
|
|
||
| export function registerAiSetupCommand(aiCmd: Command): void { | ||
| aiCmd | ||
| .command('setup') | ||
| .description('Write the linked project OpenRouter key to a local env file') | ||
| .option('--env-file <path>', `Env file to update (default: ${DEFAULT_ENV_FILE})`) | ||
| .action(async (opts: { envFile?: string }, cmd) => { | ||
| const { json } = getRootOpts(cmd); | ||
| try { | ||
| const result = await runAiSetup({ | ||
| envFile: opts.envFile, | ||
| json, | ||
| }); | ||
|
|
||
| if (json) { | ||
| outputJson({ success: true, ...result }); | ||
| } | ||
| } catch (err) { | ||
| handleError(err, json); | ||
| } finally { | ||
| await shutdownAnalytics(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| export async function runAiSetup(opts: RunAiSetupOptions): Promise<AiSetupResult> { | ||
| const project = getProjectConfig(); | ||
| if (!project) { | ||
| throw new ProjectNotLinkedError(); | ||
| } | ||
|
|
||
| if (!opts.json) { | ||
| clack.intro('AI setup'); | ||
| outputSuccess(`Linked to InsForge project: ${project.project_name} (${project.project_id})`); | ||
| } | ||
|
|
||
| const spinner = !opts.json && isInteractive ? clack.spinner() : null; | ||
| spinner?.start('Fetching OpenRouter key...'); | ||
| let key: Awaited<ReturnType<typeof getOpenRouterApiKey>>; | ||
| try { | ||
| key = await getOpenRouterApiKey(); | ||
| spinner?.stop('Fetched OpenRouter key.'); | ||
| } catch (err) { | ||
| spinner?.stop('Could not fetch OpenRouter key.'); | ||
| throw err; | ||
| } | ||
| const envFile = opts.envFile ?? DEFAULT_ENV_FILE; | ||
| const envPath = resolve(process.cwd(), envFile); | ||
| const envLabel = displayPath(envPath); | ||
| const update = upsertEnvFile(envPath, { [OPENROUTER_ENV_KEY]: key.apiKey }); | ||
| const gitignoreUpdated = ensureLocalEnvIgnored(process.cwd(), envFile); | ||
|
|
||
| captureEvent(project.project_id, 'cli_ai_setup', { | ||
| project_id: project.project_id, | ||
| project_name: project.project_name, | ||
| org_id: project.org_id, | ||
| region: project.region, | ||
| env_file: envLabel, | ||
| added: update.added.includes(OPENROUTER_ENV_KEY), | ||
| skipped: update.skipped.includes(OPENROUTER_ENV_KEY), | ||
| mismatched: update.mismatched.some((m) => m.key === OPENROUTER_ENV_KEY), | ||
| }); | ||
|
|
||
| if (!opts.json) { | ||
| if (update.added.length > 0) { | ||
| outputSuccess(`Wrote ${envLabel}: ${update.added.join(', ')}`); | ||
| } | ||
| if (update.skipped.length > 0) { | ||
| outputInfo(pc.dim(`${envLabel}: ${update.skipped.join(', ')} already set (matching) - left as-is.`)); | ||
| } | ||
| for (const m of update.mismatched) { | ||
| clack.log.warn( | ||
| `${envLabel} already has ${m.key}; left existing value untouched. Remove it or pass --env-file to write elsewhere.`, | ||
| ); | ||
| } | ||
| if (gitignoreUpdated) { | ||
| outputInfo(pc.dim('Added .env*.local to .gitignore.')); | ||
| } | ||
| if (!isLocalEnvFile(envFile)) { | ||
| clack.log.warn( | ||
| `${envLabel} may be committed unless it is listed in .gitignore. Keep ${OPENROUTER_ENV_KEY} server-only.`, | ||
| ); | ||
| } | ||
|
|
||
| outputInfo(''); | ||
| outputInfo('Use this key only from server-side code as process.env.OPENROUTER_API_KEY.'); | ||
| outputInfo('For deployment, add OPENROUTER_API_KEY to your hosting provider environment.'); | ||
| outputInfo(`Do not rename it to ${pc.bold('NEXT_PUBLIC_')}, ${pc.bold('VITE_')}, or ${pc.bold('PUBLIC_')}.`); | ||
| clack.outro('Done.'); | ||
| } | ||
|
|
||
| return { | ||
| envFile: envLabel, | ||
| added: update.added, | ||
| skipped: update.skipped, | ||
| mismatched: update.mismatched.map((m) => m.key), | ||
| gitignoreUpdated, | ||
| maskedKey: key.maskedKey, | ||
| }; | ||
| } | ||
|
|
||
| function displayPath(path: string): string { | ||
| const rel = relative(process.cwd(), path); | ||
| if (!rel || rel.startsWith('..') || isAbsolute(rel)) { | ||
| return path; | ||
| } | ||
| return rel; | ||
| } | ||
|
|
||
| function isLocalEnvFile(envFile: string): boolean { | ||
| const normalized = envFile.replace(/\\/g, '/'); | ||
| const basename = normalized.split('/').pop() ?? normalized; | ||
| return basename === '.env.local' || /^\.env\..+\.local$/.test(basename); | ||
| } | ||
|
|
||
| export function ensureLocalEnvIgnored(cwd: string, envFile: string): boolean { | ||
| if (!isLocalEnvFile(envFile)) return false; | ||
|
|
||
| const envPath = resolve(cwd, envFile); | ||
| const relEnvPath = relative(cwd, envPath); | ||
| if (!relEnvPath || relEnvPath.startsWith('..') || isAbsolute(relEnvPath)) { | ||
| return false; | ||
| } | ||
|
|
||
| const gitignorePath = join(cwd, '.gitignore'); | ||
| const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : ''; | ||
| const lines = new Set(existing.split(/\r?\n/).map((line) => line.trim())); | ||
| const envBasename = envFile.replace(/\\/g, '/').split('/').pop() ?? envFile; | ||
| if ( | ||
| lines.has('.env*') || | ||
| lines.has('.env.*') || | ||
| lines.has('.env*.local') || | ||
| (lines.has('.env.local') && envBasename === '.env.local') | ||
| ) { | ||
| return false; | ||
| } | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''; | ||
| const spacer = existing.length > 0 ? '\n' : ''; | ||
| appendFileSync(gitignorePath, `${prefix}${spacer}# Local environment secrets\n.env*.local\n`); | ||
| return true; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.