diff --git a/src/commands/create-app-run.ts b/src/commands/create-app-run.ts index cda0cd4..cc5b1a5 100644 --- a/src/commands/create-app-run.ts +++ b/src/commands/create-app-run.ts @@ -4,6 +4,7 @@ import type { Step, WizardState } from '../core/context.js' import { runSteps } from '../core/run-steps.js' import { checkUpdate } from '../steps/check-update.js' import { chooseDatabase } from '../steps/choose-database.js' +import { chooseDatabaseType } from '../steps/choose-database-type.js' import { detectPm } from '../steps/detect-package-manager.js' import { downloadTemplate } from '../steps/download-template.js' import { installDeps } from '../steps/install-deps.js' @@ -25,6 +26,7 @@ export const createAppSteps = [ checkUpdate, promptAppName, phaseHeader('[1/3] Database setup'), + chooseDatabaseType, chooseDatabase, downloadTemplate, detectPm, diff --git a/src/constants.ts b/src/constants.ts index 99e648c..b7c1558 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,26 @@ +import type { DbChoice } from './core/context.js' + +export const TEMPLATE_REPO = 'github:chaibuilder/chaibuilder-app' + +/** Template branch to pull for each database type. */ +export const TEMPLATE_BRANCHES: Record = { + sqlite: 'main', + postgres: 'postgres', +} + export const TEMPLATE_SOURCE = - process.env.CHAIBUILDER_TEMPLATE ?? 'github:chaibuilder/chaibuilder-app#main' + process.env.CHAIBUILDER_TEMPLATE ?? `${TEMPLATE_REPO}#${TEMPLATE_BRANCHES.sqlite}` + +/** + * Resolve the template source for the chosen database. Postgres pulls the + * `postgres` branch; SQLite pulls `main`. A `CHAIBUILDER_TEMPLATE` env override + * (used in tests / local dev) always wins. + */ +export function templateSource(dbChoice: DbChoice): string { + if (process.env.CHAIBUILDER_TEMPLATE) return process.env.CHAIBUILDER_TEMPLATE + return `${TEMPLATE_REPO}#${TEMPLATE_BRANCHES[dbChoice]}` +} + export const DEFAULT_APP_NAME = 'my-chai-app' export const JSON_PROTOCOL_PREFIX = 'CHAI_JSON:' export const REQUIRED_ENV_VARS = [ diff --git a/src/core/context.ts b/src/core/context.ts index e29c49a..08581a0 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -1,4 +1,4 @@ -export type DbChoice = 'sqlite' +export type DbChoice = 'sqlite' | 'postgres' export type PackageManager = 'pnpm' | 'npm' | 'yarn' | 'bun' export type WizardState = { diff --git a/src/lib/postgres-url.ts b/src/lib/postgres-url.ts new file mode 100644 index 0000000..a7920a3 --- /dev/null +++ b/src/lib/postgres-url.ts @@ -0,0 +1,20 @@ +import { dequoteUrl } from './sqlite-url.js' + +/** + * Loose shape check for a PostgreSQL connection URL. + * Accepts postgres:// and postgresql:// URLs with a hostname. + */ +export function validatePostgresUrlShape(url: string): string | undefined { + const cleaned = dequoteUrl(url) + if (!cleaned) return 'URL is required' + if (!/^postgres(ql)?:\/\//i.test(cleaned)) { + return 'URL must start with postgres:// or postgresql://' + } + try { + const parsed = new URL(cleaned) + if (!parsed.hostname) return 'URL must include a hostname' + } catch { + return 'Invalid database URL' + } + return undefined +} diff --git a/src/steps/choose-database-type.ts b/src/steps/choose-database-type.ts new file mode 100644 index 0000000..e616f3b --- /dev/null +++ b/src/steps/choose-database-type.ts @@ -0,0 +1,33 @@ +import type { Step } from '../core/context.js' +import { CliError } from '../core/errors.js' +import { validatePostgresUrlShape } from '../lib/postgres-url.js' +import { dequoteUrl } from '../lib/sqlite-url.js' + +export const chooseDatabaseType: Step = async (ctx, ports) => { + const type = await ports.prompts.select({ + message: 'Database type', + options: [ + { value: 'sqlite' as const, label: 'SQLite', hint: 'local file or libSQL/Turso' }, + { value: 'postgres' as const, label: 'PostgreSQL', hint: 'connection url' }, + ], + initialValue: 'sqlite', + }) + if (ports.prompts.isCancel(type)) throw new CliError('Cancelled') + + if (type === 'sqlite') { + return { dbChoice: 'sqlite' } + } + + const previous = + ctx.dbChoice === 'postgres' && ctx.databaseUrl ? ctx.databaseUrl : undefined + const raw = await ports.prompts.text({ + message: 'PostgreSQL database URL', + placeholder: 'postgres://user:password@host:5432/dbname', + defaultValue: previous, + validate: (v) => validatePostgresUrlShape(v), + }) + if (ports.prompts.isCancel(raw)) throw new CliError('Cancelled') + const url = dequoteUrl(String(raw)) + + return { dbChoice: 'postgres', databaseUrl: url, databaseAuthToken: undefined } +} diff --git a/src/steps/choose-database.ts b/src/steps/choose-database.ts index 140fae7..3d6c0b3 100644 --- a/src/steps/choose-database.ts +++ b/src/steps/choose-database.ts @@ -6,6 +6,9 @@ import { createClient } from '@libsql/client' const LOCAL_DB_URL = 'file:./local.db' export const chooseDatabase: Step = async (ctx, ports) => { + // Postgres is fully configured in the database-type step; nothing to do here. + if (ctx.dbChoice === 'postgres') return {} + const mode = await ports.prompts.select({ message: 'SQLite database', options: [ diff --git a/src/steps/download-template.ts b/src/steps/download-template.ts index cea95cb..0697fa1 100644 --- a/src/steps/download-template.ts +++ b/src/steps/download-template.ts @@ -1,4 +1,4 @@ -import { TEMPLATE_SOURCE } from '../constants.js' +import { templateSource } from '../constants.js' import type { Step } from '../core/context.js' import { CliError } from '../core/errors.js' @@ -6,7 +6,7 @@ export const downloadTemplate: Step = async (ctx, ports) => { const spinner = ports.log.taskSpinner({ leadingBlank: true }) spinner.start('Downloading template…') try { - await ports.template.download(TEMPLATE_SOURCE, ctx.appDir) + await ports.template.download(templateSource(ctx.dbChoice), ctx.appDir) spinner.stop('Template downloaded') } catch (err) { spinner.error('Template download failed') diff --git a/tests/commands/create-app.test.ts b/tests/commands/create-app.test.ts index be78ecb..098af5a 100644 --- a/tests/commands/create-app.test.ts +++ b/tests/commands/create-app.test.ts @@ -52,7 +52,7 @@ describe('runCreateApp', () => { fs, template, exec: seededExec(fs), - prompts: createScriptedPrompts(['my-app', 'local', 'admin@example.com', 'password123']), + prompts: createScriptedPrompts(['my-app', 'sqlite', 'local', 'admin@example.com', 'password123']), randomBytes: fixedRandomBytes(0x22), log: createFakeLogger(), }) @@ -77,6 +77,7 @@ describe('runCreateApp', () => { exec: seededExec(fs), prompts: createScriptedPrompts([ 'remote-app', + 'sqlite', 'remote', 'libsql://my-db.turso.io', 'tok_abc', @@ -91,6 +92,35 @@ describe('runCreateApp', () => { expect(env.DATABASE_AUTH_TOKEN).toBe('tok_abc') }) + it('postgres writes connection url and pulls the postgres template', async () => { + const fs = createFakeFs('/work') + const template = createFakeTemplate() + template.download = async (source, dest) => { + template.downloads.push({ source, dest }) + await seedTemplateFiles(fs, dest) + } + const ports = createFakePorts({ + fs, + template, + exec: seededExec(fs), + prompts: createScriptedPrompts([ + 'pg-app', + 'postgres', + 'postgres://user:pass@db.example.com:5432/mydb', + 'admin@example.com', + 'password123', + ]), + }) + + const state = await runCreateApp(ports, { userAgent: 'pnpm/9' }) + expect(state.dbChoice).toBe('postgres') + expect(template.downloads[0]?.source).toBe('github:chaibuilder/chaibuilder-app#postgres') + const env = parseEnv(await fs.readFile(`${state.appDir}/.env`)) + expect(env.DATABASE_URL).toBe('postgres://user:pass@db.example.com:5432/mydb') + expect(env.DATABASE_AUTH_TOKEN).toBeUndefined() + expect(env.PAYLOAD_SECRET).toBeTruthy() + }) + it('migrate failure', async () => { const fs = createFakeFs('/work') const template = createFakeTemplate() @@ -106,7 +136,7 @@ describe('runCreateApp', () => { fs, template, exec, - prompts: createScriptedPrompts(['fail-app', 'local', 'admin@example.com', 'password123']), + prompts: createScriptedPrompts(['fail-app', 'sqlite', 'local', 'admin@example.com', 'password123']), }) await expect(runCreateApp(ports, { userAgent: 'pnpm/9' })).rejects.toThrow(/process\.exit/) diff --git a/tests/lib/postgres-url.test.ts b/tests/lib/postgres-url.test.ts new file mode 100644 index 0000000..be5c2a7 --- /dev/null +++ b/tests/lib/postgres-url.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { validatePostgresUrlShape } from '../../src/lib/postgres-url.js' + +describe('validatePostgresUrlShape', () => { + it('accepts postgres/postgresql urls', () => { + expect(validatePostgresUrlShape('postgres://user:pass@host:5432/db')).toBeUndefined() + expect(validatePostgresUrlShape('postgresql://user@host/db')).toBeUndefined() + }) + + it('rejects empty', () => { + expect(validatePostgresUrlShape(' ')).toBeTruthy() + }) + + it('rejects bad scheme', () => { + expect(validatePostgresUrlShape('mysql://localhost/db')).toBeTruthy() + expect(validatePostgresUrlShape('libsql://my-db.turso.io')).toBeTruthy() + }) + + it('rejects missing host', () => { + expect(validatePostgresUrlShape('postgres://')).toBeTruthy() + }) +}) diff --git a/tests/steps/choose-database-type.test.ts b/tests/steps/choose-database-type.test.ts new file mode 100644 index 0000000..dddd814 --- /dev/null +++ b/tests/steps/choose-database-type.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { CliError } from '../../src/core/errors.js' +import type { WizardState } from '../../src/core/context.js' +import { chooseDatabaseType } from '../../src/steps/choose-database-type.js' +import { createFakePorts, createScriptedPrompts } from '../fakes.js' + +const base: WizardState = { + appName: 'app', + appDir: '/tmp/app', + packageManager: 'pnpm', + dbChoice: 'sqlite', + databaseUrl: '', +} + +describe('chooseDatabaseType', () => { + it('sqlite defers to the sqlite step', async () => { + const ports = createFakePorts({ prompts: createScriptedPrompts(['sqlite']) }) + const patch = await chooseDatabaseType(base, ports) + expect(patch).toEqual({ dbChoice: 'sqlite' }) + }) + + it('postgres captures the connection url', async () => { + const ports = createFakePorts({ + prompts: createScriptedPrompts(['postgres', 'postgres://user:pass@host:5432/db']), + }) + const patch = await chooseDatabaseType(base, ports) + expect(patch).toEqual({ + dbChoice: 'postgres', + databaseUrl: 'postgres://user:pass@host:5432/db', + databaseAuthToken: undefined, + }) + }) + + it('accepts a postgresql:// url and strips quotes', async () => { + const ports = createFakePorts({ + prompts: createScriptedPrompts(['postgres', ' "postgresql://u:p@host/db" ']), + }) + const patch = await chooseDatabaseType(base, ports) + expect(patch.databaseUrl).toBe('postgresql://u:p@host/db') + }) + + it('rejects a non-postgres url via validate', async () => { + const ports = createFakePorts({ + prompts: createScriptedPrompts(['postgres', 'mysql://localhost/db']), + }) + await expect(chooseDatabaseType(base, ports)).rejects.toBeInstanceOf(Error) + }) + + it('throws on cancel', async () => { + const ports = createFakePorts({ prompts: createScriptedPrompts(['CANCEL']) }) + await expect(chooseDatabaseType(base, ports)).rejects.toBeInstanceOf(CliError) + }) +}) diff --git a/tests/steps/choose-database.test.ts b/tests/steps/choose-database.test.ts index 7d1cd1a..691c7d5 100644 --- a/tests/steps/choose-database.test.ts +++ b/tests/steps/choose-database.test.ts @@ -57,6 +57,15 @@ describe('chooseDatabase', () => { await expect(chooseDatabase(base, ports)).rejects.toBeInstanceOf(Error) }) + it('is a no-op when postgres was already chosen', async () => { + const ports = createFakePorts({ prompts: createScriptedPrompts([]) }) + const patch = await chooseDatabase( + { ...base, dbChoice: 'postgres', databaseUrl: 'postgres://u:p@h:5432/db' }, + ports, + ) + expect(patch).toEqual({}) + }) + it('throws on cancel', async () => { const ports = createFakePorts({ prompts: createScriptedPrompts(['CANCEL']) }) await expect(chooseDatabase(base, ports)).rejects.toBeInstanceOf(CliError) diff --git a/tests/steps/download-template.test.ts b/tests/steps/download-template.test.ts index 2e888d2..4782ebf 100644 --- a/tests/steps/download-template.test.ts +++ b/tests/steps/download-template.test.ts @@ -13,12 +13,20 @@ const ctx: WizardState = { } describe('downloadTemplate', () => { - it('happy path', async () => { + it('happy path pulls the main branch for sqlite', async () => { const template = createFakeTemplate() const ports = createFakePorts({ template }) await downloadTemplate(ctx, ports) expect(template.downloads).toHaveLength(1) expect(template.downloads[0]?.dest).toBe(ctx.appDir) + expect(template.downloads[0]?.source).toBe('github:chaibuilder/chaibuilder-app#main') + }) + + it('pulls the postgres branch for postgres', async () => { + const template = createFakeTemplate() + const ports = createFakePorts({ template }) + await downloadTemplate({ ...ctx, dbChoice: 'postgres' }, ports) + expect(template.downloads[0]?.source).toBe('github:chaibuilder/chaibuilder-app#postgres') }) it('failure wraps CliError', async () => {