Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sql>`
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions src/commands/ai/index.ts
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);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
124 changes: 124 additions & 0 deletions src/commands/ai/setup.test.ts
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);
});
});
171 changes: 171 additions & 0 deletions src/commands/ai/setup.ts
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;
}
Comment thread
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;
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading