Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d2676fe
feat: add chatbot template via generic GitHub template downloader
tonychang04 Mar 19, 2026
56c603b
0.1.22
tonychang04 Mar 19, 2026
63a66f1
fix: read .env.example instead of hardcoding env vars in GitHub templ…
tonychang04 Mar 19, 2026
53cd4e8
0.1.23
tonychang04 Mar 19, 2026
10fe7e6
feat: auto-read env vars during create deploy step
tonychang04 Mar 19, 2026
cc36a42
0.1.24
tonychang04 Mar 19, 2026
a32af9e
fix: validate template flag, match env vars with digits, strip quotes
tonychang04 Mar 19, 2026
e86b608
feat: seed NEXT_PUBLIC_APP_URL with appkey.insforge.site
tonychang04 Mar 19, 2026
f4572f2
fix: confirm before running template database migrations
tonychang04 Mar 19, 2026
544328f
feat: add crm and e-commerce GitHub templates to create command
tonychang04 Mar 21, 2026
971cca4
0.1.27
tonychang04 Mar 21, 2026
fd4ceec
fix: auto-run template migrations in JSON/non-interactive mode
tonychang04 Mar 21, 2026
ba1c42b
merge: resolve package-lock.json conflict with main
tonychang04 Mar 21, 2026
4c41dc6
refactor: extract runRawSql helper, reuse in create and db query
tonychang04 Mar 21, 2026
30b2ea9
fix: revert db query to use ossFetch directly, preserve raw API response
tonychang04 Mar 21, 2026
08455ab
refactor: runRawSql returns rows + raw to preserve db query JSON output
tonychang04 Mar 21, 2026
8d2a617
0.1.28
tonychang04 Mar 21, 2026
1fb1c56
feat: auto-run template database migrations without prompting
tonychang04 Mar 21, 2026
3a39bc1
fix: rename db_int.sql to db_init.sql for template migrations
tonychang04 Mar 21, 2026
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
11 changes: 2 additions & 9 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.21",
"version": "0.1.30",
"description": "InsForge CLI - Command line tool for InsForge platform",
"type": "module",
"bin": {
Expand Down
106 changes: 103 additions & 3 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import {
getProject,
getProjectApiKey,
} from '../lib/api/platform.js';
import { getAnonKey } from '../lib/api/oss.js';
import { getAnonKey, runRawSql } from '../lib/api/oss.js';
import { getGlobalConfig, saveGlobalConfig, saveProjectConfig, getFrontendUrl } from '../lib/config.js';
import { requireAuth } from '../lib/credentials.js';
import { handleError, getRootOpts, CLIError } from '../lib/errors.js';
import { outputJson } from '../lib/output.js';
import { readEnvFile } from '../lib/env.js';
import { installCliGlobally, installSkills, reportCliUsage } from '../lib/skills.js';
import { deployProject } from './deployments/deploy.js';
import type { ProjectConfig } from '../types.js';
Expand Down Expand Up @@ -59,7 +60,7 @@ export function registerCreateCommand(program: Command): void {
.option('--name <name>', 'Project name')
.option('--org-id <id>', 'Organization ID')
.option('--region <region>', 'Deployment region (us-east, us-west, eu-central, ap-southeast)')
.option('--template <template>', 'Template to use: react, nextjs, or empty')
.option('--template <template>', 'Template to use: react, nextjs, chatbot, crm, e-commerce, or empty')
.action(async (opts, cmd) => {
const { json, apiUrl } = getRootOpts(cmd);
try {
Expand Down Expand Up @@ -108,7 +109,11 @@ export function registerCreateCommand(program: Command): void {
}

// 3. Select template
const validTemplates = ['react', 'nextjs', 'chatbot', 'crm', 'e-commerce', 'empty'];
let template = opts.template as string | undefined;
if (template && !validTemplates.includes(template)) {
throw new CLIError(`Invalid template "${template}". Valid options: ${validTemplates.join(', ')}`);
}
if (!template) {
if (json) {
template = 'empty';
Expand All @@ -118,6 +123,9 @@ export function registerCreateCommand(program: Command): void {
options: [
{ value: 'react', label: 'Web app template with React' },
{ value: 'nextjs', label: 'Web app template with Next.js' },
{ value: 'chatbot', label: 'AI Chatbot with Next.js' },
{ value: 'crm', label: 'CRM with Next.js' },
{ value: 'e-commerce', label: 'E-Commerce store with Next.js' },
{ value: 'empty', label: 'Empty project' },
],
});
Expand Down Expand Up @@ -152,7 +160,10 @@ export function registerCreateCommand(program: Command): void {

// 6. Download template if selected
const hasTemplate = template !== 'empty';
if (hasTemplate) {
const githubTemplates = ['chatbot', 'crm', 'e-commerce'];
if (githubTemplates.includes(template!)) {
await downloadGitHubTemplate(template!, projectConfig, json);
} else if (hasTemplate) {
await downloadTemplate(template as Framework, projectConfig, projectName, json, apiUrl);
}

Expand Down Expand Up @@ -186,9 +197,17 @@ export function registerCreateCommand(program: Command): void {

if (!clack.isCancel(shouldDeploy) && shouldDeploy) {
try {
// Read env vars from .env.local or .env to pass to deployment
const envVars = await readEnvFile(process.cwd());
const startBody: { envVars?: Array<{ key: string; value: string }> } = {};
if (envVars.length > 0) {
startBody.envVars = envVars;
}

const deploySpinner = clack.spinner();
const result = await deployProject({
sourceDir: process.cwd(),
startBody,
spinner: deploySpinner,
});

Expand Down Expand Up @@ -291,4 +310,85 @@ async function downloadTemplate(
}
}

async function downloadGitHubTemplate(
templateName: string,
projectConfig: ProjectConfig,
json: boolean,
): Promise<void> {
const s = !json ? clack.spinner() : null;
s?.start(`Downloading ${templateName} template...`);

const tempDir = path.join(tmpdir(), `insforge-template-${Date.now()}`);

try {
await fs.mkdir(tempDir, { recursive: true });

// Shallow clone the templates repo
await execAsync(
'git clone --depth 1 https://github.com/InsForge/insforge-templates.git .',
{ cwd: tempDir, maxBuffer: 10 * 1024 * 1024, timeout: 60_000 },
);

const templateDir = path.join(tempDir, templateName);
const stat = await fs.stat(templateDir).catch(() => null);
if (!stat?.isDirectory()) {
throw new Error(`Template "${templateName}" not found in repository`);
}

// Copy template files to cwd
s?.message('Copying template files...');
const cwd = process.cwd();
await copyDir(templateDir, cwd);

// Write .env.local from .env.example with InsForge credentials filled in
const envExamplePath = path.join(cwd, '.env.example');
const envExampleExists = await fs.stat(envExamplePath).catch(() => null);
if (envExampleExists) {
const anonKey = await getAnonKey();
const envExample = await fs.readFile(envExamplePath, 'utf-8');
const envContent = envExample.replace(
/^([A-Z][A-Z0-9_]*=)(.*)$/gm,
(_, prefix: string, _value: string) => {
const key = prefix.slice(0, -1); // remove trailing '='
if (/INSFORGE.*(URL|BASE_URL)$/.test(key)) return `${prefix}${projectConfig.oss_host}`;
if (/INSFORGE.*ANON_KEY$/.test(key)) return `${prefix}${anonKey}`;
if (key === 'NEXT_PUBLIC_APP_URL') return `${prefix}https://${projectConfig.appkey}.insforge.site`;
return `${prefix}${_value}`;
},
);
await fs.writeFile(path.join(cwd, '.env.local'), envContent);
}

s?.stop(`${templateName} template downloaded`);

// Auto-run database migrations if db_init.sql exists
const migrationPath = path.join(cwd, 'migrations', 'db_init.sql');
const migrationExists = await fs.stat(migrationPath).catch(() => null);
if (migrationExists) {
const dbSpinner = !json ? clack.spinner() : null;
dbSpinner?.start('Running database migrations...');
try {
const sql = await fs.readFile(migrationPath, 'utf-8');
await runRawSql(sql, true);
dbSpinner?.stop('Database migrations applied');
} catch (err) {
dbSpinner?.stop('Database migration failed');
if (!json) {
clack.log.warn(`Migration failed: ${(err as Error).message}`);
clack.log.info('You can run the migration manually: insforge db query --unrestricted "$(cat migrations/db_init.sql)"');
} else {
throw err;
}
}
}
} catch (err) {
s?.stop(`${templateName} template download failed`);
if (!json) {
clack.log.warn(`Failed to download ${templateName} template: ${(err as Error).message}`);
clack.log.info('You can manually clone from: https://github.com/InsForge/insforge-templates');
}
} finally {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}

21 changes: 5 additions & 16 deletions src/commands/db/query.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Command } from 'commander';
import { ossFetch } from '../../lib/api/oss.js';
import { runRawSql } from '../../lib/api/oss.js';
import { requireAuth } from '../../lib/credentials.js';
import { handleError, getRootOpts } from '../../lib/errors.js';
import { outputJson, outputTable } from '../../lib/output.js';
Expand All @@ -15,23 +15,12 @@ export function registerDbCommands(dbCmd: Command): void {
try {
await requireAuth();

const endpoint = opts.unrestricted
? '/api/database/advance/rawsql/unrestricted'
: '/api/database/advance/rawsql';

const res = await ossFetch(endpoint, {
method: 'POST',
body: JSON.stringify({ query: sql }),
});

const data = await res.json() as { rows?: Record<string, unknown>[]; data?: Record<string, unknown>[] };
const { rows, raw } = await runRawSql(sql, !!opts.unrestricted);

if (json) {
outputJson(data);
outputJson(raw);
} else {
// Try to render as table if results are array of objects
const rows = data.rows ?? data.data ?? null;
if (rows && rows.length > 0) {
if (rows.length > 0) {
const headers = Object.keys(rows[0]);
outputTable(
headers,
Expand All @@ -40,7 +29,7 @@ export function registerDbCommands(dbCmd: Command): void {
console.log(`${rows.length} row(s) returned.`);
} else {
console.log('Query executed successfully.');
if (rows && rows.length === 0) {
if (rows.length === 0) {
console.log('No rows returned.');
}
}
Expand Down
18 changes: 18 additions & 0 deletions src/lib/api/oss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ function requireProjectConfig(): ProjectConfig {
* Unified OSS API fetch. Uses API key as Bearer token for all requests,
* which grants superadmin access (SQL execution, bucket management, etc.).
*/
export interface RawSqlResult {
rows: Record<string, unknown>[];
raw: Record<string, unknown>;
}

export async function runRawSql(sql: string, unrestricted = false): Promise<RawSqlResult> {
const endpoint = unrestricted
? '/api/database/advance/rawsql/unrestricted'
: '/api/database/advance/rawsql';
const res = await ossFetch(endpoint, {
method: 'POST',
body: JSON.stringify({ query: sql }),
});
const raw = await res.json() as Record<string, unknown>;
const rows = (raw.rows ?? raw.data ?? []) as Record<string, unknown>[];
return { rows, raw };
}

export async function getAnonKey(): Promise<string> {
const res = await ossFetch('/api/auth/tokens/anon', { method: 'POST' });
const data = await res.json() as { accessToken: string };
Expand Down
33 changes: 33 additions & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';

/**
* Read environment variables from the first env file found in the directory.
* Priority: .env.local > .env.production > .env
*/
export async function readEnvFile(cwd: string): Promise<Array<{ key: string; value: string }>> {
const candidates = ['.env.local', '.env.production', '.env'];
for (const name of candidates) {
const filePath = path.join(cwd, name);
const exists = await fs.stat(filePath).catch(() => null);
if (!exists) continue;

const content = await fs.readFile(filePath, 'utf-8');
const vars: Array<{ key: string; value: string }> = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
let value = trimmed.slice(eqIndex + 1).trim();
// Strip surrounding quotes
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (key) vars.push({ key, value });
}
return vars;
}
return [];
}
Loading