diff --git a/README.md b/README.md index 1473e44..8c7620b 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,20 @@ npx @insforge/cli docs storage rest-api # Show REST API storage docs --- +### AI — `npx @insforge/cli ai` + +Configure local development for the InsForge Model Gateway. The setup command fetches the linked project's active OpenRouter key from the InsForge backend and writes it as the server-only `OPENROUTER_API_KEY` variable. + +```bash +npx @insforge/cli ai setup +npx @insforge/cli ai setup --env-file .env +npx @insforge/cli ai setup --json +``` + +By default the CLI writes `.env.local` and adds `.env*.local` to `.gitignore` when needed. For deployments such as Vercel, add `OPENROUTER_API_KEY` to the provider's server/runtime environment. Do not rename the key to `NEXT_PUBLIC_`, `VITE_`, or `PUBLIC_`; those prefixes expose values to browser code. + +--- + ### Database — `npx @insforge/cli db` #### `npx @insforge/cli db query ` diff --git a/package-lock.json b/package-lock.json index 455e93f..7a40917 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@insforge/cli", - "version": "0.1.76", + "version": "0.1.78", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@insforge/cli", - "version": "0.1.76", + "version": "0.1.78", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^0.9.1", diff --git a/package.json b/package.json index a56eb10..d3fcdc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@insforge/cli", - "version": "0.1.77", + "version": "0.1.78", "description": "InsForge CLI - Command line tool for InsForge platform", "type": "module", "bin": { diff --git a/src/commands/ai/index.ts b/src/commands/ai/index.ts new file mode 100644 index 0000000..febf3be --- /dev/null +++ b/src/commands/ai/index.ts @@ -0,0 +1,6 @@ +import type { Command } from 'commander'; +import { registerAiSetupCommand } from './setup.js'; + +export function registerAiCommands(aiCmd: Command): void { + registerAiSetupCommand(aiCmd); +} diff --git a/src/commands/ai/setup.test.ts b/src/commands/ai/setup.test.ts new file mode 100644 index 0000000..a1f35fe --- /dev/null +++ b/src/commands/ai/setup.test.ts @@ -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); + }); +}); diff --git a/src/commands/ai/setup.ts b/src/commands/ai/setup.ts new file mode 100644 index 0000000..624978e --- /dev/null +++ b/src/commands/ai/setup.ts @@ -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 ', `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 { + 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>; + 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; + } + + 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; +} diff --git a/src/index.ts b/src/index.ts index 1c2edc4..9c381a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,6 +76,7 @@ import { registerDiagnoseCommands } from './commands/diagnose/index.js'; import { registerPaymentsCommands } from './commands/payments/index.js'; import { registerPosthogSetupCommand } from './commands/posthog/setup.js'; import { registerConfigCommand } from './commands/config/index.js'; +import { registerAiCommands } from './commands/ai/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')) as { version: string }; @@ -209,6 +210,10 @@ registerComputeEventsCommand(computeCmd); const posthogCmd = program.command('posthog').description('Manage PostHog product analytics integration'); registerPosthogSetupCommand(posthogCmd); +// AI commands +const aiCmd = program.command('ai').description('Manage AI model gateway setup'); +registerAiCommands(aiCmd); + // Schedules commands const schedulesCmd = program.command('schedules').description('Manage scheduled tasks (cron jobs)'); registerSchedulesListCommand(schedulesCmd); diff --git a/src/lib/api/ai.test.ts b/src/lib/api/ai.test.ts new file mode 100644 index 0000000..24293e0 --- /dev/null +++ b/src/lib/api/ai.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { getOpenRouterApiKey } from './ai.js'; + +vi.mock('./oss.js', () => ({ + ossFetch: vi.fn(), +})); + +describe('getOpenRouterApiKey', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches the OpenRouter key from the AI backend endpoint', async () => { + const { ossFetch } = await import('./oss.js'); + (ossFetch as ReturnType).mockResolvedValueOnce({ + json: async () => ({ apiKey: ' sk-or-test ', maskedKey: ' sk-or-****test ' }), + }); + + await expect(getOpenRouterApiKey()).resolves.toEqual({ + apiKey: 'sk-or-test', + maskedKey: 'sk-or-****test', + }); + expect(ossFetch).toHaveBeenCalledWith('/api/ai/openrouter/api-key'); + }); + + it('throws a clear error when the backend returns no raw key', async () => { + const { ossFetch } = await import('./oss.js'); + (ossFetch as ReturnType).mockResolvedValueOnce({ + json: async () => ({ maskedKey: 'sk-or-****test' }), + }); + + await expect(getOpenRouterApiKey()).rejects.toThrow(/returned no OpenRouter API key/); + }); + + it('throws a clear error when the backend returns a whitespace-only key', async () => { + const { ossFetch } = await import('./oss.js'); + (ossFetch as ReturnType).mockResolvedValueOnce({ + json: async () => ({ apiKey: ' ', maskedKey: 'sk-or-****test' }), + }); + + await expect(getOpenRouterApiKey()).rejects.toThrow(/returned no OpenRouter API key/); + }); +}); diff --git a/src/lib/api/ai.ts b/src/lib/api/ai.ts new file mode 100644 index 0000000..20a9e4b --- /dev/null +++ b/src/lib/api/ai.ts @@ -0,0 +1,25 @@ +import { CLIError } from '../errors.js'; +import { ossFetch } from './oss.js'; + +export interface OpenRouterKeyResponse { + apiKey: string; + maskedKey?: string; +} + +export async function getOpenRouterApiKey(): Promise { + const res = await ossFetch('/api/ai/openrouter/api-key'); + const data = await res.json() as Partial; + const apiKey = typeof data.apiKey === 'string' ? data.apiKey.trim() : ''; + const maskedKey = typeof data.maskedKey === 'string' ? data.maskedKey.trim() : undefined; + + if (apiKey.length === 0) { + throw new CLIError( + 'AI gateway returned no OpenRouter API key. Open the InsForge dashboard AI page and verify Model Gateway is configured.', + ); + } + + return { + apiKey, + maskedKey, + }; +} diff --git a/src/lib/api/oss.test.ts b/src/lib/api/oss.test.ts index 0226d7b..6893316 100644 --- a/src/lib/api/oss.test.ts +++ b/src/lib/api/oss.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from 'vitest'; -import { isMaskedDatabasePassword, spliceDatabasePassword } from './oss.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as config from '../config.js'; +import { isMaskedDatabasePassword, ossFetch, spliceDatabasePassword } from './oss.js'; +import type { ProjectConfig } from '../../types.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); describe('spliceDatabasePassword', () => { // Real shape from cloud `/api/metadata/database-connection-string` @@ -47,3 +53,27 @@ describe('isMaskedDatabasePassword', () => { expect(isMaskedDatabasePassword('')).toBe(false); }); }); + +describe('ossFetch', () => { + it('shows an AI-specific 404 message for backends without Model Gateway setup', async () => { + vi.spyOn(config, 'getProjectConfig').mockReturnValue({ + 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', + } satisfies ProjectConfig); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'NOT_FOUND' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + await expect(ossFetch('/api/ai/openrouter/api-key')).rejects.toThrow( + /Upgrade your InsForge project to a version with Model Gateway support/, + ); + }); +}); diff --git a/src/lib/api/oss.ts b/src/lib/api/oss.ts index d61da63..c149aa5 100644 --- a/src/lib/api/oss.ts +++ b/src/lib/api/oss.ts @@ -154,6 +154,10 @@ export async function ossFetch( message = 'Database migrations are not available on this backend.\nSelf-hosted: upgrade your InsForge instance. Cloud: contact your InsForge admin about database migration support.'; } + if (res.status === 404 && isRouteLevel404 && path.startsWith('/api/ai')) { + message = 'AI Model Gateway setup is not available on this backend.\nUpgrade your InsForge project to a version with Model Gateway support, or keep using the legacy @insforge/sdk AI modules for projects that still rely on the older AI API surface.'; + } + throw new CLIError(message); }