From 3c0fa3832bc6193b9ba6d26260b703cb1c22d5fc Mon Sep 17 00:00:00 2001 From: Lyu Date: Wed, 13 May 2026 12:01:01 -0700 Subject: [PATCH 1/3] add ai setup command to configure openrouter key --- README.md | 14 +++ package-lock.json | 4 +- src/commands/ai/index.ts | 7 ++ src/commands/ai/setup.test.ts | 104 ++++++++++++++++++++++ src/commands/ai/setup.ts | 160 ++++++++++++++++++++++++++++++++++ src/index.ts | 5 ++ src/lib/api/ai.test.ts | 34 ++++++++ src/lib/api/ai.ts | 23 +++++ 8 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 src/commands/ai/index.ts create mode 100644 src/commands/ai/setup.test.ts create mode 100644 src/commands/ai/setup.ts create mode 100644 src/lib/api/ai.test.ts create mode 100644 src/lib/api/ai.ts 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..2ba4cb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@insforge/cli", - "version": "0.1.76", + "version": "0.1.77", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@insforge/cli", - "version": "0.1.76", + "version": "0.1.77", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^0.9.1", diff --git a/src/commands/ai/index.ts b/src/commands/ai/index.ts new file mode 100644 index 0000000..cbbaa72 --- /dev/null +++ b/src/commands/ai/index.ts @@ -0,0 +1,7 @@ +import type { Command } from 'commander'; +import { registerAiSetupCommand } from './setup.js'; + +export function registerAiCommands(aiCmd: Command): void { + aiCmd.description('Manage AI model gateway setup'); + registerAiSetupCommand(aiCmd); +} diff --git a/src/commands/ai/setup.test.ts b/src/commands/ai/setup.test.ts new file mode 100644 index 0000000..1bb2f0d --- /dev/null +++ b/src/commands/ai/setup.test.ts @@ -0,0 +1,104 @@ +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('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('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..1c53616 --- /dev/null +++ b/src/commands/ai/setup.ts @@ -0,0 +1,160 @@ +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'; + +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 key = await getOpenRouterApiKey(); + 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())); + if ( + lines.has('.env*') || + lines.has('.env.*') || + lines.has('.env*.local') || + lines.has('.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..5adcdef --- /dev/null +++ b/src/lib/api/ai.test.ts @@ -0,0 +1,34 @@ +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/); + }); +}); diff --git a/src/lib/api/ai.ts b/src/lib/api/ai.ts new file mode 100644 index 0000000..8fc4097 --- /dev/null +++ b/src/lib/api/ai.ts @@ -0,0 +1,23 @@ +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; + + if (typeof data.apiKey !== 'string' || data.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: data.apiKey, + maskedKey: typeof data.maskedKey === 'string' ? data.maskedKey : undefined, + }; +} From e3e580e35567d1bc23913aff8a25b73ab21c1fd8 Mon Sep 17 00:00:00 2001 From: Lyu Date: Wed, 13 May 2026 12:28:08 -0700 Subject: [PATCH 2/3] address comments --- src/commands/ai/index.ts | 1 - src/commands/ai/setup.test.ts | 20 ++++++++++++++++++++ src/commands/ai/setup.ts | 15 +++++++++++++-- src/lib/api/ai.test.ts | 11 ++++++++++- src/lib/api/ai.ts | 8 +++++--- src/lib/api/oss.test.ts | 34 ++++++++++++++++++++++++++++++++-- src/lib/api/oss.ts | 4 ++++ 7 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/commands/ai/index.ts b/src/commands/ai/index.ts index cbbaa72..febf3be 100644 --- a/src/commands/ai/index.ts +++ b/src/commands/ai/index.ts @@ -2,6 +2,5 @@ import type { Command } from 'commander'; import { registerAiSetupCommand } from './setup.js'; export function registerAiCommands(aiCmd: Command): void { - aiCmd.description('Manage AI model gateway setup'); registerAiSetupCommand(aiCmd); } diff --git a/src/commands/ai/setup.test.ts b/src/commands/ai/setup.test.ts index 1bb2f0d..a1f35fe 100644 --- a/src/commands/ai/setup.test.ts +++ b/src/commands/ai/setup.test.ts @@ -78,6 +78,18 @@ describe('runAiSetup', () => { 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' }); @@ -97,6 +109,14 @@ describe('ensureLocalEnvIgnored', () => { 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 index 1c53616..624978e 100644 --- a/src/commands/ai/setup.ts +++ b/src/commands/ai/setup.ts @@ -9,6 +9,7 @@ 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'; @@ -62,7 +63,16 @@ export async function runAiSetup(opts: RunAiSetupOptions): Promise>; + 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); @@ -144,11 +154,12 @@ export function ensureLocalEnvIgnored(cwd: string, envFile: string): boolean { 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') + (lines.has('.env.local') && envBasename === '.env.local') ) { return false; } diff --git a/src/lib/api/ai.test.ts b/src/lib/api/ai.test.ts index 5adcdef..24293e0 100644 --- a/src/lib/api/ai.test.ts +++ b/src/lib/api/ai.test.ts @@ -13,7 +13,7 @@ describe('getOpenRouterApiKey', () => { 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' }), + json: async () => ({ apiKey: ' sk-or-test ', maskedKey: ' sk-or-****test ' }), }); await expect(getOpenRouterApiKey()).resolves.toEqual({ @@ -31,4 +31,13 @@ describe('getOpenRouterApiKey', () => { 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 index 8fc4097..20a9e4b 100644 --- a/src/lib/api/ai.ts +++ b/src/lib/api/ai.ts @@ -9,15 +9,17 @@ export interface OpenRouterKeyResponse { 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 (typeof data.apiKey !== 'string' || data.apiKey.length === 0) { + 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: data.apiKey, - maskedKey: typeof data.maskedKey === 'string' ? data.maskedKey : undefined, + 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); } From 922d7ef454397f51b630e112de84df5fa5bde5d6 Mon Sep 17 00:00:00 2001 From: Lyu Date: Wed, 13 May 2026 12:28:57 -0700 Subject: [PATCH 3/3] bump CLI version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ba4cb2..7a40917 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@insforge/cli", - "version": "0.1.77", + "version": "0.1.78", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@insforge/cli", - "version": "0.1.77", + "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": {