Skip to content
Draft
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
2 changes: 2 additions & 0 deletions src/commands/create-app-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,6 +26,7 @@ export const createAppSteps = [
checkUpdate,
promptAppName,
phaseHeader('[1/3] Database setup'),
chooseDatabaseType,
chooseDatabase,
downloadTemplate,
detectPm,
Expand Down
23 changes: 22 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
@@ -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<DbChoice, string> = {
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 = [
Expand Down
2 changes: 1 addition & 1 deletion src/core/context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type DbChoice = 'sqlite'
export type DbChoice = 'sqlite' | 'postgres'
export type PackageManager = 'pnpm' | 'npm' | 'yarn' | 'bun'

export type WizardState = {
Expand Down
20 changes: 20 additions & 0 deletions src/lib/postgres-url.ts
Original file line number Diff line number Diff line change
@@ -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
}
33 changes: 33 additions & 0 deletions src/steps/choose-database-type.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
3 changes: 3 additions & 0 deletions src/steps/choose-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
4 changes: 2 additions & 2 deletions src/steps/download-template.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { TEMPLATE_SOURCE } from '../constants.js'
import { templateSource } from '../constants.js'
import type { Step } from '../core/context.js'
import { CliError } from '../core/errors.js'

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')
Expand Down
34 changes: 32 additions & 2 deletions tests/commands/create-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Expand All @@ -77,6 +77,7 @@ describe('runCreateApp', () => {
exec: seededExec(fs),
prompts: createScriptedPrompts([
'remote-app',
'sqlite',
'remote',
'libsql://my-db.turso.io',
'tok_abc',
Expand All @@ -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()
Expand All @@ -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/)
Expand Down
22 changes: 22 additions & 0 deletions tests/lib/postgres-url.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
53 changes: 53 additions & 0 deletions tests/steps/choose-database-type.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
9 changes: 9 additions & 0 deletions tests/steps/choose-database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion tests/steps/download-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down