From c4b3f7d44224c98f9ad17ea2952a027b2fb7d39c Mon Sep 17 00:00:00 2001 From: timoconnellaus Date: Thu, 3 Jul 2025 14:15:32 +1000 Subject: [PATCH 1/9] feat: add configurable add-on options system - Add Zod schemas for add-on option definitions - Support conditional file generation with EJS templates - Enable package.json.ejs for conditional dependencies - Implement filename prefix stripping for database-specific files - Create Drizzle add-on with database provider option --- .../assets/__mysql__drizzle.config.ts.ejs | 13 +++ .../assets/__postgres__drizzle.config.ts.ejs | 13 +++ .../assets/__sqlite__drizzle.config.ts.ejs | 13 +++ .../drizzle/assets/_dot_env.local.append.ejs | 2 + .../assets/src/db/__mysql__index.ts.ejs | 7 ++ .../assets/src/db/__postgres__index.ts.ejs | 7 ++ .../assets/src/db/__sqlite__index.ts.ejs | 7 ++ .../drizzle/assets/src/db/schema.ts.ejs | 9 ++ .../assets/src/routes/demo.drizzle.tsx.ejs | 85 +++++++++++++++++++ .../react-cra/add-ons/drizzle/info.json | 40 +++++++++ .../add-ons/drizzle/package.json.ejs | 19 +++++ packages/cta-cli/src/command-line.ts | 2 + packages/cta-cli/src/mcp.ts | 2 + packages/cta-cli/src/options.ts | 2 + packages/cta-engine/src/add-ons.ts | 18 ++++ .../cta-engine/src/custom-add-ons/shared.ts | 11 ++- packages/cta-engine/src/frameworks.ts | 7 +- packages/cta-engine/src/index.ts | 2 +- packages/cta-engine/src/package-json.ts | 40 ++++++++- packages/cta-engine/src/template-file.ts | 8 ++ packages/cta-engine/src/types.ts | 36 +++++++- packages/cta-ui-base/src/store/project.ts | 1 + 22 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 frameworks/react-cra/add-ons/drizzle/assets/__mysql__drizzle.config.ts.ejs create mode 100644 frameworks/react-cra/add-ons/drizzle/assets/__postgres__drizzle.config.ts.ejs create mode 100644 frameworks/react-cra/add-ons/drizzle/assets/__sqlite__drizzle.config.ts.ejs create mode 100644 frameworks/react-cra/add-ons/drizzle/assets/_dot_env.local.append.ejs create mode 100644 frameworks/react-cra/add-ons/drizzle/assets/src/db/__mysql__index.ts.ejs create mode 100644 frameworks/react-cra/add-ons/drizzle/assets/src/db/__postgres__index.ts.ejs create mode 100644 frameworks/react-cra/add-ons/drizzle/assets/src/db/__sqlite__index.ts.ejs create mode 100644 frameworks/react-cra/add-ons/drizzle/assets/src/db/schema.ts.ejs create mode 100644 frameworks/react-cra/add-ons/drizzle/assets/src/routes/demo.drizzle.tsx.ejs create mode 100644 frameworks/react-cra/add-ons/drizzle/info.json create mode 100644 frameworks/react-cra/add-ons/drizzle/package.json.ejs diff --git a/frameworks/react-cra/add-ons/drizzle/assets/__mysql__drizzle.config.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/__mysql__drizzle.config.ts.ejs new file mode 100644 index 00000000..3d1f5e1e --- /dev/null +++ b/frameworks/react-cra/add-ons/drizzle/assets/__mysql__drizzle.config.ts.ejs @@ -0,0 +1,13 @@ +<% if (addOnOption.drizzle.database !== 'mysql') { ignoreFile() } %> +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './<%= addOnOption.drizzle.schema %>/schema.<%= js %>', + out: './<%= addOnOption.drizzle.schema %>/migrations', + driver: 'mysql2', + dbCredentials: { + connectionString: process.env.DATABASE_URL!, + }, + verbose: true, + strict: true, +}) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/__postgres__drizzle.config.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/__postgres__drizzle.config.ts.ejs new file mode 100644 index 00000000..5acc95ad --- /dev/null +++ b/frameworks/react-cra/add-ons/drizzle/assets/__postgres__drizzle.config.ts.ejs @@ -0,0 +1,13 @@ +<% if (addOnOption.drizzle.database !== 'postgres') { ignoreFile() } %> +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './<%= addOnOption.drizzle.schema %>/schema.<%= js %>', + out: './<%= addOnOption.drizzle.schema %>/migrations', + driver: 'pg', + dbCredentials: { + connectionString: process.env.DATABASE_URL!, + }, + verbose: true, + strict: true, +}) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/__sqlite__drizzle.config.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/__sqlite__drizzle.config.ts.ejs new file mode 100644 index 00000000..e1ae5850 --- /dev/null +++ b/frameworks/react-cra/add-ons/drizzle/assets/__sqlite__drizzle.config.ts.ejs @@ -0,0 +1,13 @@ +<% if (addOnOption.drizzle.database !== 'sqlite') { ignoreFile() } %> +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './<%= addOnOption.drizzle.schema %>/schema.<%= js %>', + out: './<%= addOnOption.drizzle.schema %>/migrations', + driver: 'better-sqlite', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, + verbose: true, + strict: true, +}) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/_dot_env.local.append.ejs b/frameworks/react-cra/add-ons/drizzle/assets/_dot_env.local.append.ejs new file mode 100644 index 00000000..c2624b30 --- /dev/null +++ b/frameworks/react-cra/add-ons/drizzle/assets/_dot_env.local.append.ejs @@ -0,0 +1,2 @@ +# Drizzle ORM Database Configuration +<% if (addOnOption.drizzle.database === 'postgres') { %>DATABASE_URL=postgresql://username:password@localhost:5432/database_name<% } else if (addOnOption.drizzle.database === 'mysql') { %>DATABASE_URL=mysql://username:password@localhost:3306/database_name<% } else { %>DATABASE_URL=file:./local.db<% } %> \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/src/db/__mysql__index.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/src/db/__mysql__index.ts.ejs new file mode 100644 index 00000000..5a8924e1 --- /dev/null +++ b/frameworks/react-cra/add-ons/drizzle/assets/src/db/__mysql__index.ts.ejs @@ -0,0 +1,7 @@ +<% if (addOnOption.drizzle.database !== 'mysql') { ignoreFile() } %> +import { drizzle } from 'drizzle-orm/mysql2' +import mysql from 'mysql2/promise' +import * as schema from './schema' + +const connection = mysql.createConnection(process.env.DATABASE_URL!) +export const db = drizzle(connection, { schema }) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/src/db/__postgres__index.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/src/db/__postgres__index.ts.ejs new file mode 100644 index 00000000..1852f3fb --- /dev/null +++ b/frameworks/react-cra/add-ons/drizzle/assets/src/db/__postgres__index.ts.ejs @@ -0,0 +1,7 @@ +<% if (addOnOption.drizzle.database !== 'postgres') { ignoreFile() } %> +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import * as schema from './schema' + +const client = postgres(process.env.DATABASE_URL!) +export const db = drizzle(client, { schema }) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/src/db/__sqlite__index.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/src/db/__sqlite__index.ts.ejs new file mode 100644 index 00000000..538aa0c8 --- /dev/null +++ b/frameworks/react-cra/add-ons/drizzle/assets/src/db/__sqlite__index.ts.ejs @@ -0,0 +1,7 @@ +<% if (addOnOption.drizzle.database !== 'sqlite') { ignoreFile() } %> +import { drizzle } from 'drizzle-orm/better-sqlite3' +import Database from 'better-sqlite3' +import * as schema from './schema' + +const sqlite = new Database(process.env.DATABASE_URL!) +export const db = drizzle(sqlite, { schema }) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/src/db/schema.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/src/db/schema.ts.ejs new file mode 100644 index 00000000..04d190c4 --- /dev/null +++ b/frameworks/react-cra/add-ons/drizzle/assets/src/db/schema.ts.ejs @@ -0,0 +1,9 @@ +import { <% if (addOnOption.drizzle.database === 'postgres') { %>pgTable, serial, text, timestamp<% } else if (addOnOption.drizzle.database === 'mysql') { %>mysqlTable, serial, varchar, timestamp<% } else { %>sqliteTable, integer, text<% } %> } from 'drizzle-orm/<% if (addOnOption.drizzle.database === 'postgres') { %>pg-core<% } else if (addOnOption.drizzle.database === 'mysql') { %>mysql-core<% } else { %>sqlite-core<% } %>' + +export const users = <% if (addOnOption.drizzle.database === 'postgres') { %>pgTable<% } else if (addOnOption.drizzle.database === 'mysql') { %>mysqlTable<% } else { %>sqliteTable<% } %>('users', { + id: <% if (addOnOption.drizzle.database === 'sqlite') { %>integer('id').primaryKey()<% } else { %>serial('id').primaryKey()<% } %>, + <% if (addOnOption.drizzle.database === 'mysql') { %>name: varchar('name', { length: 256 }), + email: varchar('email', { length: 256 }),<% } else { %>name: text('name'), + email: text('email'),<% } %> + <% if (addOnOption.drizzle.database === 'sqlite') { %>createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),<% } else { %>createdAt: timestamp('created_at').defaultNow(),<% } %> +}) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/src/routes/demo.drizzle.tsx.ejs b/frameworks/react-cra/add-ons/drizzle/assets/src/routes/demo.drizzle.tsx.ejs new file mode 100644 index 00000000..1baa6a4a --- /dev/null +++ b/frameworks/react-cra/add-ons/drizzle/assets/src/routes/demo.drizzle.tsx.ejs @@ -0,0 +1,85 @@ +import { createFileRoute, useRouter } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { db } from '../db' +import { users } from '../db/schema' + +const getUsers = createServerFn({ + method: 'GET', +}).handler(async () => { + try { + const result = await db.select().from(users) + return result + } catch (error) { + console.error('Error loading users:', error) + return [] + } +}) + +const addUser = createServerFn({ method: 'POST' }) + .validator((data: { name: string; email: string }) => data) + .handler(async ({ data }) => { + try { + await db.insert(users).values({ + name: data.name, + email: data.email, + }) + } catch (error) { + console.error('Error adding user:', error) + throw error + } + }) + +export const Route = createFileRoute('/demo/drizzle')({ + component: DrizzleDemo, + loader: async () => await getUsers(), +}) + +function DrizzleDemo() { + const router = useRouter() + const userList = Route.useLoaderData() + + const handleAddUser = async () => { + const name = `User ${Date.now()}` + const email = `user${Date.now()}@example.com` + + try { + await addUser({ data: { name, email } }) + router.invalidate() + } catch (error) { + console.error('Error adding user:', error) + } + } + + return ( +
+

Drizzle ORM Demo

+

+ Database: <%= addOnOption.drizzle.database %> +

+ +
+ +
+ +
+

Users ({userList.length})

+ {userList.length === 0 ? ( +

No users found. Click "Add User" to create one.

+ ) : ( +
    + {userList.map((user) => ( +
  • + #{user.id} - {user.name} ({user.email}) +
  • + ))} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/info.json b/frameworks/react-cra/add-ons/drizzle/info.json new file mode 100644 index 00000000..09c095a4 --- /dev/null +++ b/frameworks/react-cra/add-ons/drizzle/info.json @@ -0,0 +1,40 @@ +{ + "name": "Drizzle ORM", + "description": "Add Drizzle ORM with configurable database support to your application.", + "phase": "add-on", + "modes": ["file-router"], + "type": "add-on", + "link": "https://orm.drizzle.team", + "dependsOn": ["start"], + "options": { + "database": { + "type": "select", + "label": "Database Provider", + "description": "Choose your database provider", + "default": "postgres", + "options": [ + { "value": "postgres", "label": "PostgreSQL" }, + { "value": "mysql", "label": "MySQL" }, + { "value": "sqlite", "label": "SQLite" } + ] + }, + "schema": { + "type": "select", + "label": "Schema Location", + "description": "Where to place your database schema", + "default": "src/db", + "options": [ + { "value": "src/db", "label": "src/db/" }, + { "value": "db", "label": "db/" } + ] + } + }, + "routes": [ + { + "url": "/demo/drizzle", + "name": "Drizzle Demo", + "path": "src/routes/demo.drizzle.tsx", + "jsName": "DrizzleDemo" + } + ] +} \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/package.json.ejs b/frameworks/react-cra/add-ons/drizzle/package.json.ejs new file mode 100644 index 00000000..4050a733 --- /dev/null +++ b/frameworks/react-cra/add-ons/drizzle/package.json.ejs @@ -0,0 +1,19 @@ +{ + "dependencies": { + "drizzle-orm": "^0.29.0"<% if (addOnOption.drizzle.database === 'postgres') { %>, + "postgres": "^3.4.0"<% } %><% if (addOnOption.drizzle.database === 'mysql') { %>, + "mysql2": "^3.6.0"<% } %><% if (addOnOption.drizzle.database === 'sqlite') { %>, + "better-sqlite3": "^8.7.0"<% } %> + }, + "devDependencies": { + "drizzle-kit": "^0.20.0"<% if (addOnOption.drizzle.database === 'postgres') { %>, + "@types/postgres": "^3.0.0"<% } %><% if (addOnOption.drizzle.database === 'mysql') { %>, + "@types/mysql2": "^3.0.0"<% } %><% if (addOnOption.drizzle.database === 'sqlite') { %>, + "@types/better-sqlite3": "^7.6.0"<% } %> + }, + "scripts": { + "db:generate": "drizzle-kit generate:<% if (addOnOption.drizzle.database === 'postgres') { %>pg<% } else if (addOnOption.drizzle.database === 'mysql') { %>mysql<% } else { %>sqlite<% } %>", + "db:push": "drizzle-kit push:<% if (addOnOption.drizzle.database === 'postgres') { %>pg<% } else if (addOnOption.drizzle.database === 'mysql') { %>mysql<% } else { %>sqlite<% } %>", + "db:studio": "drizzle-kit studio" + } +} \ No newline at end of file diff --git a/packages/cta-cli/src/command-line.ts b/packages/cta-cli/src/command-line.ts index 6bafe31b..d0e787bf 100644 --- a/packages/cta-cli/src/command-line.ts +++ b/packages/cta-cli/src/command-line.ts @@ -6,6 +6,7 @@ import { getFrameworkById, getPackageManager, loadStarter, + populateAddOnOptionsDefaults, } from '@tanstack/cta-engine' import type { Options } from '@tanstack/cta-engine' @@ -115,6 +116,7 @@ export async function normalizeOptions( DEFAULT_PACKAGE_MANAGER, git: !!cliOptions.git, chosenAddOns, + addOnOptions: populateAddOnOptionsDefaults(chosenAddOns), starter: starter, } } diff --git a/packages/cta-cli/src/mcp.ts b/packages/cta-cli/src/mcp.ts index 984138a5..6d865edc 100644 --- a/packages/cta-cli/src/mcp.ts +++ b/packages/cta-cli/src/mcp.ts @@ -13,6 +13,7 @@ import { finalizeAddOns, getFrameworkByName, getFrameworks, + populateAddOnOptionsDefaults, } from '@tanstack/cta-engine' function createServer({ @@ -114,6 +115,7 @@ function createServer({ packageManager: 'pnpm', mode: 'file-router', chosenAddOns, + addOnOptions: populateAddOnOptionsDefaults(chosenAddOns), git: true, }) } catch (error) { diff --git a/packages/cta-cli/src/options.ts b/packages/cta-cli/src/options.ts index 758d7288..80a844f9 100644 --- a/packages/cta-cli/src/options.ts +++ b/packages/cta-cli/src/options.ts @@ -4,6 +4,7 @@ import { finalizeAddOns, getFrameworkById, getPackageManager, + populateAddOnOptionsDefaults, readConfigFile, } from '@tanstack/cta-engine' @@ -130,6 +131,7 @@ export async function promptForCreateOptions( options.typescript = true } + options.addOnOptions = populateAddOnOptionsDefaults(options.chosenAddOns) options.git = cliOptions.git || (await selectGit()) return options diff --git a/packages/cta-engine/src/add-ons.ts b/packages/cta-engine/src/add-ons.ts index 333a8b32..13c97422 100644 --- a/packages/cta-engine/src/add-ons.ts +++ b/packages/cta-engine/src/add-ons.ts @@ -47,3 +47,21 @@ export async function finalizeAddOns( function loadAddOn(addOn: AddOn): AddOn { return addOn } + +export function populateAddOnOptionsDefaults( + chosenAddOns: Array +): Record> { + const addOnOptions: Record> = {} + + for (const addOn of chosenAddOns) { + if (addOn.options) { + const defaults: Record = {} + for (const [optionKey, optionDef] of Object.entries(addOn.options)) { + defaults[optionKey] = optionDef.default + } + addOnOptions[addOn.id] = defaults + } + } + + return addOnOptions +} diff --git a/packages/cta-engine/src/custom-add-ons/shared.ts b/packages/cta-engine/src/custom-add-ons/shared.ts index 6282b22c..c6b4861f 100644 --- a/packages/cta-engine/src/custom-add-ons/shared.ts +++ b/packages/cta-engine/src/custom-add-ons/shared.ts @@ -2,7 +2,7 @@ import { readdir } from 'node:fs/promises' import { resolve } from 'node:path' import { createApp } from '../create-app.js' import { createMemoryEnvironment } from '../environment.js' -import { finalizeAddOns } from '../add-ons.js' +import { finalizeAddOns, populateAddOnOptionsDefaults } from '../add-ons.js' import { getFrameworkById } from '../frameworks.js' import { readConfigFileFromEnvironment } from '../config-file.js' import { readFileHelper } from '../file-helpers.js' @@ -66,6 +66,9 @@ export async function createAppOptionsFromPersisted( const { version, ...rest } = json /* eslint-enable unused-imports/no-unused-vars */ const framework = getFrameworkById(rest.framework) + const chosenAddOns = await finalizeAddOns(framework!, json.mode!, [ + ...json.chosenAddOns, + ]) return { ...rest, mode: json.mode!, @@ -77,9 +80,8 @@ export async function createAppOptionsFromPersisted( targetDir: '', framework: framework!, starter: json.starter ? await loadStarter(json.starter) : undefined, - chosenAddOns: await finalizeAddOns(framework!, json.mode!, [ - ...json.chosenAddOns, - ]), + chosenAddOns, + addOnOptions: populateAddOnOptionsDefaults(chosenAddOns), } } @@ -100,6 +102,7 @@ export function createSerializedOptionsFromPersisted( targetDir: '', framework: json.framework, starter: json.starter, + addOnOptions: {}, } } diff --git a/packages/cta-engine/src/frameworks.ts b/packages/cta-engine/src/frameworks.ts index ae162797..59f09a5e 100644 --- a/packages/cta-engine/src/frameworks.ts +++ b/packages/cta-engine/src/frameworks.ts @@ -56,11 +56,15 @@ export function scanAddOnDirectories(addOnsDirectories: Array) { const fileContent = readFileSync(filePath, 'utf-8') const info = JSON.parse(fileContent) - let packageAdditions: Record = {} + let packageAdditions: Record = {} + let packageTemplate: string | undefined = undefined + if (existsSync(resolve(addOnsBase, dir, 'package.json'))) { packageAdditions = JSON.parse( readFileSync(resolve(addOnsBase, dir, 'package.json'), 'utf-8'), ) + } else if (existsSync(resolve(addOnsBase, dir, 'package.json.ejs'))) { + packageTemplate = readFileSync(resolve(addOnsBase, dir, 'package.json.ejs'), 'utf-8') } let readme: string | undefined @@ -97,6 +101,7 @@ export function scanAddOnDirectories(addOnsDirectories: Array) { ...info, id: dir, packageAdditions, + packageTemplate, readme, files, smallLogo, diff --git a/packages/cta-engine/src/index.ts b/packages/cta-engine/src/index.ts index 253ac72e..1ebd1935 100644 --- a/packages/cta-engine/src/index.ts +++ b/packages/cta-engine/src/index.ts @@ -1,7 +1,7 @@ export { createApp } from './create-app.js' export { addToApp } from './add-to-app.js' -export { finalizeAddOns, getAllAddOns } from './add-ons.js' +export { finalizeAddOns, getAllAddOns, populateAddOnOptionsDefaults } from './add-ons.js' export { loadRemoteAddOn } from './custom-add-ons/add-on.js' export { loadStarter } from './custom-add-ons/starter.js' diff --git a/packages/cta-engine/src/package-json.ts b/packages/cta-engine/src/package-json.ts index d93ed80b..69cc91ff 100644 --- a/packages/cta-engine/src/package-json.ts +++ b/packages/cta-engine/src/package-json.ts @@ -1,3 +1,4 @@ +import { render } from 'ejs' import { sortObject } from './utils.js' import type { Options } from './types.js' @@ -42,10 +43,41 @@ export function createPackageJSON(options: Options) { packageJSON = mergePackageJSON(packageJSON, addition) } - for (const addOn of options.chosenAddOns.map( - (addOn) => addOn.packageAdditions, - )) { - packageJSON = mergePackageJSON(packageJSON, addOn) + for (const addOn of options.chosenAddOns) { + let addOnPackageJSON = addOn.packageAdditions + + // Process EJS template if present + if (addOn.packageTemplate) { + const templateValues = { + packageManager: options.packageManager, + projectName: options.projectName, + typescript: options.typescript, + tailwind: options.tailwind, + js: options.typescript ? 'ts' : 'js', + jsx: options.typescript ? 'tsx' : 'jsx', + fileRouter: options.mode === 'file-router', + codeRouter: options.mode === 'code-router', + addOnEnabled: options.chosenAddOns.reduce>( + (acc, addon) => { + acc[addon.id] = true + return acc + }, + {}, + ), + addOnOption: options.addOnOptions, + addOns: options.chosenAddOns, + } + + try { + const renderedTemplate = render(addOn.packageTemplate, templateValues) + addOnPackageJSON = JSON.parse(renderedTemplate) + } catch (error) { + console.error(`Error processing package.json.ejs for add-on ${addOn.id}:`, error) + // Fall back to packageAdditions if template processing fails + } + } + + packageJSON = mergePackageJSON(packageJSON, addOnPackageJSON) } if (options.starter) { diff --git a/packages/cta-engine/src/template-file.ts b/packages/cta-engine/src/template-file.ts index 80bfd32c..d1a15467 100644 --- a/packages/cta-engine/src/template-file.ts +++ b/packages/cta-engine/src/template-file.ts @@ -85,6 +85,7 @@ export function createTemplateFile(environment: Environment, options: Options) { fileRouter: options.mode === 'file-router', codeRouter: options.mode === 'code-router', addOnEnabled, + addOnOption: options.addOnOptions, addOns: options.chosenAddOns, integrations, routes, @@ -121,6 +122,13 @@ export function createTemplateFile(environment: Environment, options: Options) { let target = convertDotFilesAndPaths(file.replace('.ejs', '')) + // Strip option prefixes from filename (e.g., __postgres__drizzle.config.ts -> drizzle.config.ts) + const prefixMatch = target.match(/^(.+\/)?__([^_]+)__(.+)$/) + if (prefixMatch) { + const [, directory, , filename] = prefixMatch + target = (directory || '') + filename + } + let append = false if (target.endsWith('.append')) { append = true diff --git a/packages/cta-engine/src/types.ts b/packages/cta-engine/src/types.ts index 5f4173f4..56dcb4bd 100644 --- a/packages/cta-engine/src/types.ts +++ b/packages/cta-engine/src/types.ts @@ -9,6 +9,25 @@ export type StatusStepType = | 'package-manager' | 'other' +export const AddOnSelectOptionSchema = z.object({ + type: z.literal('select'), + label: z.string(), + description: z.string().optional(), + default: z.string(), + options: z.array( + z.object({ + value: z.string(), + label: z.string(), + }), + ), +}) + +export const AddOnOptionSchema = z.discriminatedUnion('type', [ + AddOnSelectOptionSchema, +]) + +export const AddOnOptionsSchema = z.record(z.string(), AddOnOptionSchema) + export const AddOnBaseSchema = z.object({ id: z.string(), name: z.string(), @@ -48,6 +67,7 @@ export const AddOnBaseSchema = z.object({ logo: z.string().optional(), addOnSpecialSteps: z.array(z.string()).optional(), createSpecialSteps: z.array(z.string()).optional(), + options: AddOnOptionsSchema.optional(), }) export const StarterSchema = AddOnBaseSchema.extend({ @@ -79,8 +99,15 @@ export const AddOnInfoSchema = AddOnBaseSchema.extend({ export const AddOnCompiledSchema = AddOnInfoSchema.extend({ files: z.record(z.string(), z.string()), deletedFiles: z.array(z.string()), + packageTemplate: z.string().optional(), }) +export type AddOnSelectOption = z.infer + +export type AddOnOption = z.infer + +export type AddOnOptions = z.infer + export type Integration = z.infer export type AddOnBase = z.infer @@ -93,13 +120,19 @@ export type AddOnInfo = z.infer export type AddOnCompiled = z.infer +export interface AddOnSelection { + id: string + enabled: boolean + options: Record +} + export type FileBundleHandler = { getFiles: () => Promise> getFileContents: (path: string) => Promise getDeletedFiles: () => Promise> } -export type AddOn = AddOnInfo & FileBundleHandler +export type AddOn = AddOnCompiled & FileBundleHandler export type Starter = StarterCompiled & FileBundleHandler @@ -143,6 +176,7 @@ export interface Options { git: boolean chosenAddOns: Array + addOnOptions: Record> starter?: Starter | undefined } diff --git a/packages/cta-ui-base/src/store/project.ts b/packages/cta-ui-base/src/store/project.ts index 4185b3a3..6e285bc9 100644 --- a/packages/cta-ui-base/src/store/project.ts +++ b/packages/cta-ui-base/src/store/project.ts @@ -23,6 +23,7 @@ export const useProjectOptions = create< tailwind: true, git: true, chosenAddOns: [], + addOnOptions: {}, packageManager: 'pnpm', })) From 3c1e3e5fc77e3ef1a256b1314cce1b483d4da332 Mon Sep 17 00:00:00 2001 From: timoconnellaus Date: Thu, 3 Jul 2025 14:21:42 +1000 Subject: [PATCH 2/9] no optional path to drizzle db location --- .../drizzle/assets/__mysql__drizzle.config.ts.ejs | 4 ++-- .../drizzle/assets/__postgres__drizzle.config.ts.ejs | 4 ++-- .../drizzle/assets/__sqlite__drizzle.config.ts.ejs | 4 ++-- frameworks/react-cra/add-ons/drizzle/info.json | 10 ---------- 4 files changed, 6 insertions(+), 16 deletions(-) diff --git a/frameworks/react-cra/add-ons/drizzle/assets/__mysql__drizzle.config.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/__mysql__drizzle.config.ts.ejs index 3d1f5e1e..e6a2c40c 100644 --- a/frameworks/react-cra/add-ons/drizzle/assets/__mysql__drizzle.config.ts.ejs +++ b/frameworks/react-cra/add-ons/drizzle/assets/__mysql__drizzle.config.ts.ejs @@ -2,8 +2,8 @@ import { defineConfig } from 'drizzle-kit' export default defineConfig({ - schema: './<%= addOnOption.drizzle.schema %>/schema.<%= js %>', - out: './<%= addOnOption.drizzle.schema %>/migrations', + schema: './src/db/schema.<%= js %>', + out: './src/db/migrations', driver: 'mysql2', dbCredentials: { connectionString: process.env.DATABASE_URL!, diff --git a/frameworks/react-cra/add-ons/drizzle/assets/__postgres__drizzle.config.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/__postgres__drizzle.config.ts.ejs index 5acc95ad..e09eefa6 100644 --- a/frameworks/react-cra/add-ons/drizzle/assets/__postgres__drizzle.config.ts.ejs +++ b/frameworks/react-cra/add-ons/drizzle/assets/__postgres__drizzle.config.ts.ejs @@ -2,8 +2,8 @@ import { defineConfig } from 'drizzle-kit' export default defineConfig({ - schema: './<%= addOnOption.drizzle.schema %>/schema.<%= js %>', - out: './<%= addOnOption.drizzle.schema %>/migrations', + schema: './src/db/schema.<%= js %>', + out: './src/db/migrations', driver: 'pg', dbCredentials: { connectionString: process.env.DATABASE_URL!, diff --git a/frameworks/react-cra/add-ons/drizzle/assets/__sqlite__drizzle.config.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/__sqlite__drizzle.config.ts.ejs index e1ae5850..09c60977 100644 --- a/frameworks/react-cra/add-ons/drizzle/assets/__sqlite__drizzle.config.ts.ejs +++ b/frameworks/react-cra/add-ons/drizzle/assets/__sqlite__drizzle.config.ts.ejs @@ -2,8 +2,8 @@ import { defineConfig } from 'drizzle-kit' export default defineConfig({ - schema: './<%= addOnOption.drizzle.schema %>/schema.<%= js %>', - out: './<%= addOnOption.drizzle.schema %>/migrations', + schema: './src/db/schema.<%= js %>', + out: './src/db/migrations', driver: 'better-sqlite', dbCredentials: { url: process.env.DATABASE_URL!, diff --git a/frameworks/react-cra/add-ons/drizzle/info.json b/frameworks/react-cra/add-ons/drizzle/info.json index 09c095a4..7d3468c2 100644 --- a/frameworks/react-cra/add-ons/drizzle/info.json +++ b/frameworks/react-cra/add-ons/drizzle/info.json @@ -17,16 +17,6 @@ { "value": "mysql", "label": "MySQL" }, { "value": "sqlite", "label": "SQLite" } ] - }, - "schema": { - "type": "select", - "label": "Schema Location", - "description": "Where to place your database schema", - "default": "src/db", - "options": [ - { "value": "src/db", "label": "src/db/" }, - { "value": "db", "label": "db/" } - ] } }, "routes": [ From f2d644c0bd62621ce66408e5c7a0b8e20f61a4a3 Mon Sep 17 00:00:00 2001 From: timoconnellaus Date: Thu, 3 Jul 2025 15:32:24 +1000 Subject: [PATCH 3/9] feat: improve add-on options UI with settings dialog - Move add-on configuration from inline display to dedicated dialog - Add settings button (gear icon) next to info icon for configurable add-ons - Create new AddOnConfigDialog component with proper modal interface - Fix reactive state subscription for add-on options in sidebar - Clean up sidebar layout by removing inline configuration clutter - Add proper default option initialization when add-ons are toggled - Export add-on option types from engine for better type safety Improves UX by providing dedicated space for configuration with clear context while keeping the sidebar clean and compact. --- .../react-cra/add-ons/drizzle/small-logo.svg | 7 + packages/cta-engine/src/index.ts | 4 + .../src/components/add-on-config-dialog.tsx | 51 +++++++ .../src/components/add-on-option-select.tsx | 63 ++++++++ .../src/components/add-on-options-panel.tsx | 42 ++++++ .../src/components/sidebar-items/add-ons.tsx | 138 +++++++++++------- packages/cta-ui-base/src/store/project.ts | 47 +++++- packages/cta-ui-base/src/types.d.ts | 3 +- .../lib/engine-handling/create-app-wrapper.ts | 4 + .../generate-initial-payload.ts | 1 + packages/cta-ui/lib/types.d.ts | 1 + packages/cta-ui/src/types.d.ts | 1 + 12 files changed, 308 insertions(+), 54 deletions(-) create mode 100644 frameworks/react-cra/add-ons/drizzle/small-logo.svg create mode 100644 packages/cta-ui-base/src/components/add-on-config-dialog.tsx create mode 100644 packages/cta-ui-base/src/components/add-on-option-select.tsx create mode 100644 packages/cta-ui-base/src/components/add-on-options-panel.tsx diff --git a/frameworks/react-cra/add-ons/drizzle/small-logo.svg b/frameworks/react-cra/add-ons/drizzle/small-logo.svg new file mode 100644 index 00000000..15c5c0ba --- /dev/null +++ b/frameworks/react-cra/add-ons/drizzle/small-logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/cta-engine/src/index.ts b/packages/cta-engine/src/index.ts index 1ebd1935..395473d6 100644 --- a/packages/cta-engine/src/index.ts +++ b/packages/cta-engine/src/index.ts @@ -73,6 +73,10 @@ export { export type { AddOn, + AddOnOption, + AddOnOptions, + AddOnSelectOption, + AddOnSelection, Environment, FileBundleHandler, Framework, diff --git a/packages/cta-ui-base/src/components/add-on-config-dialog.tsx b/packages/cta-ui-base/src/components/add-on-config-dialog.tsx new file mode 100644 index 00000000..620f8df7 --- /dev/null +++ b/packages/cta-ui-base/src/components/add-on-config-dialog.tsx @@ -0,0 +1,51 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from './ui/dialog' + +import AddOnOptionsPanel from './add-on-options-panel' + +import type { AddOnInfo } from '../types' + +interface AddOnConfigDialogProps { + addOn: AddOnInfo | undefined + selectedOptions: Record + onOptionChange: (optionName: string, value: any) => void + onClose: () => void + disabled?: boolean +} + +export default function AddOnConfigDialog({ + addOn, + selectedOptions, + onOptionChange, + onClose, + disabled = false, +}: AddOnConfigDialogProps) { + if (!addOn) return null + + return ( + !open && onClose()}> + + + Configure {addOn.name} + + Customize the configuration options for this add-on. + + + +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/packages/cta-ui-base/src/components/add-on-option-select.tsx b/packages/cta-ui-base/src/components/add-on-option-select.tsx new file mode 100644 index 00000000..e7a61770 --- /dev/null +++ b/packages/cta-ui-base/src/components/add-on-option-select.tsx @@ -0,0 +1,63 @@ +import { ChevronDownIcon } from 'lucide-react' + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from './ui/dropdown-menu' +import { Button } from './ui/button' +import { Label } from './ui/label' + +import type { AddOnSelectOption } from '@tanstack/cta-engine' + +interface AddOnOptionSelectProps { + option: AddOnSelectOption + value: string + onChange: (value: string) => void + disabled?: boolean +} + +export default function AddOnOptionSelect({ + option, + value, + onChange, + disabled = false, +}: AddOnOptionSelectProps) { + const selectedOption = option.options.find((opt: { value: string; label: string }) => opt.value === value) + + return ( +
+ + {option.description && ( +

{option.description}

+ )} + + + + + + + {option.options.map((opt: { value: string; label: string }) => ( + onChange(opt.value)} + className={value === opt.value ? 'bg-accent' : ''} + > + {opt.label} + + ))} + + +
+ ) +} \ No newline at end of file diff --git a/packages/cta-ui-base/src/components/add-on-options-panel.tsx b/packages/cta-ui-base/src/components/add-on-options-panel.tsx new file mode 100644 index 00000000..f04665c4 --- /dev/null +++ b/packages/cta-ui-base/src/components/add-on-options-panel.tsx @@ -0,0 +1,42 @@ +import AddOnOptionSelect from './add-on-option-select' + +import type { AddOnInfo } from '../types' + +interface AddOnOptionsPanelProps { + addOn: AddOnInfo + selectedOptions: Record + onOptionChange: (optionName: string, value: any) => void + disabled?: boolean +} + +export default function AddOnOptionsPanel({ + addOn, + selectedOptions, + onOptionChange, + disabled = false, +}: AddOnOptionsPanelProps) { + if (!addOn.options || Object.keys(addOn.options).length === 0) { + return null + } + + return ( +
+ {Object.entries(addOn.options).map(([optionName, option]) => { + if (option && typeof option === 'object' && 'type' in option && option.type === 'select') { + return ( + onOptionChange(optionName, value)} + disabled={disabled} + /> + ) + } + + // Future option types can be added here + return null + })} +
+ ) +} \ No newline at end of file diff --git a/packages/cta-ui-base/src/components/sidebar-items/add-ons.tsx b/packages/cta-ui-base/src/components/sidebar-items/add-ons.tsx index b7e2d1d8..5e736266 100644 --- a/packages/cta-ui-base/src/components/sidebar-items/add-ons.tsx +++ b/packages/cta-ui-base/src/components/sidebar-items/add-ons.tsx @@ -1,13 +1,15 @@ import { Fragment, useMemo, useState } from 'react' -import { InfoIcon } from 'lucide-react' +import { InfoIcon, SettingsIcon } from 'lucide-react' import { Switch } from '../ui/switch' import { Label } from '../ui/label' +import { Button } from '../ui/button' -import { useAddOns } from '../../store/project' +import { useAddOns, useProjectOptions } from '../../store/project' import ImportCustomAddOn from '../custom-add-on-dialog' import AddOnInfoDialog from '../add-on-info-dialog' +import AddOnConfigDialog from '../add-on-config-dialog' import type { AddOnInfo } from '../../types' @@ -18,7 +20,8 @@ const addOnTypeLabels: Record = { } export default function SelectedAddOns() { - const { availableAddOns, addOnState, toggleAddOn } = useAddOns() + const { availableAddOns, addOnState, toggleAddOn, setAddOnOption } = useAddOns() + const addOnOptions = useProjectOptions((state) => state.addOnOptions) const sortedAddOns = useMemo(() => { return availableAddOns.sort((a, b) => { @@ -27,6 +30,7 @@ export default function SelectedAddOns() { }, [availableAddOns]) const [infoAddOn, setInfoAddOn] = useState() + const [configAddOn, setConfigAddOn] = useState() return ( <> @@ -34,58 +38,88 @@ export default function SelectedAddOns() { addOn={infoAddOn} onClose={() => setInfoAddOn(undefined)} /> - {Object.keys(addOnTypeLabels).map((type) => ( - - {sortedAddOns.filter((addOn) => addOn.type === type).length > 0 && ( -
-

{addOnTypeLabels[type]}

-
- {sortedAddOns - .filter((addOn) => addOn.type === type) - .map((addOn) => ( -
-
- { - toggleAddOn(addOn.id) - }} - /> -
- )} - - ))} + )} + + ))} +
diff --git a/packages/cta-ui-base/src/store/project.ts b/packages/cta-ui-base/src/store/project.ts index 6e285bc9..9ad1addc 100644 --- a/packages/cta-ui-base/src/store/project.ts +++ b/packages/cta-ui-base/src/store/project.ts @@ -148,18 +148,63 @@ export function useAddOns() { (addOn) => addOn !== addOnId, ), })) + // Clear options when add-on is disabled + useProjectOptions.setState((state) => { + const newAddOnOptions = { ...state.addOnOptions } + delete newAddOnOptions[addOnId] + return { addOnOptions: newAddOnOptions } + }) } else { useMutableAddOns.setState((state) => ({ userSelectedAddOns: [...state.userSelectedAddOns, addOnId], })) + // Initialize options with defaults when add-on is enabled + const addOn = availableAddOns.find((a) => a.id === addOnId) + if (addOn?.options) { + const defaultOptions: Record = {} + Object.entries(addOn.options).forEach(([optionName, option]) => { + defaultOptions[optionName] = (option as any).default + }) + useProjectOptions.setState((state) => ({ + addOnOptions: { + ...state.addOnOptions, + [addOnId]: defaultOptions, + }, + })) + } } } }, - [ready, addOnState], + [ready, addOnState, availableAddOns], + ) + + const setAddOnOption = useCallback( + (addOnId: string, optionName: string, value: any) => { + if (!ready) return + useProjectOptions.setState((state) => ({ + addOnOptions: { + ...state.addOnOptions, + [addOnId]: { + ...state.addOnOptions[addOnId], + [optionName]: value, + }, + }, + })) + }, + [ready], + ) + + const getAddOnOptions = useCallback( + (addOnId: string) => { + return useProjectOptions.getState().addOnOptions[addOnId] || {} + }, + [], ) return { toggleAddOn, + setAddOnOption, + getAddOnOptions, chosenAddOns, availableAddOns, userSelectedAddOns, diff --git a/packages/cta-ui-base/src/types.d.ts b/packages/cta-ui-base/src/types.d.ts index a8dafc78..177c04c4 100644 --- a/packages/cta-ui-base/src/types.d.ts +++ b/packages/cta-ui-base/src/types.d.ts @@ -1,4 +1,4 @@ -import type { StatusStepType } from '@tanstack/cta-engine' +import type { StatusStepType, AddOnOption, AddOnOptions } from '@tanstack/cta-engine' export type ApplicationMode = 'add' | 'setup' | 'none' @@ -38,6 +38,7 @@ export type AddOnInfo = { logo?: string link: string dependsOn?: Array + options?: AddOnOptions } export type FileClass = diff --git a/packages/cta-ui/lib/engine-handling/create-app-wrapper.ts b/packages/cta-ui/lib/engine-handling/create-app-wrapper.ts index 6f2830ef..6ad5b6e4 100644 --- a/packages/cta-ui/lib/engine-handling/create-app-wrapper.ts +++ b/packages/cta-ui/lib/engine-handling/create-app-wrapper.ts @@ -7,6 +7,7 @@ import { finalizeAddOns, getFrameworkById, loadStarter, + populateAddOnOptionsDefaults, } from '@tanstack/cta-engine' import { TMP_TARGET_DIR } from '../constants.js' @@ -64,6 +65,9 @@ export async function createAppWrapper( starter, framework, chosenAddOns, + addOnOptions: (!projectOptions.addOnOptions || Object.keys(projectOptions.addOnOptions).length === 0) + ? populateAddOnOptionsDefaults(chosenAddOns) + : projectOptions.addOnOptions, } function createEnvironment() { diff --git a/packages/cta-ui/lib/engine-handling/generate-initial-payload.ts b/packages/cta-ui/lib/engine-handling/generate-initial-payload.ts index 88a253b9..a3c5542a 100644 --- a/packages/cta-ui/lib/engine-handling/generate-initial-payload.ts +++ b/packages/cta-ui/lib/engine-handling/generate-initial-payload.ts @@ -35,6 +35,7 @@ function convertAddOnToAddOnInfo(addOn: AddOn): AddOnInfo { logo: addOn.logo, link: addOn.link!, dependsOn: addOn.dependsOn, + options: addOn.options, } } diff --git a/packages/cta-ui/lib/types.d.ts b/packages/cta-ui/lib/types.d.ts index 2b996a18..3d1acd94 100644 --- a/packages/cta-ui/lib/types.d.ts +++ b/packages/cta-ui/lib/types.d.ts @@ -17,4 +17,5 @@ export type AddOnInfo = { logo?: string link: string dependsOn?: Array + options?: Record } diff --git a/packages/cta-ui/src/types.d.ts b/packages/cta-ui/src/types.d.ts index a8dafc78..52576298 100644 --- a/packages/cta-ui/src/types.d.ts +++ b/packages/cta-ui/src/types.d.ts @@ -38,6 +38,7 @@ export type AddOnInfo = { logo?: string link: string dependsOn?: Array + options?: Record } export type FileClass = From d9f5aae1e280e6f63b749e63c381966475a8d94f Mon Sep 17 00:00:00 2001 From: timoconnellaus Date: Thu, 3 Jul 2025 15:47:15 +1000 Subject: [PATCH 4/9] cli in interactive mode asks for option values --- packages/cta-cli/src/options.ts | 13 +++++++++- packages/cta-cli/src/ui-prompts.ts | 41 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/cta-cli/src/options.ts b/packages/cta-cli/src/options.ts index 80a844f9..790a88c2 100644 --- a/packages/cta-cli/src/options.ts +++ b/packages/cta-cli/src/options.ts @@ -10,6 +10,7 @@ import { import { getProjectName, + promptForAddOnOptions, selectAddOns, selectGit, selectPackageManager, @@ -131,7 +132,17 @@ export async function promptForCreateOptions( options.typescript = true } - options.addOnOptions = populateAddOnOptionsDefaults(options.chosenAddOns) + // Prompt for add-on options in interactive mode + if (Array.isArray(cliOptions.addOns)) { + // Non-interactive mode: use defaults + options.addOnOptions = populateAddOnOptionsDefaults(options.chosenAddOns) + } else { + // Interactive mode: prompt for options + const userOptions = await promptForAddOnOptions(options.chosenAddOns.map(a => a.id), options.framework) + const defaultOptions = populateAddOnOptionsDefaults(options.chosenAddOns) + // Merge user options with defaults + options.addOnOptions = { ...defaultOptions, ...userOptions } + } options.git = cliOptions.git || (await selectGit()) return options diff --git a/packages/cta-cli/src/ui-prompts.ts b/packages/cta-cli/src/ui-prompts.ts index 0f065432..0ec01eee 100644 --- a/packages/cta-cli/src/ui-prompts.ts +++ b/packages/cta-cli/src/ui-prompts.ts @@ -183,3 +183,44 @@ export async function selectToolchain( return tc } + +export async function promptForAddOnOptions( + addOnIds: Array, + framework: Framework, +): Promise>> { + const addOnOptions: Record> = {} + + for (const addOnId of addOnIds) { + const addOn = framework.getAddOns().find(a => a.id === addOnId) + if (!addOn || !addOn.options) continue + + addOnOptions[addOnId] = {} + + for (const [optionName, option] of Object.entries(addOn.options)) { + if (option && typeof option === 'object' && 'type' in option) { + if (option.type === 'select') { + const selectOption = option as { type: 'select'; label: string; description?: string; default: string; options: Array<{ value: string; label: string }> } + + const value = await select({ + message: `${addOn.name}: ${selectOption.label}`, + options: selectOption.options.map(opt => ({ + value: opt.value, + label: opt.label, + })), + initialValue: selectOption.default, + }) + + if (isCancel(value)) { + cancel('Operation cancelled.') + process.exit(0) + } + + addOnOptions[addOnId][optionName] = value + } + // Future option types can be added here + } + } + } + + return addOnOptions +} From 20e1931ea91b4a04953f192bf9529eab5e945d17 Mon Sep 17 00:00:00 2001 From: timoconnellaus Date: Thu, 3 Jul 2025 15:59:09 +1000 Subject: [PATCH 5/9] feat: add add-on discovery and configuration CLI options - Enhanced --list-add-ons to show * for configurable add-ons - Added --addon-details command to show comprehensive add-on information - Added --add-on-config option for non-interactive configuration - Supports discovering options, dependencies, routes, and all add-on metadata --- packages/cta-cli/src/cli.ts | 69 +++++++++++++++++++++++++++- packages/cta-cli/src/command-line.ts | 16 ++++++- packages/cta-cli/src/types.ts | 2 + 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/packages/cta-cli/src/cli.ts b/packages/cta-cli/src/cli.ts index 23d72b5c..c3b82fc3 100644 --- a/packages/cta-cli/src/cli.ts +++ b/packages/cta-cli/src/cli.ts @@ -327,6 +327,7 @@ Remove your node_modules directory and package lock file and re-install.`, }, ) .option('--list-add-ons', 'list all available add-ons', false) + .option('--addon-details ', 'show detailed information about a specific add-on') .option('--no-git', 'do not create a git repository') .option( '--target-dir ', @@ -335,6 +336,7 @@ Remove your node_modules directory and package lock file and re-install.`, .option('--mcp', 'run the MCP server', false) .option('--mcp-sse', 'run the MCP server in SSE mode', false) .option('--ui', 'Add with the UI') + .option('--add-on-config ', 'JSON string with add-on configuration options') program.action(async (projectName: string, options: CliOptions) => { if (options.listAddOns) { @@ -343,8 +345,73 @@ Remove your node_modules directory and package lock file and re-install.`, defaultMode || convertTemplateToMode(options.template || defaultTemplate), ) + let hasConfigurableAddOns = false for (const addOn of addOns.filter((a) => !forcedAddOns.includes(a.id))) { - console.log(`${chalk.bold(addOn.id)}: ${addOn.description}`) + const hasOptions = addOn.options && Object.keys(addOn.options).length > 0 + const optionMarker = hasOptions ? '*' : ' ' + if (hasOptions) hasConfigurableAddOns = true + console.log(`${optionMarker} ${chalk.bold(addOn.id)}: ${addOn.description}`) + } + if (hasConfigurableAddOns) { + console.log('\n* = has configuration options') + } + } else if (options.addonDetails) { + const addOns = await getAllAddOns( + getFrameworkByName(options.framework || defaultFramework || 'React')!, + defaultMode || + convertTemplateToMode(options.template || defaultTemplate), + ) + const addOn = addOns.find((a) => a.id === options.addonDetails) + if (!addOn) { + console.error(`Add-on '${options.addonDetails}' not found`) + process.exit(1) + } + + console.log(`${chalk.bold.cyan('Add-on Details:')} ${chalk.bold(addOn.name)}`) + console.log(`${chalk.bold('ID:')} ${addOn.id}`) + console.log(`${chalk.bold('Description:')} ${addOn.description}`) + console.log(`${chalk.bold('Type:')} ${addOn.type}`) + console.log(`${chalk.bold('Phase:')} ${addOn.phase}`) + console.log(`${chalk.bold('Supported Modes:')} ${addOn.modes.join(', ')}`) + + if (addOn.link) { + console.log(`${chalk.bold('Link:')} ${chalk.blue(addOn.link)}`) + } + + if (addOn.dependsOn && addOn.dependsOn.length > 0) { + console.log(`${chalk.bold('Dependencies:')} ${addOn.dependsOn.join(', ')}`) + } + + if (addOn.options && Object.keys(addOn.options).length > 0) { + console.log(`\n${chalk.bold.yellow('Configuration Options:')}`) + for (const [optionName, option] of Object.entries(addOn.options)) { + if (option && typeof option === 'object' && 'type' in option) { + const opt = option as any + console.log(` ${chalk.bold(optionName)}:`) + console.log(` Label: ${opt.label}`) + if (opt.description) { + console.log(` Description: ${opt.description}`) + } + console.log(` Type: ${opt.type}`) + console.log(` Default: ${opt.default}`) + if (opt.type === 'select' && opt.options) { + console.log(` Available values:`) + for (const choice of opt.options) { + console.log(` - ${choice.value}: ${choice.label}`) + } + } + } + } + } else { + console.log(`\n${chalk.gray('No configuration options available')}`) + } + + if (addOn.routes && addOn.routes.length > 0) { + console.log(`\n${chalk.bold.green('Routes:')}`) + for (const route of addOn.routes) { + console.log(` ${chalk.bold(route.url)} (${route.name})`) + console.log(` File: ${route.path}`) + } } } else if (options.mcp || options.mcpSse) { await runMCPServer(!!options.mcpSse, { diff --git a/packages/cta-cli/src/command-line.ts b/packages/cta-cli/src/command-line.ts index d0e787bf..c4143419 100644 --- a/packages/cta-cli/src/command-line.ts +++ b/packages/cta-cli/src/command-line.ts @@ -103,6 +103,17 @@ export async function normalizeOptions( typescript = true } + // Handle add-on configuration option + let addOnOptionsFromCLI = {} + if (cliOptions.addOnConfig) { + try { + addOnOptionsFromCLI = JSON.parse(cliOptions.addOnConfig) + } catch (error) { + console.error('Error parsing add-on config:', error) + process.exit(1) + } + } + return { projectName: projectName, targetDir: resolve(process.cwd(), projectName), @@ -116,7 +127,10 @@ export async function normalizeOptions( DEFAULT_PACKAGE_MANAGER, git: !!cliOptions.git, chosenAddOns, - addOnOptions: populateAddOnOptionsDefaults(chosenAddOns), + addOnOptions: { + ...populateAddOnOptionsDefaults(chosenAddOns), + ...addOnOptionsFromCLI + }, starter: starter, } } diff --git a/packages/cta-cli/src/types.ts b/packages/cta-cli/src/types.ts index 8f7d58e3..4cdcdd96 100644 --- a/packages/cta-cli/src/types.ts +++ b/packages/cta-cli/src/types.ts @@ -12,10 +12,12 @@ export interface CliOptions { git?: boolean addOns?: Array | boolean listAddOns?: boolean + addonDetails?: string mcp?: boolean mcpSse?: boolean starter?: string targetDir?: string interactive?: boolean ui?: boolean + addOnConfig?: string } From 6074f82f432734fcebf337bcc83443f136c4c034 Mon Sep 17 00:00:00 2001 From: timoconnellaus Date: Thu, 3 Jul 2025 17:21:55 +1000 Subject: [PATCH 6/9] test: add comprehensive unit tests for add-on options system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 4 testing for the add-on options system with comprehensive unit test coverage: - add-on-options.test.ts: Zod schema validation, default population, and error handling (15 tests) - template-context.test.ts: EJS template context with addOnOption variable integration (12 tests) - filename-processing.test.ts: Prefix stripping logic for __prefix__filename patterns (11 tests) - conditional-packages.test.ts: EJS conditional package.json processing (11 tests) Key test coverage areas: - Option schema validation with comprehensive positive/negative cases - Template variable integration and conditional file processing - Filename prefix stripping (__option__filename.ext.ejs → filename.ext) - Conditional package dependency generation via EJS templates - Error handling for malformed options and templates All 49 new tests pass, ensuring robust validation of the add-on options functionality. --- .../cta-engine/tests/add-on-options.test.ts | 326 ++++++++++++++ .../tests/conditional-packages.test.ts | 418 ++++++++++++++++++ .../tests/filename-processing.test.ts | 275 ++++++++++++ .../cta-engine/tests/template-context.test.ts | 314 +++++++++++++ 4 files changed, 1333 insertions(+) create mode 100644 packages/cta-engine/tests/add-on-options.test.ts create mode 100644 packages/cta-engine/tests/conditional-packages.test.ts create mode 100644 packages/cta-engine/tests/filename-processing.test.ts create mode 100644 packages/cta-engine/tests/template-context.test.ts diff --git a/packages/cta-engine/tests/add-on-options.test.ts b/packages/cta-engine/tests/add-on-options.test.ts new file mode 100644 index 00000000..d439747e --- /dev/null +++ b/packages/cta-engine/tests/add-on-options.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect } from 'vitest' +import { z } from 'zod' +import { AddOnSelectOptionSchema, AddOnOptionSchema, AddOnOptionsSchema } from '../src/types.js' +import { populateAddOnOptionsDefaults } from '../src/add-ons.js' + +describe('Add-on Options', () => { + describe('Option Schema Validation', () => { + it('should validate a valid select option', () => { + const validSelectOption = { + type: 'select', + label: 'Database Provider', + description: 'Choose your database provider', + default: 'postgres', + options: [ + { value: 'postgres', label: 'PostgreSQL' }, + { value: 'mysql', label: 'MySQL' }, + { value: 'sqlite', label: 'SQLite' } + ] + } + + expect(() => AddOnSelectOptionSchema.parse(validSelectOption)).not.toThrow() + }) + + it('should reject select option without required fields', () => { + const invalidSelectOption = { + type: 'select', + // Missing required 'label' field + options: [{ value: 'test', label: 'Test' }] + } + + expect(() => AddOnSelectOptionSchema.parse(invalidSelectOption)).toThrow() + }) + + it('should reject select option with invalid option format', () => { + const invalidSelectOption = { + type: 'select', + label: 'Test', + options: [ + { value: 'test' } // Missing 'label' field + ] + } + + expect(() => AddOnSelectOptionSchema.parse(invalidSelectOption)).toThrow() + }) + + it('should reject select option with empty options array', () => { + const invalidSelectOption = { + type: 'select', + label: 'Test', + options: [] + } + + expect(() => AddOnSelectOptionSchema.parse(invalidSelectOption)).toThrow() + }) + + it('should validate AddOnOption discriminated union', () => { + const validOption = { + type: 'select', + label: 'Theme', + default: 'dark', + options: [ + { value: 'dark', label: 'Dark' }, + { value: 'light', label: 'Light' } + ] + } + + expect(() => AddOnOptionSchema.parse(validOption)).not.toThrow() + }) + + it('should validate AddOnOptions record', () => { + const validOptions = { + database: { + type: 'select', + label: 'Database Provider', + default: 'postgres', + options: [ + { value: 'postgres', label: 'PostgreSQL' }, + { value: 'mysql', label: 'MySQL' } + ] + }, + theme: { + type: 'select', + label: 'Theme', + default: 'dark', + options: [ + { value: 'dark', label: 'Dark' }, + { value: 'light', label: 'Light' } + ] + } + } + + expect(() => AddOnOptionsSchema.parse(validOptions)).not.toThrow() + }) + }) + + describe('populateAddOnOptionsDefaults', () => { + it('should populate defaults for add-ons with options', () => { + const addOns = [ + { + id: 'drizzle', + name: 'Drizzle ORM', + options: { + database: { + type: 'select' as const, + label: 'Database Provider', + default: 'postgres', + options: [ + { value: 'postgres', label: 'PostgreSQL' }, + { value: 'mysql', label: 'MySQL' }, + { value: 'sqlite', label: 'SQLite' } + ] + } + } + }, + { + id: 'shadcn', + name: 'shadcn/ui', + options: { + theme: { + type: 'select' as const, + label: 'Theme', + default: 'neutral', + options: [ + { value: 'neutral', label: 'Neutral' }, + { value: 'slate', label: 'Slate' } + ] + } + } + } + ] + + const result = populateAddOnOptionsDefaults(addOns) + + expect(result).toEqual({ + drizzle: { + database: 'postgres' + }, + shadcn: { + theme: 'neutral' + } + }) + }) + + it('should handle add-ons without options', () => { + const addOns = [ + { + id: 'simple-addon', + name: 'Simple Add-on' + // No options property + } + ] + + const result = populateAddOnOptionsDefaults(addOns) + + expect(result).toEqual({}) + }) + + it('should only populate defaults for enabled add-ons', () => { + const addOns = [ + { + id: 'drizzle', + name: 'Drizzle ORM', + options: { + database: { + type: 'select' as const, + label: 'Database Provider', + default: 'postgres', + options: [ + { value: 'postgres', label: 'PostgreSQL' }, + { value: 'mysql', label: 'MySQL' } + ] + } + } + }, + { + id: 'shadcn', + name: 'shadcn/ui', + options: { + theme: { + type: 'select' as const, + label: 'Theme', + default: 'neutral', + options: [ + { value: 'neutral', label: 'Neutral' }, + { value: 'slate', label: 'Slate' } + ] + } + } + } + ] + + const enabledAddOns = [addOns[0]] // Only drizzle + const result = populateAddOnOptionsDefaults(enabledAddOns) + + expect(result).toEqual({ + drizzle: { + database: 'postgres' + } + // shadcn should not be included + }) + }) + + it('should handle empty enabled add-ons array', () => { + const addOns = [ + { + id: 'drizzle', + name: 'Drizzle ORM', + options: { + database: { + type: 'select' as const, + label: 'Database Provider', + default: 'postgres', + options: [ + { value: 'postgres', label: 'PostgreSQL' } + ] + } + } + } + ] + + const enabledAddOns: Array = [] + const result = populateAddOnOptionsDefaults(enabledAddOns) + + expect(result).toEqual({}) + }) + + it('should handle add-ons with multiple options', () => { + const addOns = [ + { + id: 'complex-addon', + name: 'Complex Add-on', + options: { + database: { + type: 'select' as const, + label: 'Database', + default: 'postgres', + options: [ + { value: 'postgres', label: 'PostgreSQL' }, + { value: 'mysql', label: 'MySQL' } + ] + }, + theme: { + type: 'select' as const, + label: 'Theme', + default: 'dark', + options: [ + { value: 'dark', label: 'Dark' }, + { value: 'light', label: 'Light' } + ] + } + } + } + ] + + const result = populateAddOnOptionsDefaults(addOns) + + expect(result).toEqual({ + 'complex-addon': { + database: 'postgres', + theme: 'dark' + } + }) + }) + + it('should handle options without default values', () => { + const addOns = [ + { + id: 'no-default', + name: 'No Default Add-on', + options: { + database: { + type: 'select' as const, + label: 'Database', + // No default property + options: [ + { value: 'postgres', label: 'PostgreSQL' }, + { value: 'mysql', label: 'MySQL' } + ] + } + } + } + ] + + const result = populateAddOnOptionsDefaults(addOns) + + expect(result).toEqual({ + 'no-default': { + database: undefined + } + }) + }) + }) + + describe('Error Handling', () => { + it('should handle malformed option definitions gracefully', () => { + const malformedOptions = { + invalid: { + type: 'unknown-type', // Invalid type + label: 'Test' + } + } + + expect(() => AddOnOptionsSchema.parse(malformedOptions)).toThrow() + }) + + it('should validate option value types', () => { + const invalidOption = { + type: 'select', + label: 123, // Should be string + options: [{ value: 'test', label: 'Test' }] + } + + expect(() => AddOnSelectOptionSchema.parse(invalidOption)).toThrow() + }) + + it('should require non-empty option arrays', () => { + const emptyOptionsArray = { + type: 'select', + label: 'Test', + options: [] + } + + expect(() => AddOnSelectOptionSchema.parse(emptyOptionsArray)).toThrow() + }) + }) +}) \ No newline at end of file diff --git a/packages/cta-engine/tests/conditional-packages.test.ts b/packages/cta-engine/tests/conditional-packages.test.ts new file mode 100644 index 00000000..34dcfb4c --- /dev/null +++ b/packages/cta-engine/tests/conditional-packages.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, it } from 'vitest' + +import { createPackageJSON, mergePackageJSON } from '../src/package-json.js' +import type { Options, Framework } from '../src/types.js' + +describe('Conditional Package Dependencies', () => { + const baseFramework = { + basePackageJSON: { + version: '1.0.0', + type: 'module' + }, + optionalPackages: {} + } as unknown as Framework + + const baseOptions = { + projectName: 'test-app', + framework: baseFramework, + mode: 'file-router', + typescript: true, + tailwind: false, + packageManager: 'pnpm', + chosenAddOns: [], + addOnOptions: {} + } as unknown as Options + + describe('EJS Template Processing', () => { + it('should process packageTemplate with conditional dependencies', () => { + const options = { + ...baseOptions, + chosenAddOns: [ + { + id: 'drizzle', + name: 'Drizzle ORM', + packageTemplate: `{ + "dependencies": { + "drizzle-orm": "^0.29.0"<% if (addOnOption.drizzle.database === 'postgres') { %>, + "postgres": "^3.4.0"<% } %><% if (addOnOption.drizzle.database === 'mysql') { %>, + "mysql2": "^3.6.0"<% } %><% if (addOnOption.drizzle.database === 'sqlite') { %>, + "better-sqlite3": "^8.7.0"<% } %> + }, + "devDependencies": {<% if (addOnOption.drizzle.database === 'postgres') { %> + "@types/postgres": "^3.0.0"<% } %><% if (addOnOption.drizzle.database === 'mysql') { %> + "@types/mysql2": "^3.0.0"<% } %><% if (addOnOption.drizzle.database === 'sqlite') { %> + "@types/better-sqlite3": "^7.6.0"<% } %> + } + }` + } + ], + addOnOptions: { + drizzle: { + database: 'postgres' + } + } + } + + const packageJSON = createPackageJSON(options) + + expect(packageJSON.dependencies).toEqual({ + 'drizzle-orm': '^0.29.0', + 'postgres': '^3.4.0' + }) + expect(packageJSON.devDependencies).toEqual({ + '@types/postgres': '^3.0.0' + }) + // MySQL and SQLite dependencies should not be included + expect(packageJSON.dependencies).not.toHaveProperty('mysql2') + expect(packageJSON.dependencies).not.toHaveProperty('better-sqlite3') + expect(packageJSON.devDependencies).not.toHaveProperty('@types/mysql2') + expect(packageJSON.devDependencies).not.toHaveProperty('@types/better-sqlite3') + }) + + it('should handle different database options correctly', () => { + const options = { + ...baseOptions, + chosenAddOns: [ + { + id: 'drizzle', + name: 'Drizzle ORM', + packageTemplate: `{ + "dependencies": { + "drizzle-orm": "^0.29.0"<% if (addOnOption.drizzle.database === 'postgres') { %>, + "postgres": "^3.4.0"<% } %><% if (addOnOption.drizzle.database === 'mysql') { %>, + "mysql2": "^3.6.0"<% } %><% if (addOnOption.drizzle.database === 'sqlite') { %>, + "better-sqlite3": "^8.7.0"<% } %> + } + }` + } + ], + addOnOptions: { + drizzle: { + database: 'mysql' + } + } + } + + const packageJSON = createPackageJSON(options) + + expect(packageJSON.dependencies).toEqual({ + 'drizzle-orm': '^0.29.0', + 'mysql2': '^3.6.0' + }) + // PostgreSQL and SQLite dependencies should not be included + expect(packageJSON.dependencies).not.toHaveProperty('postgres') + expect(packageJSON.dependencies).not.toHaveProperty('better-sqlite3') + }) + + it('should handle SQLite option correctly', () => { + const options = { + ...baseOptions, + chosenAddOns: [ + { + id: 'drizzle', + name: 'Drizzle ORM', + packageTemplate: `{ + "dependencies": { + "drizzle-orm": "^0.29.0"<% if (addOnOption.drizzle.database === 'postgres') { %>, + "postgres": "^3.4.0"<% } %><% if (addOnOption.drizzle.database === 'mysql') { %>, + "mysql2": "^3.6.0"<% } %><% if (addOnOption.drizzle.database === 'sqlite') { %>, + "better-sqlite3": "^8.7.0"<% } %> + }, + "devDependencies": {<% if (addOnOption.drizzle.database === 'sqlite') { %> + "@types/better-sqlite3": "^7.6.0"<% } %> + } + }` + } + ], + addOnOptions: { + drizzle: { + database: 'sqlite' + } + } + } + + const packageJSON = createPackageJSON(options) + + expect(packageJSON.dependencies).toEqual({ + 'drizzle-orm': '^0.29.0', + 'better-sqlite3': '^8.7.0' + }) + expect(packageJSON.devDependencies).toEqual({ + '@types/better-sqlite3': '^7.6.0' + }) + }) + + it('should handle multiple add-ons with options', () => { + const options = { + ...baseOptions, + chosenAddOns: [ + { + id: 'drizzle', + name: 'Drizzle ORM', + packageTemplate: `{ + "dependencies": { + "drizzle-orm": "^0.29.0"<% if (addOnOption.drizzle.database === 'postgres') { %>, + "postgres": "^3.4.0"<% } %> + } + }` + }, + { + id: 'auth', + name: 'Authentication', + packageTemplate: `{ + "dependencies": {<% if (addOnOption.auth.provider === 'auth0') { %> + "@auth0/nextjs-auth0": "^3.0.0"<% } %><% if (addOnOption.auth.provider === 'supabase') { %> + "@supabase/supabase-js": "^2.0.0"<% } %> + } + }` + } + ], + addOnOptions: { + drizzle: { + database: 'postgres' + }, + auth: { + provider: 'auth0' + } + } + } + + const packageJSON = createPackageJSON(options) + + expect(packageJSON.dependencies).toEqual({ + 'drizzle-orm': '^0.29.0', + 'postgres': '^3.4.0', + '@auth0/nextjs-auth0': '^3.0.0' + }) + expect(packageJSON.dependencies).not.toHaveProperty('@supabase/supabase-js') + }) + + it('should handle complex conditional logic', () => { + const options = { + ...baseOptions, + chosenAddOns: [ + { + id: 'ui', + name: 'UI Library', + packageTemplate: `{ + "dependencies": {<% if (addOnOption.ui.library === 'chakra') { %> + "@chakra-ui/react": "^2.0.0", + "@emotion/react": "^11.0.0", + "@emotion/styled": "^11.0.0"<% } else if (addOnOption.ui.library === 'mui') { %> + "@mui/material": "^5.0.0", + "@emotion/react": "^11.0.0", + "@emotion/styled": "^11.0.0"<% } else if (addOnOption.ui.library === 'mantine') { %> + "@mantine/core": "^7.0.0", + "@mantine/hooks": "^7.0.0"<% } %> + } + }` + } + ], + addOnOptions: { + ui: { + library: 'mantine' + } + } + } + + const packageJSON = createPackageJSON(options) + + expect(packageJSON.dependencies).toEqual({ + '@mantine/core': '^7.0.0', + '@mantine/hooks': '^7.0.0' + }) + // Other UI library dependencies should not be included + expect(packageJSON.dependencies).not.toHaveProperty('@chakra-ui/react') + expect(packageJSON.dependencies).not.toHaveProperty('@mui/material') + }) + + it('should handle scripts conditionally', () => { + const options = { + ...baseOptions, + chosenAddOns: [ + { + id: 'testing', + name: 'Testing Setup', + packageTemplate: `{ + "scripts": {<% if (addOnOption.testing.framework === 'jest') { %> + "test": "jest", + "test:watch": "jest --watch"<% } else if (addOnOption.testing.framework === 'vitest') { %> + "test": "vitest", + "test:ui": "vitest --ui"<% } %> + }, + "devDependencies": {<% if (addOnOption.testing.framework === 'jest') { %> + "jest": "^29.0.0", + "@types/jest": "^29.0.0"<% } else if (addOnOption.testing.framework === 'vitest') { %> + "vitest": "^1.0.0", + "@vitest/ui": "^1.0.0"<% } %> + } + }` + } + ], + addOnOptions: { + testing: { + framework: 'vitest' + } + } + } + + const packageJSON = createPackageJSON(options) + + expect(packageJSON.scripts).toEqual({ + 'test': 'vitest', + 'test:ui': 'vitest --ui' + }) + expect(packageJSON.devDependencies).toEqual({ + 'vitest': '^1.0.0', + '@vitest/ui': '^1.0.0' + }) + // Jest-specific scripts and dependencies should not be included + expect(packageJSON.scripts).not.toHaveProperty('test:watch') + expect(packageJSON.devDependencies).not.toHaveProperty('jest') + expect(packageJSON.devDependencies).not.toHaveProperty('@types/jest') + }) + + it('should fallback to packageAdditions on template error', () => { + const options = { + ...baseOptions, + chosenAddOns: [ + { + id: 'broken', + name: 'Broken Template', + packageTemplate: `{ + "dependencies": { + "valid-package": "^1.0.0" + <% this will cause a syntax error %> + } + }`, + packageAdditions: { + dependencies: { + 'fallback-package': '^1.0.0' + } + } + } + ], + addOnOptions: {} + } + + const packageJSON = createPackageJSON(options) + + // Should use fallback packageAdditions + expect(packageJSON.dependencies).toEqual({ + 'fallback-package': '^1.0.0' + }) + expect(packageJSON.dependencies).not.toHaveProperty('valid-package') + }) + + it('should handle empty or missing addOnOptions', () => { + const options = { + ...baseOptions, + chosenAddOns: [ + { + id: 'simple', + name: 'Simple Add-on', + packageTemplate: `{ + "dependencies": { + "always-included": "^1.0.0"<% if (addOnOption.simple && addOnOption.simple.feature) { %>, + "conditional-package": "^1.0.0"<% } %> + } + }` + } + ], + addOnOptions: {} // No options for this add-on + } + + const packageJSON = createPackageJSON(options) + + expect(packageJSON.dependencies).toEqual({ + 'always-included': '^1.0.0' + }) + expect(packageJSON.dependencies).not.toHaveProperty('conditional-package') + }) + + it('should preserve dependency sorting after template processing', () => { + const options = { + ...baseOptions, + chosenAddOns: [ + { + id: 'sorting-test', + name: 'Sorting Test', + packageTemplate: `{ + "dependencies": { + "z-package": "^1.0.0", + "a-package": "^1.0.0", + "m-package": "^1.0.0" + } + }` + } + ], + addOnOptions: {} + } + + const packageJSON = createPackageJSON(options) + const dependencyKeys = Object.keys(packageJSON.dependencies) + + // Dependencies should be sorted alphabetically + expect(dependencyKeys).toEqual(['a-package', 'm-package', 'z-package']) + }) + }) + + describe('mergePackageJSON', () => { + it('should merge dependencies correctly', () => { + const base = { + dependencies: { + 'react': '^18.0.0', + 'lodash': '^4.0.0' + }, + devDependencies: { + 'typescript': '^5.0.0' + } + } + + const overlay = { + dependencies: { + 'axios': '^1.0.0', + 'lodash': '^4.17.0' // Should override + }, + devDependencies: { + 'jest': '^29.0.0' + } + } + + const result = mergePackageJSON(base, overlay) + + expect(result.dependencies).toEqual({ + 'react': '^18.0.0', + 'lodash': '^4.17.0', // Overridden version + 'axios': '^1.0.0' + }) + expect(result.devDependencies).toEqual({ + 'typescript': '^5.0.0', + 'jest': '^29.0.0' + }) + }) + + it('should handle missing sections gracefully', () => { + const base = { + dependencies: { + 'react': '^18.0.0' + } + } + + const overlay = { + devDependencies: { + 'jest': '^29.0.0' + } + } + + const result = mergePackageJSON(base, overlay) + + expect(result.dependencies).toEqual({ + 'react': '^18.0.0' + }) + expect(result.devDependencies).toEqual({ + 'jest': '^29.0.0' + }) + }) + }) +}) \ No newline at end of file diff --git a/packages/cta-engine/tests/filename-processing.test.ts b/packages/cta-engine/tests/filename-processing.test.ts new file mode 100644 index 00000000..856c05dc --- /dev/null +++ b/packages/cta-engine/tests/filename-processing.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from 'vitest' + +import { createMemoryEnvironment } from '../src/environment.js' +import { createTemplateFile } from '../src/template-file.js' + +import type { Options } from '../src/types.js' + +const simpleOptions = { + projectName: 'test', + targetDir: '/test', + framework: { + id: 'test', + name: 'Test', + }, + chosenAddOns: [], + packageManager: 'pnpm', + typescript: true, + tailwind: true, + mode: 'file-router', + addOnOptions: {}, +} as unknown as Options + +describe('Filename Processing - Prefix Stripping', () => { + it('should strip single prefix from filename', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { database: 'postgres' } + } + }) + environment.startRun() + await templateFile( + './__postgres__drizzle.config.ts.ejs', + '<% if (addOnOption.drizzle.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL config\nexport default { driver: "postgres" }' + ) + environment.finishRun() + + // File should be created with prefix stripped + expect(output.files['/test/drizzle.config.ts']).toBeDefined() + expect(output.files['/test/drizzle.config.ts'].trim()).toEqual('// PostgreSQL config\nexport default { driver: \'postgres\' }') + + // Original prefixed filename should not exist + expect(output.files['/test/__postgres__drizzle.config.ts']).toBeUndefined() + }) + + it('should strip prefix from nested directory paths', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { database: 'mysql' } + } + }) + environment.startRun() + await templateFile( + './src/db/__mysql__connection.ts.ejs', + '<% if (addOnOption.drizzle.database !== "mysql") { ignoreFile() } %>\n// MySQL connection\nexport const connection = "mysql"' + ) + environment.finishRun() + + // File should be created with prefix stripped, preserving directory structure + expect(output.files['/test/src/db/connection.ts']).toBeDefined() + expect(output.files['/test/src/db/connection.ts'].trim()).toEqual('// MySQL connection\nexport const connection = \'mysql\'') + + // Original prefixed path should not exist + expect(output.files['/test/src/db/__mysql__connection.ts']).toBeUndefined() + }) + + it('should handle multiple prefixed files in same directory', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { database: 'sqlite' } + } + }) + environment.startRun() + await templateFile( + './__postgres__drizzle.config.ts.ejs', + '<% if (addOnOption.drizzle.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL config' + ) + await templateFile( + './__mysql__drizzle.config.ts.ejs', + '<% if (addOnOption.drizzle.database !== "mysql") { ignoreFile() } %>\n// MySQL config' + ) + await templateFile( + './__sqlite__drizzle.config.ts.ejs', + '<% if (addOnOption.drizzle.database !== "sqlite") { ignoreFile() } %>\n// SQLite config' + ) + environment.finishRun() + + // Only SQLite file should exist (others ignored via ignoreFile()) + expect(output.files['/test/drizzle.config.ts']).toBeDefined() + expect(output.files['/test/drizzle.config.ts'].trim()).toEqual('// SQLite config') + + // Prefixed filenames should not exist + expect(output.files['/test/__postgres__drizzle.config.ts']).toBeUndefined() + expect(output.files['/test/__mysql__drizzle.config.ts']).toBeUndefined() + expect(output.files['/test/__sqlite__drizzle.config.ts']).toBeUndefined() + }) + + it('should handle complex filename patterns', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + auth: { provider: 'auth0' } + } + }) + environment.startRun() + await templateFile( + './__auth0__auth.config.js.ejs', + '<% if (addOnOption.auth.provider !== "auth0") { ignoreFile() } %>\n// Auth0 configuration\nmodule.exports = { provider: "auth0" }' + ) + environment.finishRun() + + // File should be created with prefix stripped + expect(output.files['/test/auth.config.js']).toBeDefined() + expect(output.files['/test/auth.config.js'].trim()).toEqual('// Auth0 configuration\nmodule.exports = { provider: "auth0" }') + }) + + it('should handle prefixed files with .append.ejs extension', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { database: 'postgres' } + } + }) + environment.startRun() + // Create base file first + await templateFile( + './.env.ejs', + 'BASE_VAR=value\n' + ) + // Then append with prefixed filename + await templateFile( + './__postgres__.env.append.ejs', + '<% if (addOnOption.drizzle.database !== "postgres") { ignoreFile() } %>\nDATABASE_URL=postgresql://localhost:5432/mydb\n' + ) + environment.finishRun() + + // File should be created with prefix stripped and content appended + expect(output.files['/test/.env']).toBeDefined() + expect(output.files['/test/.env']).toEqual('BASE_VAR=value\n\nDATABASE_URL=postgresql://localhost:5432/mydb\n') + }) + + it('should handle files without prefixes normally', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, simpleOptions) + environment.startRun() + await templateFile( + './regular-file.ts.ejs', + 'export const config = "normal"' + ) + environment.finishRun() + + // File should be created with normal filename processing + expect(output.files['/test/regular-file.ts']).toBeDefined() + expect(output.files['/test/regular-file.ts']).toEqual('export const config = \'normal\'\n') + }) + + it('should handle malformed prefixes gracefully', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, simpleOptions) + environment.startRun() + await templateFile( + './__malformed_prefix.ts.ejs', + 'export const config = "malformed"' + ) + await templateFile( + './__only_one_underscore.ts.ejs', + 'export const config = "malformed2"' + ) + await templateFile( + './____.ts.ejs', + 'export const config = "empty"' + ) + environment.finishRun() + + // Files with malformed prefixes should be created as-is (minus .ejs extension) + expect(output.files['/test/__malformed_prefix.ts']).toBeDefined() + expect(output.files['/test/__only_one_underscore.ts']).toBeDefined() + expect(output.files['/test/____.ts']).toBeDefined() + }) + + it('should handle deeply nested prefixed files', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + styling: { framework: 'tailwind' } + } + }) + environment.startRun() + await templateFile( + './src/styles/components/__tailwind__button.css.ejs', + '<% if (addOnOption.styling.framework !== "tailwind") { ignoreFile() } %>\n@tailwind base;\n@tailwind components;\n@tailwind utilities;' + ) + environment.finishRun() + + // File should be created with prefix stripped, preserving deep directory structure + expect(output.files['/test/src/styles/components/button.css']).toBeDefined() + expect(output.files['/test/src/styles/components/button.css'].trim()).toEqual('@tailwind base;\n@tailwind components;\n@tailwind utilities;') + }) + + it('should handle prefix stripping with different option values', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + ui: { library: 'chakra' } + } + }) + environment.startRun() + await templateFile( + './__chakra__theme.ts.ejs', + '<% if (addOnOption.ui.library !== "chakra") { ignoreFile() } %>\n// Chakra UI theme\nexport const theme = { colors: {} }' + ) + await templateFile( + './__mui__theme.ts.ejs', + '<% if (addOnOption.ui.library !== "mui") { ignoreFile() } %>\n// Material-UI theme\nexport const theme = { palette: {} }' + ) + environment.finishRun() + + // Only Chakra file should exist (MUI ignored via ignoreFile()) + expect(output.files['/test/theme.ts']).toBeDefined() + expect(output.files['/test/theme.ts'].trim()).toEqual('// Chakra UI theme\nexport const theme = { colors: {} }') + + // Prefixed filenames should not exist + expect(output.files['/test/__chakra__theme.ts']).toBeUndefined() + expect(output.files['/test/__mui__theme.ts']).toBeUndefined() + }) + + it('should handle complex prefix with special characters', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + deployment: { platform: 'vercel-edge' } + } + }) + environment.startRun() + await templateFile( + './__vercel-edge__api.ts.ejs', + '<% if (addOnOption.deployment.platform !== "vercel-edge") { ignoreFile() } %>\n// Vercel Edge API\nexport const runtime = "edge"' + ) + environment.finishRun() + + // File should be created with prefix stripped + expect(output.files['/test/api.ts']).toBeDefined() + expect(output.files['/test/api.ts'].trim()).toEqual('// Vercel Edge API\nexport const runtime = \'edge\'') + }) + + it('should handle multiple prefixes in same filename (edge case)', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + test: { value: 'postgres' } + } + }) + environment.startRun() + await templateFile( + './__postgres__file__with__underscores.ts.ejs', + '<% if (addOnOption.test.value !== "postgres") { ignoreFile() } %>\n// File with underscores\nexport const value = "test"' + ) + environment.finishRun() + + // Should only strip the first valid prefix pattern + expect(output.files['/test/file__with__underscores.ts']).toBeDefined() + expect(output.files['/test/file__with__underscores.ts'].trim()).toEqual('// File with underscores\nexport const value = \'test\'') + }) +}) \ No newline at end of file diff --git a/packages/cta-engine/tests/template-context.test.ts b/packages/cta-engine/tests/template-context.test.ts new file mode 100644 index 00000000..813456b6 --- /dev/null +++ b/packages/cta-engine/tests/template-context.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from 'vitest' + +import { createMemoryEnvironment } from '../src/environment.js' +import { createTemplateFile } from '../src/template-file.js' + +import type { AddOn, Options } from '../src/types.js' + +const simpleOptions = { + projectName: 'test', + targetDir: '/test', + framework: { + id: 'test', + name: 'Test', + }, + chosenAddOns: [], + packageManager: 'pnpm', + typescript: true, + tailwind: true, + mode: 'file-router', + addOnOptions: {}, +} as unknown as Options + +describe('Template Context - Add-on Options', () => { + it('should provide addOnOption context variable', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { + database: 'postgres' + } + } + }) + environment.startRun() + await templateFile('./test.txt.ejs', 'Database: <%= addOnOption.drizzle.database %>') + environment.finishRun() + + expect(output.files['/test/test.txt']).toEqual('Database: postgres') + }) + + it('should handle multiple add-on options', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { + database: 'mysql' + }, + shadcn: { + theme: 'slate' + } + } + }) + environment.startRun() + await templateFile( + './test.txt.ejs', + 'Drizzle: <%= addOnOption.drizzle.database %>, shadcn: <%= addOnOption.shadcn.theme %>' + ) + environment.finishRun() + + expect(output.files['/test/test.txt']).toEqual('Drizzle: mysql, shadcn: slate') + }) + + it('should handle multiple options per add-on', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + 'complex-addon': { + database: 'postgres', + theme: 'dark', + port: 5432 + } + } + }) + environment.startRun() + await templateFile( + './test.txt.ejs', + 'DB: <%= addOnOption["complex-addon"].database %>, Theme: <%= addOnOption["complex-addon"].theme %>, Port: <%= addOnOption["complex-addon"].port %>' + ) + environment.finishRun() + + expect(output.files['/test/test.txt']).toEqual('DB: postgres, Theme: dark, Port: 5432') + }) + + it('should handle conditional logic with addOnOption', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { + database: 'postgres' + } + } + }) + environment.startRun() + await templateFile( + './test.txt.ejs', + `<% if (addOnOption.drizzle.database === 'postgres') { %> +PostgreSQL configuration +<% } else if (addOnOption.drizzle.database === 'mysql') { %> +MySQL configuration +<% } else { %> +SQLite configuration +<% } %>` + ) + environment.finishRun() + + expect(output.files['/test/test.txt'].trim()).toEqual('PostgreSQL configuration') + }) + + it('should handle ignoreFile() with option conditions', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { + database: 'postgres' + } + } + }) + environment.startRun() + await templateFile( + './postgres-config.ts.ejs', + '<% if (addOnOption.drizzle.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL configuration\nexport const config = "postgres"' + ) + await templateFile( + './mysql-config.ts.ejs', + '<% if (addOnOption.drizzle.database !== "mysql") { ignoreFile() } %>\n// MySQL configuration\nexport const config = "mysql"' + ) + environment.finishRun() + + expect(output.files['/test/postgres-config.ts']).toBeDefined() + expect(output.files['/test/postgres-config.ts'].trim()).toEqual('// PostgreSQL configuration\nexport const config = \'postgres\'') + expect(output.files['/test/mysql-config.ts']).toBeUndefined() + }) + + it('should handle empty addOnOptions', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: {} + }) + environment.startRun() + await templateFile( + './test.txt.ejs', + 'Options: <%= JSON.stringify(addOnOption) %>' + ) + environment.finishRun() + + expect(output.files['/test/test.txt']).toEqual('Options: {}') + }) + + it('should handle undefined option values', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { + database: undefined + } + } + }) + environment.startRun() + await templateFile( + './test.txt.ejs', + 'Database: <%= addOnOption.drizzle.database || "not set" %>' + ) + environment.finishRun() + + expect(output.files['/test/test.txt']).toEqual('Database: not set') + }) + + it('should work alongside existing template variables', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + projectName: 'my-app', + chosenAddOns: [ + { + id: 'drizzle', + name: 'Drizzle ORM', + } as AddOn + ], + addOnOptions: { + drizzle: { + database: 'postgres' + } + } + }) + environment.startRun() + await templateFile( + './test.txt.ejs', + 'Project: <%= projectName %>, Add-ons: <%= Object.keys(addOnEnabled).join(", ") %>, Database: <%= addOnOption.drizzle.database %>' + ) + environment.finishRun() + + expect(output.files['/test/test.txt']).toEqual('Project: my-app, Add-ons: drizzle, Database: postgres') + }) + + it('should handle nested object access safely', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { + database: 'postgres' + } + } + }) + environment.startRun() + await templateFile( + './test.txt.ejs', + 'Exists: <%= addOnOption.drizzle ? "yes" : "no" %>, Non-existent: <%= addOnOption.nonexistent ? "yes" : "no" %>' + ) + environment.finishRun() + + expect(output.files['/test/test.txt']).toEqual('Exists: yes, Non-existent: no') + }) + + it('should handle option-based conditional imports', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { + database: 'postgres' + } + } + }) + environment.startRun() + await templateFile( + './db-config.ts.ejs', + `<% if (addOnOption.drizzle.database === 'postgres') { %> +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +<% } else if (addOnOption.drizzle.database === 'mysql') { %> +import { drizzle } from 'drizzle-orm/mysql2' +import mysql from 'mysql2/promise' +<% } else if (addOnOption.drizzle.database === 'sqlite') { %> +import { drizzle } from 'drizzle-orm/better-sqlite3' +import Database from 'better-sqlite3' +<% } %> + +export const db = drizzle(/* connection */)` + ) + environment.finishRun() + + expect(output.files['/test/db-config.ts']).toContain("import { drizzle } from 'drizzle-orm/postgres-js'") + expect(output.files['/test/db-config.ts']).toContain("import postgres from 'postgres'") + expect(output.files['/test/db-config.ts']).not.toContain("import mysql from 'mysql2/promise'") + expect(output.files['/test/db-config.ts']).not.toContain("import Database from 'better-sqlite3'") + }) + + it('should handle filename prefix stripping', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { + database: 'postgres' + } + } + }) + environment.startRun() + await templateFile( + './__postgres__drizzle.config.ts.ejs', + '<% if (addOnOption.drizzle.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL Drizzle config\nexport default { driver: "postgres" }' + ) + await templateFile( + './__mysql__drizzle.config.ts.ejs', + '<% if (addOnOption.drizzle.database !== "mysql") { ignoreFile() } %>\n// MySQL Drizzle config\nexport default { driver: "mysql" }' + ) + environment.finishRun() + + // File should be created with prefix stripped + expect(output.files['/test/drizzle.config.ts']).toBeDefined() + expect(output.files['/test/drizzle.config.ts'].trim()).toEqual('// PostgreSQL Drizzle config\nexport default { driver: \'postgres\' }') + + // Prefixed filename should not exist + expect(output.files['/test/__postgres__drizzle.config.ts']).toBeUndefined() + expect(output.files['/test/__mysql__drizzle.config.ts']).toBeUndefined() + }) + + it('should handle nested directory with prefixed files', async () => { + const { environment, output } = createMemoryEnvironment() + const templateFile = createTemplateFile(environment, { + ...simpleOptions, + addOnOptions: { + drizzle: { + database: 'sqlite' + } + } + }) + environment.startRun() + await templateFile( + './src/db/__sqlite__index.ts.ejs', + '<% if (addOnOption.drizzle.database !== "sqlite") { ignoreFile() } %>\n// SQLite database connection\nexport const db = "sqlite"' + ) + await templateFile( + './src/db/__postgres__index.ts.ejs', + '<% if (addOnOption.drizzle.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL database connection\nexport const db = "postgres"' + ) + environment.finishRun() + + // SQLite file should be created with prefix stripped + expect(output.files['/test/src/db/index.ts']).toBeDefined() + expect(output.files['/test/src/db/index.ts'].trim()).toEqual('// SQLite database connection\nexport const db = \'sqlite\'') + + // Prefixed filenames should not exist + expect(output.files['/test/src/db/__sqlite__index.ts']).toBeUndefined() + expect(output.files['/test/src/db/__postgres__index.ts']).toBeUndefined() + }) +}) \ No newline at end of file From 35b55417f010b4a9551cc86a3aef1a6f6337f083 Mon Sep 17 00:00:00 2001 From: timoconnellaus Date: Mon, 14 Jul 2025 12:16:00 +1000 Subject: [PATCH 7/9] docs: add comprehensive add-on options documentation - Document configuration format and option types in info.json - Explain template usage with addOnOption variables - Cover conditional file naming conventions with prefixes - Provide complete examples for both React CRA and Solid frameworks - Include CLI usage patterns for interactive and non-interactive modes - Add best practices for add-on developers --- frameworks/react-cra/ADD-ON-AUTHORING.md | 209 ++++++++++++++++++ frameworks/solid/ADD-ON-AUTHORING.md | 264 ++++++++++++++++++++++- 2 files changed, 472 insertions(+), 1 deletion(-) diff --git a/frameworks/react-cra/ADD-ON-AUTHORING.md b/frameworks/react-cra/ADD-ON-AUTHORING.md index c4a01594..d1a6bb11 100644 --- a/frameworks/react-cra/ADD-ON-AUTHORING.md +++ b/frameworks/react-cra/ADD-ON-AUTHORING.md @@ -167,3 +167,212 @@ If you don't want a header link you can omit the `url` and `name` properties. You **MUST** specify routes in the `info.json` file if your add-on supports the `code-router` mode. This is because the `code-routers` setup needs to import the routes in order to add them to the router. By convension you should prefix demo routes with `demo` to make it clear that they are demo routes so they can be easily identified and removed. + +# Add-on Options + +The CTA framework supports configurable add-ons through an options system that allows users to customize add-on behavior during creation. This enables more flexible and reusable add-ons that can adapt to different use cases. + +## Overview + +Add-on options allow developers to create configurable add-ons where users can select from predefined choices that affect: +- Which files are included in the generated project +- Template variable values used during file generation +- Package dependencies that get installed +- Configuration file contents + +## Configuration Format + +Options are defined in the `info.json` file using the following schema: + +```json +{ + "name": "My Add-on", + "description": "A configurable add-on", + "options": { + "optionName": { + "type": "select", + "label": "Display Label", + "description": "Optional description shown to users", + "default": "defaultValue", + "options": [ + { "value": "option1", "label": "Option 1" }, + { "value": "option2", "label": "Option 2" } + ] + } + } +} +``` + +### Option Types + +#### Select Options + +The `select` type allows users to choose from a predefined list of options: + +```json +"database": { + "type": "select", + "label": "Database Provider", + "description": "Choose your database provider", + "default": "postgres", + "options": [ + { "value": "postgres", "label": "PostgreSQL" }, + { "value": "mysql", "label": "MySQL" }, + { "value": "sqlite", "label": "SQLite" } + ] +} +``` + +**Properties:** +- `type`: Must be `"select"` +- `label`: Display text shown to users +- `description`: Optional help text +- `default`: Default value that must match one of the option values +- `options`: Array of value/label pairs + +## Template Usage + +Option values are available in EJS templates through the `addOnOption` variable: + +```ejs + +<% if (addOnOption.myAddOnId.database === 'postgres') { %> + PostgreSQL specific code +<% } %> + + +const driver = '<%= addOnOption.myAddOnId.database %>' +``` + +The structure is: `addOnOption.{addOnId}.{optionName}` + +## Conditional Files + +Use filename prefixes to include files only when specific option values are selected: + +``` +assets/ +├── __postgres__drizzle.config.ts.ejs +├── __mysql__drizzle.config.ts.ejs +├── __sqlite__drizzle.config.ts.ejs +└── src/ + └── db/ + ├── __postgres__index.ts.ejs + ├── __mysql__index.ts.ejs + └── __sqlite__index.ts.ejs +``` + +**Naming Convention:** +- `__optionValue__filename.ext.ejs` - Include only if option matches value +- The prefix is stripped from the final filename +- Use `ignoreFile()` in templates for additional conditional logic + +### Template Conditional Logic + +Within template files, use `ignoreFile()` to skip file generation: + +```ejs +<% if (addOnOption.drizzle.database !== 'postgres') { ignoreFile() } %> +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' + +const client = postgres(process.env.DATABASE_URL!) +export const db = drizzle(client) +``` + +## Complete Example: Drizzle Add-on + +Here's how the Drizzle add-on implements configurable database support: + +### Examples + +Configuration in `info.json`: +```json +{ + "name": "Drizzle ORM", + "description": "Add Drizzle ORM with configurable database support to your application.", + "options": { + "database": { + "type": "select", + "label": "Database Provider", + "description": "Choose your database provider", + "default": "postgres", + "options": [ + { "value": "postgres", "label": "PostgreSQL" }, + { "value": "mysql", "label": "MySQL" }, + { "value": "sqlite", "label": "SQLite" } + ] + } + } +} +``` + +File structure: +``` +drizzle/ +├── assets/ +│ ├── __postgres__drizzle.config.ts.ejs +│ ├── __mysql__drizzle.config.ts.ejs +│ ├── __sqlite__drizzle.config.ts.ejs +│ └── src/ +│ └── db/ +│ ├── __postgres__index.ts.ejs +│ ├── __mysql__index.ts.ejs +│ └── __sqlite__index.ts.ejs +└── package.json.ejs +``` + +Code in `assets/__postgres__drizzle.config.ts.ejs`: +```ejs +<% if (addOnOption.drizzle.database !== 'postgres') { ignoreFile() } %> +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './src/db/schema.<%= js %>', + out: './src/db/migrations', + driver: 'pg', + dbCredentials: { + connectionString: process.env.DATABASE_URL!, + } +}) +``` + +Code in `package.json.ejs`: +```ejs +{ + <% if (addOnOption.drizzle.database === 'postgres') { %> + "pg": "^8.11.0", + "drizzle-orm": "^0.29.0" + <% } else if (addOnOption.drizzle.database === 'mysql') { %> + "mysql2": "^3.6.0", + "drizzle-orm": "^0.29.0" + <% } %> +} +``` + +## CLI Usage + +### Interactive Mode +When using the CLI interactively, users are prompted for each option: + +```bash +create-tsrouter-app my-app +# User selects Drizzle add-on +# CLI prompts: "Drizzle ORM: Database Provider" with options +``` + +### Non-Interactive Mode +Options can be specified via JSON configuration: + +```bash +create-tsrouter-app my-app --add-ons drizzle --add-on-config '{"drizzle":{"database":"mysql"}}' +``` + +## Best Practices + +1. **Use descriptive labels** - Make option purposes clear to users +2. **Provide sensible defaults** - Choose the most common use case +3. **Group related files** - Use consistent prefixing for option-specific files +4. **Document options** - Include descriptions to help users understand choices +5. **Test all combinations** - Ensure each option value generates working code +6. **Use validation** - The system validates options against the schema automatically diff --git a/frameworks/solid/ADD-ON-AUTHORING.md b/frameworks/solid/ADD-ON-AUTHORING.md index 047151dc..90f67f0f 100644 --- a/frameworks/solid/ADD-ON-AUTHORING.md +++ b/frameworks/solid/ADD-ON-AUTHORING.md @@ -79,7 +79,7 @@ The code is integrated into these locations with these application architectures Code in `assets/src/components/my-provider.tsx`: ```ts -export default function MyProvider({ children }: { children: React.ReactNode }) { +export default function MyProvider({ children }: { children: JSX.Element }) { return {children} } ``` @@ -127,3 +127,265 @@ If you don't want a header link you can omit the `url` and `name` properties. You **MUST** specify routes in the `info.json` file if your add-on supports the `code-router` mode. This is because the `code-routers` setup needs to import the routes in order to add them to the router. By convension you should prefix demo routes with `demo` to make it clear that they are demo routes so they can be easily identified and removed. + +# Add-on Options + +The CTA framework supports configurable add-ons through an options system that allows users to customize add-on behavior during creation. This enables more flexible and reusable add-ons that can adapt to different use cases. + +## Overview + +Add-on options allow developers to create configurable add-ons where users can select from predefined choices that affect: +- Which files are included in the generated project +- Template variable values used during file generation +- Package dependencies that get installed +- Configuration file contents + +## Configuration Format + +Options are defined in the `info.json` file using the following schema: + +```json +{ + "name": "My Add-on", + "description": "A configurable add-on", + "options": { + "optionName": { + "type": "select", + "label": "Display Label", + "description": "Optional description shown to users", + "default": "defaultValue", + "options": [ + { "value": "option1", "label": "Option 1" }, + { "value": "option2", "label": "Option 2" } + ] + } + } +} +``` + +### Option Types + +#### Select Options + +The `select` type allows users to choose from a predefined list of options: + +```json +"database": { + "type": "select", + "label": "Database Provider", + "description": "Choose your database provider", + "default": "postgres", + "options": [ + { "value": "postgres", "label": "PostgreSQL" }, + { "value": "mysql", "label": "MySQL" }, + { "value": "sqlite", "label": "SQLite" } + ] +} +``` + +**Properties:** +- `type`: Must be `"select"` +- `label`: Display text shown to users +- `description`: Optional help text +- `default`: Default value that must match one of the option values +- `options`: Array of value/label pairs + +## Template Usage + +Option values are available in EJS templates through the `addOnOption` variable: + +```ejs + +<% if (addOnOption.myAddOnId.database === 'postgres') { %> + PostgreSQL specific code +<% } %> + + +const driver = '<%= addOnOption.myAddOnId.database %>' +``` + +The structure is: `addOnOption.{addOnId}.{optionName}` + +## Conditional Files + +Use filename prefixes to include files only when specific option values are selected: + +``` +assets/ +├── __postgres__drizzle.config.ts.ejs +├── __mysql__drizzle.config.ts.ejs +├── __sqlite__drizzle.config.ts.ejs +└── src/ + └── db/ + ├── __postgres__index.ts.ejs + ├── __mysql__index.ts.ejs + └── __sqlite__index.ts.ejs +``` + +**Naming Convention:** +- `__optionValue__filename.ext.ejs` - Include only if option matches value +- The prefix is stripped from the final filename +- Use `ignoreFile()` in templates for additional conditional logic + +### Template Conditional Logic + +Within template files, use `ignoreFile()` to skip file generation: + +```ejs +<% if (addOnOption.drizzle.database !== 'postgres') { ignoreFile() } %> +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' + +const client = postgres(process.env.DATABASE_URL!) +export const db = drizzle(client) +``` + +## Complete Example: Database Add-on + +Here's how you could implement a configurable database add-on for Solid: + +### Examples + +Configuration in `info.json`: +```json +{ + "name": "Database Integration", + "description": "Add database support with configurable providers to your Solid application.", + "modes": ["file-router"], + "options": { + "database": { + "type": "select", + "label": "Database Provider", + "description": "Choose your database provider", + "default": "postgres", + "options": [ + { "value": "postgres", "label": "PostgreSQL" }, + { "value": "mysql", "label": "MySQL" }, + { "value": "sqlite", "label": "SQLite" } + ] + } + } +} +``` + +File structure: +``` +database/ +├── assets/ +│ ├── __postgres__db.config.ts.ejs +│ ├── __mysql__db.config.ts.ejs +│ ├── __sqlite__db.config.ts.ejs +│ └── src/ +│ ├── db/ +│ │ ├── __postgres__connection.ts.ejs +│ │ ├── __mysql__connection.ts.ejs +│ │ └── __sqlite__connection.ts.ejs +│ └── routes/ +│ └── demo.database.tsx.ejs +└── package.json.ejs +``` + +Code in `assets/__postgres__db.config.ts.ejs`: +```ejs +<% if (addOnOption.database.database !== 'postgres') { ignoreFile() } %> +export const dbConfig = { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME || 'myapp', + username: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || '', +} +``` + +Code in `assets/src/db/__postgres__connection.ts.ejs`: +```ejs +<% if (addOnOption.database.database !== 'postgres') { ignoreFile() } %> +import { Client } from 'pg' +import { dbConfig } from '../db.config' + +export const createConnection = () => { + const client = new Client(dbConfig) + return client +} +``` + +Code in `assets/src/routes/demo.database.tsx.ejs`: +```ejs +import { createSignal, onMount } from 'solid-js' + +export default function DatabaseDemo() { + const [status, setStatus] = createSignal('Connecting...') + + onMount(async () => { + try { + // Database-specific connection logic + <% if (addOnOption.database.database === 'postgres') { %> + const { createConnection } = await import('../db/connection') + const client = createConnection() + await client.connect() + setStatus('Connected to PostgreSQL!') + <% } else if (addOnOption.database.database === 'mysql') { %> + const { createConnection } = await import('../db/connection') + const connection = createConnection() + setStatus('Connected to MySQL!') + <% } else if (addOnOption.database.database === 'sqlite') { %> + setStatus('Connected to SQLite!') + <% } %> + } catch (error) { + setStatus(`Connection failed: ${error.message}`) + } + }) + + return ( +
+

Database Demo

+

Database Type: <%= addOnOption.database.database %>

+

Status: {status()}

+
+ ) +} +``` + +Code in `package.json.ejs`: +```ejs +{ + <% if (addOnOption.database.database === 'postgres') { %> + "pg": "^8.11.0", + "@types/pg": "^8.10.0" + <% } else if (addOnOption.database.database === 'mysql') { %> + "mysql2": "^3.6.0" + <% } else if (addOnOption.database.database === 'sqlite') { %> + "better-sqlite3": "^8.7.0", + "@types/better-sqlite3": "^7.6.0" + <% } %> +} +``` + +## CLI Usage + +### Interactive Mode +When using the CLI interactively, users are prompted for each option: + +```bash +create-tsrouter-app my-solid-app +# User selects database add-on +# CLI prompts: "Database Integration: Database Provider" with options +``` + +### Non-Interactive Mode +Options can be specified via JSON configuration: + +```bash +create-tsrouter-app my-solid-app --add-ons database --add-on-config '{"database":{"database":"mysql"}}' +``` + +## Best Practices + +1. **Use descriptive labels** - Make option purposes clear to users +2. **Provide sensible defaults** - Choose the most common use case +3. **Group related files** - Use consistent prefixing for option-specific files +4. **Document options** - Include descriptions to help users understand choices +5. **Test all combinations** - Ensure each option value generates working code +6. **Use validation** - The system validates options against the schema automatically +7. **Consider Solid patterns** - Use Solid-specific patterns like signals and resources +8. **Framework compatibility** - Ensure generated code works with Solid's reactivity system From 355402953d25fbc2c53678b606098afd1fdd351e Mon Sep 17 00:00:00 2001 From: timoconnellaus Date: Mon, 14 Jul 2025 12:37:25 +1000 Subject: [PATCH 8/9] add addon options to the mcp --- packages/cta-cli/src/mcp.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/cta-cli/src/mcp.ts b/packages/cta-cli/src/mcp.ts index 6d865edc..3b6679be 100644 --- a/packages/cta-cli/src/mcp.ts +++ b/packages/cta-cli/src/mcp.ts @@ -54,7 +54,10 @@ function createServer({ .filter((addOn) => addOn.modes.includes('file-router')) .map((addOn) => ({ id: addOn.id, + name: addOn.name, description: addOn.description, + options: addOn.options, + dependsOn: addOn.dependsOn, })), ), }, @@ -78,7 +81,8 @@ function createServer({ 'The package.json module name of the application (will also be the directory name)', ), cwd: z.string().describe('The directory to create the application in'), - addOns: z.array(z.string()).describe('The IDs of the add-ons to install'), + addOns: z.array(z.string()).describe('Array of add-on IDs to install. Use listTanStackAddOns tool to see available add-ons and their configuration options. Example: ["drizzle", "shadcn", "tanstack-query"]'), + addOnOptions: z.record(z.record(z.any())).optional().describe('Configuration options for add-ons. Format: {"addOnId": {"optionName": "value"}}. Use listTanStackAddOns to see available options for each add-on.'), targetDir: z .string() .describe( @@ -89,6 +93,7 @@ function createServer({ framework: frameworkName, projectName, addOns, + addOnOptions, cwd, targetDir, }) => { @@ -115,7 +120,7 @@ function createServer({ packageManager: 'pnpm', mode: 'file-router', chosenAddOns, - addOnOptions: populateAddOnOptionsDefaults(chosenAddOns), + addOnOptions: addOnOptions || populateAddOnOptionsDefaults(chosenAddOns), git: true, }) } catch (error) { From 45d105125990b03e8878118f51dab19c9b991fb7 Mon Sep 17 00:00:00 2001 From: timoconnellaus Date: Tue, 15 Jul 2025 12:42:02 +1000 Subject: [PATCH 9/9] switch to prisma --- frameworks/react-cra/ADD-ON-AUTHORING.md | 84 +++++---- .../assets/__mysql__drizzle.config.ts.ejs | 13 -- .../assets/__postgres__drizzle.config.ts.ejs | 13 -- .../assets/__sqlite__drizzle.config.ts.ejs | 13 -- .../drizzle/assets/_dot_env.local.append.ejs | 2 - .../assets/src/db/__mysql__index.ts.ejs | 7 - .../assets/src/db/__postgres__index.ts.ejs | 7 - .../assets/src/db/__sqlite__index.ts.ejs | 7 - .../drizzle/assets/src/db/schema.ts.ejs | 9 - .../assets/src/routes/demo.drizzle.tsx.ejs | 85 --------- .../react-cra/add-ons/drizzle/info.json | 30 ---- .../add-ons/drizzle/package.json.ejs | 19 -- .../react-cra/add-ons/drizzle/small-logo.svg | 7 - .../prisma/assets/__mysql__schema.prisma.ejs | 16 ++ .../assets/__postgres__schema.prisma.ejs | 16 ++ .../prisma/assets/__sqlite__schema.prisma.ejs | 16 ++ .../prisma/assets/_dot_env.local.append.ejs | 7 + .../assets/src/db/__mysql__index.ts.ejs | 11 ++ .../assets/src/db/__postgres__index.ts.ejs | 11 ++ .../assets/src/db/__sqlite__index.ts.ejs | 11 ++ .../assets/src/routes/demo.prisma.tsx.ejs | 168 ++++++++++++++++++ frameworks/react-cra/add-ons/prisma/info.json | 30 ++++ .../react-cra/add-ons/prisma/package.json.ejs | 20 +++ .../react-cra/add-ons/prisma/small-logo.svg | 5 + frameworks/solid/ADD-ON-AUTHORING.md | 96 +++++----- packages/cta-cli/src/mcp.ts | 2 +- packages/cta-engine/src/template-file.ts | 2 +- .../cta-engine/tests/add-on-options.test.ts | 90 +++++----- .../tests/conditional-packages.test.ts | 52 +++--- .../tests/filename-processing.test.ts | 44 ++--- .../cta-engine/tests/template-context.test.ts | 78 ++++---- 31 files changed, 551 insertions(+), 420 deletions(-) delete mode 100644 frameworks/react-cra/add-ons/drizzle/assets/__mysql__drizzle.config.ts.ejs delete mode 100644 frameworks/react-cra/add-ons/drizzle/assets/__postgres__drizzle.config.ts.ejs delete mode 100644 frameworks/react-cra/add-ons/drizzle/assets/__sqlite__drizzle.config.ts.ejs delete mode 100644 frameworks/react-cra/add-ons/drizzle/assets/_dot_env.local.append.ejs delete mode 100644 frameworks/react-cra/add-ons/drizzle/assets/src/db/__mysql__index.ts.ejs delete mode 100644 frameworks/react-cra/add-ons/drizzle/assets/src/db/__postgres__index.ts.ejs delete mode 100644 frameworks/react-cra/add-ons/drizzle/assets/src/db/__sqlite__index.ts.ejs delete mode 100644 frameworks/react-cra/add-ons/drizzle/assets/src/db/schema.ts.ejs delete mode 100644 frameworks/react-cra/add-ons/drizzle/assets/src/routes/demo.drizzle.tsx.ejs delete mode 100644 frameworks/react-cra/add-ons/drizzle/info.json delete mode 100644 frameworks/react-cra/add-ons/drizzle/package.json.ejs delete mode 100644 frameworks/react-cra/add-ons/drizzle/small-logo.svg create mode 100644 frameworks/react-cra/add-ons/prisma/assets/__mysql__schema.prisma.ejs create mode 100644 frameworks/react-cra/add-ons/prisma/assets/__postgres__schema.prisma.ejs create mode 100644 frameworks/react-cra/add-ons/prisma/assets/__sqlite__schema.prisma.ejs create mode 100644 frameworks/react-cra/add-ons/prisma/assets/_dot_env.local.append.ejs create mode 100644 frameworks/react-cra/add-ons/prisma/assets/src/db/__mysql__index.ts.ejs create mode 100644 frameworks/react-cra/add-ons/prisma/assets/src/db/__postgres__index.ts.ejs create mode 100644 frameworks/react-cra/add-ons/prisma/assets/src/db/__sqlite__index.ts.ejs create mode 100644 frameworks/react-cra/add-ons/prisma/assets/src/routes/demo.prisma.tsx.ejs create mode 100644 frameworks/react-cra/add-ons/prisma/info.json create mode 100644 frameworks/react-cra/add-ons/prisma/package.json.ejs create mode 100644 frameworks/react-cra/add-ons/prisma/small-logo.svg diff --git a/frameworks/react-cra/ADD-ON-AUTHORING.md b/frameworks/react-cra/ADD-ON-AUTHORING.md index d1a6bb11..3d3b61e7 100644 --- a/frameworks/react-cra/ADD-ON-AUTHORING.md +++ b/frameworks/react-cra/ADD-ON-AUTHORING.md @@ -252,9 +252,9 @@ Use filename prefixes to include files only when specific option values are sele ``` assets/ -├── __postgres__drizzle.config.ts.ejs -├── __mysql__drizzle.config.ts.ejs -├── __sqlite__drizzle.config.ts.ejs +├── __postgres__schema.prisma.ejs +├── __mysql__schema.prisma.ejs +├── __sqlite__schema.prisma.ejs └── src/ └── db/ ├── __postgres__index.ts.ejs @@ -272,25 +272,31 @@ assets/ Within template files, use `ignoreFile()` to skip file generation: ```ejs -<% if (addOnOption.drizzle.database !== 'postgres') { ignoreFile() } %> -import { drizzle } from 'drizzle-orm/postgres-js' -import postgres from 'postgres' +<% if (addOnOption.prisma.database !== 'postgres') { ignoreFile() } %> +import { PrismaClient } from '@prisma/client' -const client = postgres(process.env.DATABASE_URL!) -export const db = drizzle(client) +declare global { + var __prisma: PrismaClient | undefined +} + +export const prisma = globalThis.__prisma || new PrismaClient() + +if (process.env.NODE_ENV !== 'production') { + globalThis.__prisma = prisma +} ``` -## Complete Example: Drizzle Add-on +## Complete Example: Prisma Add-on -Here's how the Drizzle add-on implements configurable database support: +Here's how the Prisma add-on implements configurable database support: ### Examples Configuration in `info.json`: ```json { - "name": "Drizzle ORM", - "description": "Add Drizzle ORM with configurable database support to your application.", + "name": "Prisma ORM", + "description": "Add Prisma ORM with configurable database support to your application.", "options": { "database": { "type": "select", @@ -309,11 +315,11 @@ Configuration in `info.json`: File structure: ``` -drizzle/ +prisma/ ├── assets/ -│ ├── __postgres__drizzle.config.ts.ejs -│ ├── __mysql__drizzle.config.ts.ejs -│ ├── __sqlite__drizzle.config.ts.ejs +│ ├── __postgres__schema.prisma.ejs +│ ├── __mysql__schema.prisma.ejs +│ ├── __sqlite__schema.prisma.ejs │ └── src/ │ └── db/ │ ├── __postgres__index.ts.ejs @@ -322,31 +328,35 @@ drizzle/ └── package.json.ejs ``` -Code in `assets/__postgres__drizzle.config.ts.ejs`: +Code in `assets/__postgres__schema.prisma.ejs`: ```ejs -<% if (addOnOption.drizzle.database !== 'postgres') { ignoreFile() } %> -import { defineConfig } from 'drizzle-kit' - -export default defineConfig({ - schema: './src/db/schema.<%= js %>', - out: './src/db/migrations', - driver: 'pg', - dbCredentials: { - connectionString: process.env.DATABASE_URL!, - } -}) +<% if (addOnOption.prisma.database !== 'postgres') { ignoreFile() } %> +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} ``` Code in `package.json.ejs`: ```ejs { - <% if (addOnOption.drizzle.database === 'postgres') { %> + "prisma": "^5.8.0", + "@prisma/client": "^5.8.0"<% if (addOnOption.prisma.database === 'postgres') { %>, "pg": "^8.11.0", - "drizzle-orm": "^0.29.0" - <% } else if (addOnOption.drizzle.database === 'mysql') { %> - "mysql2": "^3.6.0", - "drizzle-orm": "^0.29.0" - <% } %> + "@types/pg": "^8.10.0"<% } else if (addOnOption.prisma.database === 'mysql') { %>, + "mysql2": "^3.6.0"<% } else if (addOnOption.prisma.database === 'sqlite') { %><% } %> } ``` @@ -357,15 +367,15 @@ When using the CLI interactively, users are prompted for each option: ```bash create-tsrouter-app my-app -# User selects Drizzle add-on -# CLI prompts: "Drizzle ORM: Database Provider" with options +# User selects Prisma add-on +# CLI prompts: "Prisma ORM: Database Provider" with options ``` ### Non-Interactive Mode Options can be specified via JSON configuration: ```bash -create-tsrouter-app my-app --add-ons drizzle --add-on-config '{"drizzle":{"database":"mysql"}}' +create-tsrouter-app my-app --add-ons prisma --add-on-config '{"prisma":{"database":"mysql"}}' ``` ## Best Practices diff --git a/frameworks/react-cra/add-ons/drizzle/assets/__mysql__drizzle.config.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/__mysql__drizzle.config.ts.ejs deleted file mode 100644 index e6a2c40c..00000000 --- a/frameworks/react-cra/add-ons/drizzle/assets/__mysql__drizzle.config.ts.ejs +++ /dev/null @@ -1,13 +0,0 @@ -<% if (addOnOption.drizzle.database !== 'mysql') { ignoreFile() } %> -import { defineConfig } from 'drizzle-kit' - -export default defineConfig({ - schema: './src/db/schema.<%= js %>', - out: './src/db/migrations', - driver: 'mysql2', - dbCredentials: { - connectionString: process.env.DATABASE_URL!, - }, - verbose: true, - strict: true, -}) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/__postgres__drizzle.config.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/__postgres__drizzle.config.ts.ejs deleted file mode 100644 index e09eefa6..00000000 --- a/frameworks/react-cra/add-ons/drizzle/assets/__postgres__drizzle.config.ts.ejs +++ /dev/null @@ -1,13 +0,0 @@ -<% if (addOnOption.drizzle.database !== 'postgres') { ignoreFile() } %> -import { defineConfig } from 'drizzle-kit' - -export default defineConfig({ - schema: './src/db/schema.<%= js %>', - out: './src/db/migrations', - driver: 'pg', - dbCredentials: { - connectionString: process.env.DATABASE_URL!, - }, - verbose: true, - strict: true, -}) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/__sqlite__drizzle.config.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/__sqlite__drizzle.config.ts.ejs deleted file mode 100644 index 09c60977..00000000 --- a/frameworks/react-cra/add-ons/drizzle/assets/__sqlite__drizzle.config.ts.ejs +++ /dev/null @@ -1,13 +0,0 @@ -<% if (addOnOption.drizzle.database !== 'sqlite') { ignoreFile() } %> -import { defineConfig } from 'drizzle-kit' - -export default defineConfig({ - schema: './src/db/schema.<%= js %>', - out: './src/db/migrations', - driver: 'better-sqlite', - dbCredentials: { - url: process.env.DATABASE_URL!, - }, - verbose: true, - strict: true, -}) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/_dot_env.local.append.ejs b/frameworks/react-cra/add-ons/drizzle/assets/_dot_env.local.append.ejs deleted file mode 100644 index c2624b30..00000000 --- a/frameworks/react-cra/add-ons/drizzle/assets/_dot_env.local.append.ejs +++ /dev/null @@ -1,2 +0,0 @@ -# Drizzle ORM Database Configuration -<% if (addOnOption.drizzle.database === 'postgres') { %>DATABASE_URL=postgresql://username:password@localhost:5432/database_name<% } else if (addOnOption.drizzle.database === 'mysql') { %>DATABASE_URL=mysql://username:password@localhost:3306/database_name<% } else { %>DATABASE_URL=file:./local.db<% } %> \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/src/db/__mysql__index.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/src/db/__mysql__index.ts.ejs deleted file mode 100644 index 5a8924e1..00000000 --- a/frameworks/react-cra/add-ons/drizzle/assets/src/db/__mysql__index.ts.ejs +++ /dev/null @@ -1,7 +0,0 @@ -<% if (addOnOption.drizzle.database !== 'mysql') { ignoreFile() } %> -import { drizzle } from 'drizzle-orm/mysql2' -import mysql from 'mysql2/promise' -import * as schema from './schema' - -const connection = mysql.createConnection(process.env.DATABASE_URL!) -export const db = drizzle(connection, { schema }) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/src/db/__postgres__index.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/src/db/__postgres__index.ts.ejs deleted file mode 100644 index 1852f3fb..00000000 --- a/frameworks/react-cra/add-ons/drizzle/assets/src/db/__postgres__index.ts.ejs +++ /dev/null @@ -1,7 +0,0 @@ -<% if (addOnOption.drizzle.database !== 'postgres') { ignoreFile() } %> -import { drizzle } from 'drizzle-orm/postgres-js' -import postgres from 'postgres' -import * as schema from './schema' - -const client = postgres(process.env.DATABASE_URL!) -export const db = drizzle(client, { schema }) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/src/db/__sqlite__index.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/src/db/__sqlite__index.ts.ejs deleted file mode 100644 index 538aa0c8..00000000 --- a/frameworks/react-cra/add-ons/drizzle/assets/src/db/__sqlite__index.ts.ejs +++ /dev/null @@ -1,7 +0,0 @@ -<% if (addOnOption.drizzle.database !== 'sqlite') { ignoreFile() } %> -import { drizzle } from 'drizzle-orm/better-sqlite3' -import Database from 'better-sqlite3' -import * as schema from './schema' - -const sqlite = new Database(process.env.DATABASE_URL!) -export const db = drizzle(sqlite, { schema }) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/src/db/schema.ts.ejs b/frameworks/react-cra/add-ons/drizzle/assets/src/db/schema.ts.ejs deleted file mode 100644 index 04d190c4..00000000 --- a/frameworks/react-cra/add-ons/drizzle/assets/src/db/schema.ts.ejs +++ /dev/null @@ -1,9 +0,0 @@ -import { <% if (addOnOption.drizzle.database === 'postgres') { %>pgTable, serial, text, timestamp<% } else if (addOnOption.drizzle.database === 'mysql') { %>mysqlTable, serial, varchar, timestamp<% } else { %>sqliteTable, integer, text<% } %> } from 'drizzle-orm/<% if (addOnOption.drizzle.database === 'postgres') { %>pg-core<% } else if (addOnOption.drizzle.database === 'mysql') { %>mysql-core<% } else { %>sqlite-core<% } %>' - -export const users = <% if (addOnOption.drizzle.database === 'postgres') { %>pgTable<% } else if (addOnOption.drizzle.database === 'mysql') { %>mysqlTable<% } else { %>sqliteTable<% } %>('users', { - id: <% if (addOnOption.drizzle.database === 'sqlite') { %>integer('id').primaryKey()<% } else { %>serial('id').primaryKey()<% } %>, - <% if (addOnOption.drizzle.database === 'mysql') { %>name: varchar('name', { length: 256 }), - email: varchar('email', { length: 256 }),<% } else { %>name: text('name'), - email: text('email'),<% } %> - <% if (addOnOption.drizzle.database === 'sqlite') { %>createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),<% } else { %>createdAt: timestamp('created_at').defaultNow(),<% } %> -}) \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/assets/src/routes/demo.drizzle.tsx.ejs b/frameworks/react-cra/add-ons/drizzle/assets/src/routes/demo.drizzle.tsx.ejs deleted file mode 100644 index 1baa6a4a..00000000 --- a/frameworks/react-cra/add-ons/drizzle/assets/src/routes/demo.drizzle.tsx.ejs +++ /dev/null @@ -1,85 +0,0 @@ -import { createFileRoute, useRouter } from '@tanstack/react-router' -import { createServerFn } from '@tanstack/react-start' -import { db } from '../db' -import { users } from '../db/schema' - -const getUsers = createServerFn({ - method: 'GET', -}).handler(async () => { - try { - const result = await db.select().from(users) - return result - } catch (error) { - console.error('Error loading users:', error) - return [] - } -}) - -const addUser = createServerFn({ method: 'POST' }) - .validator((data: { name: string; email: string }) => data) - .handler(async ({ data }) => { - try { - await db.insert(users).values({ - name: data.name, - email: data.email, - }) - } catch (error) { - console.error('Error adding user:', error) - throw error - } - }) - -export const Route = createFileRoute('/demo/drizzle')({ - component: DrizzleDemo, - loader: async () => await getUsers(), -}) - -function DrizzleDemo() { - const router = useRouter() - const userList = Route.useLoaderData() - - const handleAddUser = async () => { - const name = `User ${Date.now()}` - const email = `user${Date.now()}@example.com` - - try { - await addUser({ data: { name, email } }) - router.invalidate() - } catch (error) { - console.error('Error adding user:', error) - } - } - - return ( -
-

Drizzle ORM Demo

-

- Database: <%= addOnOption.drizzle.database %> -

- -
- -
- -
-

Users ({userList.length})

- {userList.length === 0 ? ( -

No users found. Click "Add User" to create one.

- ) : ( -
    - {userList.map((user) => ( -
  • - #{user.id} - {user.name} ({user.email}) -
  • - ))} -
- )} -
-
- ) -} \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/info.json b/frameworks/react-cra/add-ons/drizzle/info.json deleted file mode 100644 index 7d3468c2..00000000 --- a/frameworks/react-cra/add-ons/drizzle/info.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "Drizzle ORM", - "description": "Add Drizzle ORM with configurable database support to your application.", - "phase": "add-on", - "modes": ["file-router"], - "type": "add-on", - "link": "https://orm.drizzle.team", - "dependsOn": ["start"], - "options": { - "database": { - "type": "select", - "label": "Database Provider", - "description": "Choose your database provider", - "default": "postgres", - "options": [ - { "value": "postgres", "label": "PostgreSQL" }, - { "value": "mysql", "label": "MySQL" }, - { "value": "sqlite", "label": "SQLite" } - ] - } - }, - "routes": [ - { - "url": "/demo/drizzle", - "name": "Drizzle Demo", - "path": "src/routes/demo.drizzle.tsx", - "jsName": "DrizzleDemo" - } - ] -} \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/package.json.ejs b/frameworks/react-cra/add-ons/drizzle/package.json.ejs deleted file mode 100644 index 4050a733..00000000 --- a/frameworks/react-cra/add-ons/drizzle/package.json.ejs +++ /dev/null @@ -1,19 +0,0 @@ -{ - "dependencies": { - "drizzle-orm": "^0.29.0"<% if (addOnOption.drizzle.database === 'postgres') { %>, - "postgres": "^3.4.0"<% } %><% if (addOnOption.drizzle.database === 'mysql') { %>, - "mysql2": "^3.6.0"<% } %><% if (addOnOption.drizzle.database === 'sqlite') { %>, - "better-sqlite3": "^8.7.0"<% } %> - }, - "devDependencies": { - "drizzle-kit": "^0.20.0"<% if (addOnOption.drizzle.database === 'postgres') { %>, - "@types/postgres": "^3.0.0"<% } %><% if (addOnOption.drizzle.database === 'mysql') { %>, - "@types/mysql2": "^3.0.0"<% } %><% if (addOnOption.drizzle.database === 'sqlite') { %>, - "@types/better-sqlite3": "^7.6.0"<% } %> - }, - "scripts": { - "db:generate": "drizzle-kit generate:<% if (addOnOption.drizzle.database === 'postgres') { %>pg<% } else if (addOnOption.drizzle.database === 'mysql') { %>mysql<% } else { %>sqlite<% } %>", - "db:push": "drizzle-kit push:<% if (addOnOption.drizzle.database === 'postgres') { %>pg<% } else if (addOnOption.drizzle.database === 'mysql') { %>mysql<% } else { %>sqlite<% } %>", - "db:studio": "drizzle-kit studio" - } -} \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/drizzle/small-logo.svg b/frameworks/react-cra/add-ons/drizzle/small-logo.svg deleted file mode 100644 index 15c5c0ba..00000000 --- a/frameworks/react-cra/add-ons/drizzle/small-logo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frameworks/react-cra/add-ons/prisma/assets/__mysql__schema.prisma.ejs b/frameworks/react-cra/add-ons/prisma/assets/__mysql__schema.prisma.ejs new file mode 100644 index 00000000..db439719 --- /dev/null +++ b/frameworks/react-cra/add-ons/prisma/assets/__mysql__schema.prisma.ejs @@ -0,0 +1,16 @@ +<% if (addOnOption.prisma.database !== 'mysql') { ignoreFile() } %>generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/prisma/assets/__postgres__schema.prisma.ejs b/frameworks/react-cra/add-ons/prisma/assets/__postgres__schema.prisma.ejs new file mode 100644 index 00000000..816e2756 --- /dev/null +++ b/frameworks/react-cra/add-ons/prisma/assets/__postgres__schema.prisma.ejs @@ -0,0 +1,16 @@ +<% if (addOnOption.prisma.database !== 'postgres') { ignoreFile() } %>generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/prisma/assets/__sqlite__schema.prisma.ejs b/frameworks/react-cra/add-ons/prisma/assets/__sqlite__schema.prisma.ejs new file mode 100644 index 00000000..d67c7706 --- /dev/null +++ b/frameworks/react-cra/add-ons/prisma/assets/__sqlite__schema.prisma.ejs @@ -0,0 +1,16 @@ +<% if (addOnOption.prisma.database !== 'sqlite') { ignoreFile() } %>generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/prisma/assets/_dot_env.local.append.ejs b/frameworks/react-cra/add-ons/prisma/assets/_dot_env.local.append.ejs new file mode 100644 index 00000000..75802e04 --- /dev/null +++ b/frameworks/react-cra/add-ons/prisma/assets/_dot_env.local.append.ejs @@ -0,0 +1,7 @@ +<% if (addOnOption.prisma.database === 'postgres') { %> +# Database URL for PostgreSQL +DATABASE_URL="postgresql://username:password@localhost:5432/mydb"<% } else if (addOnOption.prisma.database === 'mysql') { %> +# Database URL for MySQL +DATABASE_URL="mysql://username:password@localhost:3306/mydb"<% } else if (addOnOption.prisma.database === 'sqlite') { %> +# Database URL for SQLite +DATABASE_URL="file:./dev.db"<% } %> \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/prisma/assets/src/db/__mysql__index.ts.ejs b/frameworks/react-cra/add-ons/prisma/assets/src/db/__mysql__index.ts.ejs new file mode 100644 index 00000000..cb00d6fb --- /dev/null +++ b/frameworks/react-cra/add-ons/prisma/assets/src/db/__mysql__index.ts.ejs @@ -0,0 +1,11 @@ +<% if (addOnOption.prisma.database !== 'mysql') { ignoreFile() } %>import { PrismaClient } from '@prisma/client' + +declare global { + var __prisma: PrismaClient | undefined +} + +export const prisma = globalThis.__prisma || new PrismaClient() + +if (process.env.NODE_ENV !== 'production') { + globalThis.__prisma = prisma +} \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/prisma/assets/src/db/__postgres__index.ts.ejs b/frameworks/react-cra/add-ons/prisma/assets/src/db/__postgres__index.ts.ejs new file mode 100644 index 00000000..1f15b8b3 --- /dev/null +++ b/frameworks/react-cra/add-ons/prisma/assets/src/db/__postgres__index.ts.ejs @@ -0,0 +1,11 @@ +<% if (addOnOption.prisma.database !== 'postgres') { ignoreFile() } %>import { PrismaClient } from '@prisma/client' + +declare global { + var __prisma: PrismaClient | undefined +} + +export const prisma = globalThis.__prisma || new PrismaClient() + +if (process.env.NODE_ENV !== 'production') { + globalThis.__prisma = prisma +} \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/prisma/assets/src/db/__sqlite__index.ts.ejs b/frameworks/react-cra/add-ons/prisma/assets/src/db/__sqlite__index.ts.ejs new file mode 100644 index 00000000..40f81775 --- /dev/null +++ b/frameworks/react-cra/add-ons/prisma/assets/src/db/__sqlite__index.ts.ejs @@ -0,0 +1,11 @@ +<% if (addOnOption.prisma.database !== 'sqlite') { ignoreFile() } %>import { PrismaClient } from '@prisma/client' + +declare global { + var __prisma: PrismaClient | undefined +} + +export const prisma = globalThis.__prisma || new PrismaClient() + +if (process.env.NODE_ENV !== 'production') { + globalThis.__prisma = prisma +} \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/prisma/assets/src/routes/demo.prisma.tsx.ejs b/frameworks/react-cra/add-ons/prisma/assets/src/routes/demo.prisma.tsx.ejs new file mode 100644 index 00000000..4674eec8 --- /dev/null +++ b/frameworks/react-cra/add-ons/prisma/assets/src/routes/demo.prisma.tsx.ejs @@ -0,0 +1,168 @@ +import { <% if (fileRouter) { %>createFileRoute<% } else { %>createRoute<% } %>, useRouter } from '@tanstack/react-router' +import { createServerFn } from "@tanstack/react-start"; +import { prisma } from '../db' +<% if (codeRouter) { %> +import { useState, useEffect } from 'react' +import type { RootRoute } from '@tanstack/react-router' +<% } %> + +const createUser = createServerFn({ + method: 'POST', +}) + .validator((userData: { name: string; email: string }) => userData) + .handler(async ({ data }) => { + return await prisma.user.create({ + data, + }) + }) + +const getUsers = createServerFn({ + method: 'GET', +}).handler(async () => { + return await prisma.user.findMany({ + orderBy: { createdAt: 'desc' }, + }) +}) + +<% if (fileRouter) { %> +export const Route = createFileRoute('/demo/prisma')({ + component: DemoPrisma, + loader: async () => await getUsers(), +}) +<% } %> + +function DemoPrisma() { + const router = useRouter() + <% if (fileRouter) { %> + const users = Route.useLoaderData() + <% } else { %> + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(false) + + const loadUsers = async () => { + try { + const data = await getUsers() + setUsers(data) + } catch (error) { + console.error('Failed to load users:', error) + } + } + + useEffect(() => { + loadUsers() + }, []) + <% } %> + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + const formData = new FormData(e.target as HTMLFormElement) + const name = formData.get('name') as string + const email = formData.get('email') as string + + if (!name || !email) return + + try { + await createUser({ data: { name, email } }) + <% if (fileRouter) { %> + router.invalidate() + <% } else { %> + await loadUsers() + <% } %> + ;(e.target as HTMLFormElement).reset() + } catch (error) { + console.error('Failed to create user:', error) + } + } + + return ( +
+

Prisma Demo

+ +
+

Add New User

+
+
+ + +
+
+ + +
+ +
+
+ +
+

Users

+
+
    + {users.map((user) => ( +
  • +
    +
    +

    {user.name}

    +

    {user.email}

    +
    +
    + {new Date(user.createdAt).toLocaleDateString()} +
    +
    +
  • + ))} + {users.length === 0 && ( +
  • + No users found. Create one above! +
  • + )} +
+
+
+ +
+

Database: <%= addOnOption.prisma.database %>

+

+ This demo shows basic CRUD operations using Prisma ORM with <%= addOnOption.prisma.database === 'postgres' ? 'PostgreSQL' : addOnOption.prisma.database === 'mysql' ? 'MySQL' : 'SQLite' %>. +

+
+

Setup Instructions:

+
    +
  1. Configure your DATABASE_URL in .env.local
  2. +
  3. Run: npx prisma generate
  4. +
  5. Run: npx prisma db push
  6. +
  7. Optional: npx prisma studio
  8. +
+
+
+
+ ) +} + +<% if (codeRouter) { %> +export default (parentRoute: RootRoute) => createRoute({ + path: '/demo/prisma', + component: DemoPrisma, + getParentRoute: () => parentRoute, +}) +<% } %> \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/prisma/info.json b/frameworks/react-cra/add-ons/prisma/info.json new file mode 100644 index 00000000..1d3c7fa8 --- /dev/null +++ b/frameworks/react-cra/add-ons/prisma/info.json @@ -0,0 +1,30 @@ +{ + "name": "Prisma ORM", + "description": "Add Prisma ORM with configurable database support to your application.", + "phase": "add-on", + "type": "add-on", + "link": "https://www.prisma.io/", + "modes": ["file-router", "code-router"], + "dependsOn": ["start"], + "options": { + "database": { + "type": "select", + "label": "Database Provider", + "description": "Choose your database provider", + "default": "sqlite", + "options": [ + { "value": "sqlite", "label": "SQLite" }, + { "value": "postgres", "label": "PostgreSQL" }, + { "value": "mysql", "label": "MySQL" } + ] + } + }, + "routes": [ + { + "url": "/demo/prisma", + "name": "Prisma", + "path": "src/routes/demo.prisma.tsx", + "jsName": "DemoPrisma" + } + ] +} diff --git a/frameworks/react-cra/add-ons/prisma/package.json.ejs b/frameworks/react-cra/add-ons/prisma/package.json.ejs new file mode 100644 index 00000000..642b610b --- /dev/null +++ b/frameworks/react-cra/add-ons/prisma/package.json.ejs @@ -0,0 +1,20 @@ +{ + "dependencies": { + "prisma": "^5.8.0", + "@prisma/client": "^5.8.0"<% if (addOnOption.prisma.database === 'postgres') { %>, + "pg": "^8.11.0"<% } %><% if (addOnOption.prisma.database === 'mysql') { %>, + "mysql2": "^3.6.0"<% } %> + }, + "devDependencies": {<% if (addOnOption.prisma.database === 'postgres') { %> + "@types/pg": "^8.10.0"<% } %><% if (addOnOption.prisma.database === 'mysql') { %> + "@types/mysql2": "^3.6.0"<% } %><% if (addOnOption.prisma.database === 'sqlite') { %> + "@types/better-sqlite3": "^7.6.0"<% } %> + }, + "scripts": { + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:studio": "prisma studio", + "db:seed": "prisma db seed" + } +} \ No newline at end of file diff --git a/frameworks/react-cra/add-ons/prisma/small-logo.svg b/frameworks/react-cra/add-ons/prisma/small-logo.svg new file mode 100644 index 00000000..5e765082 --- /dev/null +++ b/frameworks/react-cra/add-ons/prisma/small-logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frameworks/solid/ADD-ON-AUTHORING.md b/frameworks/solid/ADD-ON-AUTHORING.md index 90f67f0f..2258e803 100644 --- a/frameworks/solid/ADD-ON-AUTHORING.md +++ b/frameworks/solid/ADD-ON-AUTHORING.md @@ -212,9 +212,9 @@ Use filename prefixes to include files only when specific option values are sele ``` assets/ -├── __postgres__drizzle.config.ts.ejs -├── __mysql__drizzle.config.ts.ejs -├── __sqlite__drizzle.config.ts.ejs +├── __postgres__schema.prisma.ejs +├── __mysql__schema.prisma.ejs +├── __sqlite__schema.prisma.ejs └── src/ └── db/ ├── __postgres__index.ts.ejs @@ -232,12 +232,18 @@ assets/ Within template files, use `ignoreFile()` to skip file generation: ```ejs -<% if (addOnOption.drizzle.database !== 'postgres') { ignoreFile() } %> -import { drizzle } from 'drizzle-orm/postgres-js' -import postgres from 'postgres' +<% if (addOnOption.database.database !== 'postgres') { ignoreFile() } %> +import { PrismaClient } from '@prisma/client' + +declare global { + var __prisma: PrismaClient | undefined +} -const client = postgres(process.env.DATABASE_URL!) -export const db = drizzle(client) +export const prisma = globalThis.__prisma || new PrismaClient() + +if (process.env.NODE_ENV !== 'production') { + globalThis.__prisma = prisma +} ``` ## Complete Example: Database Add-on @@ -272,40 +278,53 @@ File structure: ``` database/ ├── assets/ -│ ├── __postgres__db.config.ts.ejs -│ ├── __mysql__db.config.ts.ejs -│ ├── __sqlite__db.config.ts.ejs +│ ├── __postgres__schema.prisma.ejs +│ ├── __mysql__schema.prisma.ejs +│ ├── __sqlite__schema.prisma.ejs │ └── src/ │ ├── db/ -│ │ ├── __postgres__connection.ts.ejs -│ │ ├── __mysql__connection.ts.ejs -│ │ └── __sqlite__connection.ts.ejs +│ │ ├── __postgres__index.ts.ejs +│ │ ├── __mysql__index.ts.ejs +│ │ └── __sqlite__index.ts.ejs │ └── routes/ │ └── demo.database.tsx.ejs └── package.json.ejs ``` -Code in `assets/__postgres__db.config.ts.ejs`: +Code in `assets/__postgres__schema.prisma.ejs`: ```ejs <% if (addOnOption.database.database !== 'postgres') { ignoreFile() } %> -export const dbConfig = { - host: process.env.DB_HOST || 'localhost', - port: parseInt(process.env.DB_PORT || '5432'), - database: process.env.DB_NAME || 'myapp', - username: process.env.DB_USER || 'postgres', - password: process.env.DB_PASSWORD || '', +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } ``` -Code in `assets/src/db/__postgres__connection.ts.ejs`: +Code in `assets/src/db/__postgres__index.ts.ejs`: ```ejs <% if (addOnOption.database.database !== 'postgres') { ignoreFile() } %> -import { Client } from 'pg' -import { dbConfig } from '../db.config' +import { PrismaClient } from '@prisma/client' + +declare global { + var __prisma: PrismaClient | undefined +} + +export const prisma = globalThis.__prisma || new PrismaClient() -export const createConnection = () => { - const client = new Client(dbConfig) - return client +if (process.env.NODE_ENV !== 'production') { + globalThis.__prisma = prisma } ``` @@ -320,15 +339,16 @@ export default function DatabaseDemo() { try { // Database-specific connection logic <% if (addOnOption.database.database === 'postgres') { %> - const { createConnection } = await import('../db/connection') - const client = createConnection() - await client.connect() + const { prisma } = await import('../db') + await prisma.$connect() setStatus('Connected to PostgreSQL!') <% } else if (addOnOption.database.database === 'mysql') { %> - const { createConnection } = await import('../db/connection') - const connection = createConnection() + const { prisma } = await import('../db') + await prisma.$connect() setStatus('Connected to MySQL!') <% } else if (addOnOption.database.database === 'sqlite') { %> + const { prisma } = await import('../db') + await prisma.$connect() setStatus('Connected to SQLite!') <% } %> } catch (error) { @@ -349,15 +369,11 @@ export default function DatabaseDemo() { Code in `package.json.ejs`: ```ejs { - <% if (addOnOption.database.database === 'postgres') { %> + "prisma": "^5.8.0", + "@prisma/client": "^5.8.0"<% if (addOnOption.database.database === 'postgres') { %>, "pg": "^8.11.0", - "@types/pg": "^8.10.0" - <% } else if (addOnOption.database.database === 'mysql') { %> - "mysql2": "^3.6.0" - <% } else if (addOnOption.database.database === 'sqlite') { %> - "better-sqlite3": "^8.7.0", - "@types/better-sqlite3": "^7.6.0" - <% } %> + "@types/pg": "^8.10.0"<% } else if (addOnOption.database.database === 'mysql') { %>, + "mysql2": "^3.6.0"<% } else if (addOnOption.database.database === 'sqlite') { %><% } %> } ``` diff --git a/packages/cta-cli/src/mcp.ts b/packages/cta-cli/src/mcp.ts index 3b6679be..df834f0b 100644 --- a/packages/cta-cli/src/mcp.ts +++ b/packages/cta-cli/src/mcp.ts @@ -81,7 +81,7 @@ function createServer({ 'The package.json module name of the application (will also be the directory name)', ), cwd: z.string().describe('The directory to create the application in'), - addOns: z.array(z.string()).describe('Array of add-on IDs to install. Use listTanStackAddOns tool to see available add-ons and their configuration options. Example: ["drizzle", "shadcn", "tanstack-query"]'), + addOns: z.array(z.string()).describe('Array of add-on IDs to install. Use listTanStackAddOns tool to see available add-ons and their configuration options. Example: ["prisma", "shadcn", "tanstack-query"]'), addOnOptions: z.record(z.record(z.any())).optional().describe('Configuration options for add-ons. Format: {"addOnId": {"optionName": "value"}}. Use listTanStackAddOns to see available options for each add-on.'), targetDir: z .string() diff --git a/packages/cta-engine/src/template-file.ts b/packages/cta-engine/src/template-file.ts index d1a15467..55764525 100644 --- a/packages/cta-engine/src/template-file.ts +++ b/packages/cta-engine/src/template-file.ts @@ -122,7 +122,7 @@ export function createTemplateFile(environment: Environment, options: Options) { let target = convertDotFilesAndPaths(file.replace('.ejs', '')) - // Strip option prefixes from filename (e.g., __postgres__drizzle.config.ts -> drizzle.config.ts) + // Strip option prefixes from filename (e.g., __postgres__schema.prisma -> schema.prisma) const prefixMatch = target.match(/^(.+\/)?__([^_]+)__(.+)$/) if (prefixMatch) { const [, directory, , filename] = prefixMatch diff --git a/packages/cta-engine/tests/add-on-options.test.ts b/packages/cta-engine/tests/add-on-options.test.ts index d439747e..2f96f530 100644 --- a/packages/cta-engine/tests/add-on-options.test.ts +++ b/packages/cta-engine/tests/add-on-options.test.ts @@ -1,7 +1,25 @@ -import { describe, it, expect } from 'vitest' -import { z } from 'zod' -import { AddOnSelectOptionSchema, AddOnOptionSchema, AddOnOptionsSchema } from '../src/types.js' +import { describe, expect, it } from 'vitest' import { populateAddOnOptionsDefaults } from '../src/add-ons.js' +import { AddOnOptionSchema, AddOnOptionsSchema, AddOnSelectOptionSchema } from '../src/types.js' +import type { AddOn } from '../src/types.js' + +// Helper function to create test AddOn objects +function createTestAddOn(overrides: Partial): AddOn { + return { + id: 'test-addon', + name: 'Test Addon', + description: 'Test addon description', + type: 'add-on', + modes: ['file-router'], + phase: 'add-on', + files: {}, + deletedFiles: [], + getFiles: () => Promise.resolve([]), + getFileContents: () => Promise.resolve(''), + getDeletedFiles: () => Promise.resolve([]), + ...overrides + } +} describe('Add-on Options', () => { describe('Option Schema Validation', () => { @@ -96,9 +114,9 @@ describe('Add-on Options', () => { describe('populateAddOnOptionsDefaults', () => { it('should populate defaults for add-ons with options', () => { const addOns = [ - { - id: 'drizzle', - name: 'Drizzle ORM', + createTestAddOn({ + id: 'testAddon', + name: 'Test Addon', options: { database: { type: 'select' as const, @@ -111,8 +129,8 @@ describe('Add-on Options', () => { ] } } - }, - { + }), + createTestAddOn({ id: 'shadcn', name: 'shadcn/ui', options: { @@ -126,13 +144,13 @@ describe('Add-on Options', () => { ] } } - } + }) ] const result = populateAddOnOptionsDefaults(addOns) expect(result).toEqual({ - drizzle: { + testAddon: { database: 'postgres' }, shadcn: { @@ -143,11 +161,11 @@ describe('Add-on Options', () => { it('should handle add-ons without options', () => { const addOns = [ - { + createTestAddOn({ id: 'simple-addon', name: 'Simple Add-on' // No options property - } + }) ] const result = populateAddOnOptionsDefaults(addOns) @@ -157,9 +175,9 @@ describe('Add-on Options', () => { it('should only populate defaults for enabled add-ons', () => { const addOns = [ - { - id: 'drizzle', - name: 'Drizzle ORM', + createTestAddOn({ + id: 'testAddon', + name: 'Test Addon', options: { database: { type: 'select' as const, @@ -171,8 +189,8 @@ describe('Add-on Options', () => { ] } } - }, - { + }), + createTestAddOn({ id: 'shadcn', name: 'shadcn/ui', options: { @@ -186,14 +204,14 @@ describe('Add-on Options', () => { ] } } - } + }) ] - const enabledAddOns = [addOns[0]] // Only drizzle + const enabledAddOns = [addOns[0]] // Only testAddon const result = populateAddOnOptionsDefaults(enabledAddOns) expect(result).toEqual({ - drizzle: { + testAddon: { database: 'postgres' } // shadcn should not be included @@ -201,23 +219,6 @@ describe('Add-on Options', () => { }) it('should handle empty enabled add-ons array', () => { - const addOns = [ - { - id: 'drizzle', - name: 'Drizzle ORM', - options: { - database: { - type: 'select' as const, - label: 'Database Provider', - default: 'postgres', - options: [ - { value: 'postgres', label: 'PostgreSQL' } - ] - } - } - } - ] - const enabledAddOns: Array = [] const result = populateAddOnOptionsDefaults(enabledAddOns) @@ -226,7 +227,7 @@ describe('Add-on Options', () => { it('should handle add-ons with multiple options', () => { const addOns = [ - { + createTestAddOn({ id: 'complex-addon', name: 'Complex Add-on', options: { @@ -249,7 +250,7 @@ describe('Add-on Options', () => { ] } } - } + }) ] const result = populateAddOnOptionsDefaults(addOns) @@ -264,23 +265,28 @@ describe('Add-on Options', () => { it('should handle options without default values', () => { const addOns = [ - { + createTestAddOn({ id: 'no-default', name: 'No Default Add-on', options: { database: { type: 'select' as const, label: 'Database', - // No default property + default: 'postgres', // We need a default for valid schema options: [ { value: 'postgres', label: 'PostgreSQL' }, { value: 'mysql', label: 'MySQL' } ] } } - } + }) ] + // Test the case where an addon has no default by manually modifying the option + if (addOns[0].options?.database) { + delete (addOns[0].options.database as any).default + } + const result = populateAddOnOptionsDefaults(addOns) expect(result).toEqual({ diff --git a/packages/cta-engine/tests/conditional-packages.test.ts b/packages/cta-engine/tests/conditional-packages.test.ts index 34dcfb4c..6d2710b9 100644 --- a/packages/cta-engine/tests/conditional-packages.test.ts +++ b/packages/cta-engine/tests/conditional-packages.test.ts @@ -29,25 +29,25 @@ describe('Conditional Package Dependencies', () => { ...baseOptions, chosenAddOns: [ { - id: 'drizzle', + id: 'testAddon', name: 'Drizzle ORM', packageTemplate: `{ "dependencies": { - "drizzle-orm": "^0.29.0"<% if (addOnOption.drizzle.database === 'postgres') { %>, - "postgres": "^3.4.0"<% } %><% if (addOnOption.drizzle.database === 'mysql') { %>, - "mysql2": "^3.6.0"<% } %><% if (addOnOption.drizzle.database === 'sqlite') { %>, + "testAddon-orm": "^0.29.0"<% if (addOnOption.testAddon.database === 'postgres') { %>, + "postgres": "^3.4.0"<% } %><% if (addOnOption.testAddon.database === 'mysql') { %>, + "mysql2": "^3.6.0"<% } %><% if (addOnOption.testAddon.database === 'sqlite') { %>, "better-sqlite3": "^8.7.0"<% } %> }, - "devDependencies": {<% if (addOnOption.drizzle.database === 'postgres') { %> - "@types/postgres": "^3.0.0"<% } %><% if (addOnOption.drizzle.database === 'mysql') { %> - "@types/mysql2": "^3.0.0"<% } %><% if (addOnOption.drizzle.database === 'sqlite') { %> + "devDependencies": {<% if (addOnOption.testAddon.database === 'postgres') { %> + "@types/postgres": "^3.0.0"<% } %><% if (addOnOption.testAddon.database === 'mysql') { %> + "@types/mysql2": "^3.0.0"<% } %><% if (addOnOption.testAddon.database === 'sqlite') { %> "@types/better-sqlite3": "^7.6.0"<% } %> } }` } ], addOnOptions: { - drizzle: { + testAddon: { database: 'postgres' } } @@ -56,7 +56,7 @@ describe('Conditional Package Dependencies', () => { const packageJSON = createPackageJSON(options) expect(packageJSON.dependencies).toEqual({ - 'drizzle-orm': '^0.29.0', + 'testAddon-orm': '^0.29.0', 'postgres': '^3.4.0' }) expect(packageJSON.devDependencies).toEqual({ @@ -74,20 +74,20 @@ describe('Conditional Package Dependencies', () => { ...baseOptions, chosenAddOns: [ { - id: 'drizzle', + id: 'testAddon', name: 'Drizzle ORM', packageTemplate: `{ "dependencies": { - "drizzle-orm": "^0.29.0"<% if (addOnOption.drizzle.database === 'postgres') { %>, - "postgres": "^3.4.0"<% } %><% if (addOnOption.drizzle.database === 'mysql') { %>, - "mysql2": "^3.6.0"<% } %><% if (addOnOption.drizzle.database === 'sqlite') { %>, + "testAddon-orm": "^0.29.0"<% if (addOnOption.testAddon.database === 'postgres') { %>, + "postgres": "^3.4.0"<% } %><% if (addOnOption.testAddon.database === 'mysql') { %>, + "mysql2": "^3.6.0"<% } %><% if (addOnOption.testAddon.database === 'sqlite') { %>, "better-sqlite3": "^8.7.0"<% } %> } }` } ], addOnOptions: { - drizzle: { + testAddon: { database: 'mysql' } } @@ -96,7 +96,7 @@ describe('Conditional Package Dependencies', () => { const packageJSON = createPackageJSON(options) expect(packageJSON.dependencies).toEqual({ - 'drizzle-orm': '^0.29.0', + 'testAddon-orm': '^0.29.0', 'mysql2': '^3.6.0' }) // PostgreSQL and SQLite dependencies should not be included @@ -109,23 +109,23 @@ describe('Conditional Package Dependencies', () => { ...baseOptions, chosenAddOns: [ { - id: 'drizzle', + id: 'testAddon', name: 'Drizzle ORM', packageTemplate: `{ "dependencies": { - "drizzle-orm": "^0.29.0"<% if (addOnOption.drizzle.database === 'postgres') { %>, - "postgres": "^3.4.0"<% } %><% if (addOnOption.drizzle.database === 'mysql') { %>, - "mysql2": "^3.6.0"<% } %><% if (addOnOption.drizzle.database === 'sqlite') { %>, + "testAddon-orm": "^0.29.0"<% if (addOnOption.testAddon.database === 'postgres') { %>, + "postgres": "^3.4.0"<% } %><% if (addOnOption.testAddon.database === 'mysql') { %>, + "mysql2": "^3.6.0"<% } %><% if (addOnOption.testAddon.database === 'sqlite') { %>, "better-sqlite3": "^8.7.0"<% } %> }, - "devDependencies": {<% if (addOnOption.drizzle.database === 'sqlite') { %> + "devDependencies": {<% if (addOnOption.testAddon.database === 'sqlite') { %> "@types/better-sqlite3": "^7.6.0"<% } %> } }` } ], addOnOptions: { - drizzle: { + testAddon: { database: 'sqlite' } } @@ -134,7 +134,7 @@ describe('Conditional Package Dependencies', () => { const packageJSON = createPackageJSON(options) expect(packageJSON.dependencies).toEqual({ - 'drizzle-orm': '^0.29.0', + 'testAddon-orm': '^0.29.0', 'better-sqlite3': '^8.7.0' }) expect(packageJSON.devDependencies).toEqual({ @@ -147,11 +147,11 @@ describe('Conditional Package Dependencies', () => { ...baseOptions, chosenAddOns: [ { - id: 'drizzle', + id: 'testAddon', name: 'Drizzle ORM', packageTemplate: `{ "dependencies": { - "drizzle-orm": "^0.29.0"<% if (addOnOption.drizzle.database === 'postgres') { %>, + "testAddon-orm": "^0.29.0"<% if (addOnOption.testAddon.database === 'postgres') { %>, "postgres": "^3.4.0"<% } %> } }` @@ -168,7 +168,7 @@ describe('Conditional Package Dependencies', () => { } ], addOnOptions: { - drizzle: { + testAddon: { database: 'postgres' }, auth: { @@ -180,7 +180,7 @@ describe('Conditional Package Dependencies', () => { const packageJSON = createPackageJSON(options) expect(packageJSON.dependencies).toEqual({ - 'drizzle-orm': '^0.29.0', + 'testAddon-orm': '^0.29.0', 'postgres': '^3.4.0', '@auth0/nextjs-auth0': '^3.0.0' }) diff --git a/packages/cta-engine/tests/filename-processing.test.ts b/packages/cta-engine/tests/filename-processing.test.ts index 856c05dc..589a5139 100644 --- a/packages/cta-engine/tests/filename-processing.test.ts +++ b/packages/cta-engine/tests/filename-processing.test.ts @@ -26,22 +26,22 @@ describe('Filename Processing - Prefix Stripping', () => { const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { database: 'postgres' } + testAddon: { database: 'postgres' } } }) environment.startRun() await templateFile( - './__postgres__drizzle.config.ts.ejs', - '<% if (addOnOption.drizzle.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL config\nexport default { driver: "postgres" }' + './__postgres__testAddon.config.ts.ejs', + '<% if (addOnOption.testAddon.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL config\nexport default { driver: "postgres" }' ) environment.finishRun() // File should be created with prefix stripped - expect(output.files['/test/drizzle.config.ts']).toBeDefined() - expect(output.files['/test/drizzle.config.ts'].trim()).toEqual('// PostgreSQL config\nexport default { driver: \'postgres\' }') + expect(output.files['/test/testAddon.config.ts']).toBeDefined() + expect(output.files['/test/testAddon.config.ts'].trim()).toEqual('// PostgreSQL config\nexport default { driver: \'postgres\' }') // Original prefixed filename should not exist - expect(output.files['/test/__postgres__drizzle.config.ts']).toBeUndefined() + expect(output.files['/test/__postgres__testAddon.config.ts']).toBeUndefined() }) it('should strip prefix from nested directory paths', async () => { @@ -49,13 +49,13 @@ describe('Filename Processing - Prefix Stripping', () => { const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { database: 'mysql' } + testAddon: { database: 'mysql' } } }) environment.startRun() await templateFile( './src/db/__mysql__connection.ts.ejs', - '<% if (addOnOption.drizzle.database !== "mysql") { ignoreFile() } %>\n// MySQL connection\nexport const connection = "mysql"' + '<% if (addOnOption.testAddon.database !== "mysql") { ignoreFile() } %>\n// MySQL connection\nexport const connection = "mysql"' ) environment.finishRun() @@ -72,32 +72,32 @@ describe('Filename Processing - Prefix Stripping', () => { const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { database: 'sqlite' } + testAddon: { database: 'sqlite' } } }) environment.startRun() await templateFile( - './__postgres__drizzle.config.ts.ejs', - '<% if (addOnOption.drizzle.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL config' + './__postgres__testAddon.config.ts.ejs', + '<% if (addOnOption.testAddon.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL config' ) await templateFile( - './__mysql__drizzle.config.ts.ejs', - '<% if (addOnOption.drizzle.database !== "mysql") { ignoreFile() } %>\n// MySQL config' + './__mysql__testAddon.config.ts.ejs', + '<% if (addOnOption.testAddon.database !== "mysql") { ignoreFile() } %>\n// MySQL config' ) await templateFile( - './__sqlite__drizzle.config.ts.ejs', - '<% if (addOnOption.drizzle.database !== "sqlite") { ignoreFile() } %>\n// SQLite config' + './__sqlite__testAddon.config.ts.ejs', + '<% if (addOnOption.testAddon.database !== "sqlite") { ignoreFile() } %>\n// SQLite config' ) environment.finishRun() // Only SQLite file should exist (others ignored via ignoreFile()) - expect(output.files['/test/drizzle.config.ts']).toBeDefined() - expect(output.files['/test/drizzle.config.ts'].trim()).toEqual('// SQLite config') + expect(output.files['/test/testAddon.config.ts']).toBeDefined() + expect(output.files['/test/testAddon.config.ts'].trim()).toEqual('// SQLite config') // Prefixed filenames should not exist - expect(output.files['/test/__postgres__drizzle.config.ts']).toBeUndefined() - expect(output.files['/test/__mysql__drizzle.config.ts']).toBeUndefined() - expect(output.files['/test/__sqlite__drizzle.config.ts']).toBeUndefined() + expect(output.files['/test/__postgres__testAddon.config.ts']).toBeUndefined() + expect(output.files['/test/__mysql__testAddon.config.ts']).toBeUndefined() + expect(output.files['/test/__sqlite__testAddon.config.ts']).toBeUndefined() }) it('should handle complex filename patterns', async () => { @@ -125,7 +125,7 @@ describe('Filename Processing - Prefix Stripping', () => { const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { database: 'postgres' } + testAddon: { database: 'postgres' } } }) environment.startRun() @@ -137,7 +137,7 @@ describe('Filename Processing - Prefix Stripping', () => { // Then append with prefixed filename await templateFile( './__postgres__.env.append.ejs', - '<% if (addOnOption.drizzle.database !== "postgres") { ignoreFile() } %>\nDATABASE_URL=postgresql://localhost:5432/mydb\n' + '<% if (addOnOption.testAddon.database !== "postgres") { ignoreFile() } %>\nDATABASE_URL=postgresql://localhost:5432/mydb\n' ) environment.finishRun() diff --git a/packages/cta-engine/tests/template-context.test.ts b/packages/cta-engine/tests/template-context.test.ts index 813456b6..c6386805 100644 --- a/packages/cta-engine/tests/template-context.test.ts +++ b/packages/cta-engine/tests/template-context.test.ts @@ -26,13 +26,13 @@ describe('Template Context - Add-on Options', () => { const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { + testAddon: { database: 'postgres' } } }) environment.startRun() - await templateFile('./test.txt.ejs', 'Database: <%= addOnOption.drizzle.database %>') + await templateFile('./test.txt.ejs', 'Database: <%= addOnOption.testAddon.database %>') environment.finishRun() expect(output.files['/test/test.txt']).toEqual('Database: postgres') @@ -43,7 +43,7 @@ describe('Template Context - Add-on Options', () => { const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { + testAddon: { database: 'mysql' }, shadcn: { @@ -54,7 +54,7 @@ describe('Template Context - Add-on Options', () => { environment.startRun() await templateFile( './test.txt.ejs', - 'Drizzle: <%= addOnOption.drizzle.database %>, shadcn: <%= addOnOption.shadcn.theme %>' + 'Drizzle: <%= addOnOption.testAddon.database %>, shadcn: <%= addOnOption.shadcn.theme %>' ) environment.finishRun() @@ -88,7 +88,7 @@ describe('Template Context - Add-on Options', () => { const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { + testAddon: { database: 'postgres' } } @@ -96,9 +96,9 @@ describe('Template Context - Add-on Options', () => { environment.startRun() await templateFile( './test.txt.ejs', - `<% if (addOnOption.drizzle.database === 'postgres') { %> + `<% if (addOnOption.testAddon.database === 'postgres') { %> PostgreSQL configuration -<% } else if (addOnOption.drizzle.database === 'mysql') { %> +<% } else if (addOnOption.testAddon.database === 'mysql') { %> MySQL configuration <% } else { %> SQLite configuration @@ -114,7 +114,7 @@ SQLite configuration const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { + testAddon: { database: 'postgres' } } @@ -122,11 +122,11 @@ SQLite configuration environment.startRun() await templateFile( './postgres-config.ts.ejs', - '<% if (addOnOption.drizzle.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL configuration\nexport const config = "postgres"' + '<% if (addOnOption.testAddon.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL configuration\nexport const config = "postgres"' ) await templateFile( './mysql-config.ts.ejs', - '<% if (addOnOption.drizzle.database !== "mysql") { ignoreFile() } %>\n// MySQL configuration\nexport const config = "mysql"' + '<% if (addOnOption.testAddon.database !== "mysql") { ignoreFile() } %>\n// MySQL configuration\nexport const config = "mysql"' ) environment.finishRun() @@ -156,7 +156,7 @@ SQLite configuration const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { + testAddon: { database: undefined } } @@ -164,7 +164,7 @@ SQLite configuration environment.startRun() await templateFile( './test.txt.ejs', - 'Database: <%= addOnOption.drizzle.database || "not set" %>' + 'Database: <%= addOnOption.testAddon.database || "not set" %>' ) environment.finishRun() @@ -178,12 +178,12 @@ SQLite configuration projectName: 'my-app', chosenAddOns: [ { - id: 'drizzle', + id: 'testAddon', name: 'Drizzle ORM', } as AddOn ], addOnOptions: { - drizzle: { + testAddon: { database: 'postgres' } } @@ -191,11 +191,11 @@ SQLite configuration environment.startRun() await templateFile( './test.txt.ejs', - 'Project: <%= projectName %>, Add-ons: <%= Object.keys(addOnEnabled).join(", ") %>, Database: <%= addOnOption.drizzle.database %>' + 'Project: <%= projectName %>, Add-ons: <%= Object.keys(addOnEnabled).join(", ") %>, Database: <%= addOnOption.testAddon.database %>' ) environment.finishRun() - expect(output.files['/test/test.txt']).toEqual('Project: my-app, Add-ons: drizzle, Database: postgres') + expect(output.files['/test/test.txt']).toEqual('Project: my-app, Add-ons: testAddon, Database: postgres') }) it('should handle nested object access safely', async () => { @@ -203,7 +203,7 @@ SQLite configuration const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { + testAddon: { database: 'postgres' } } @@ -211,7 +211,7 @@ SQLite configuration environment.startRun() await templateFile( './test.txt.ejs', - 'Exists: <%= addOnOption.drizzle ? "yes" : "no" %>, Non-existent: <%= addOnOption.nonexistent ? "yes" : "no" %>' + 'Exists: <%= addOnOption.testAddon ? "yes" : "no" %>, Non-existent: <%= addOnOption.nonexistent ? "yes" : "no" %>' ) environment.finishRun() @@ -223,7 +223,7 @@ SQLite configuration const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { + testAddon: { database: 'postgres' } } @@ -231,22 +231,22 @@ SQLite configuration environment.startRun() await templateFile( './db-config.ts.ejs', - `<% if (addOnOption.drizzle.database === 'postgres') { %> -import { drizzle } from 'drizzle-orm/postgres-js' + `<% if (addOnOption.testAddon.database === 'postgres') { %> +import { testAddon } from 'testAddon-orm/postgres-js' import postgres from 'postgres' -<% } else if (addOnOption.drizzle.database === 'mysql') { %> -import { drizzle } from 'drizzle-orm/mysql2' +<% } else if (addOnOption.testAddon.database === 'mysql') { %> +import { testAddon } from 'testAddon-orm/mysql2' import mysql from 'mysql2/promise' -<% } else if (addOnOption.drizzle.database === 'sqlite') { %> -import { drizzle } from 'drizzle-orm/better-sqlite3' +<% } else if (addOnOption.testAddon.database === 'sqlite') { %> +import { testAddon } from 'testAddon-orm/better-sqlite3' import Database from 'better-sqlite3' <% } %> -export const db = drizzle(/* connection */)` +export const db = testAddon(/* connection */)` ) environment.finishRun() - expect(output.files['/test/db-config.ts']).toContain("import { drizzle } from 'drizzle-orm/postgres-js'") + expect(output.files['/test/db-config.ts']).toContain("import { testAddon } from 'testAddon-orm/postgres-js'") expect(output.files['/test/db-config.ts']).toContain("import postgres from 'postgres'") expect(output.files['/test/db-config.ts']).not.toContain("import mysql from 'mysql2/promise'") expect(output.files['/test/db-config.ts']).not.toContain("import Database from 'better-sqlite3'") @@ -257,29 +257,29 @@ export const db = drizzle(/* connection */)` const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { + testAddon: { database: 'postgres' } } }) environment.startRun() await templateFile( - './__postgres__drizzle.config.ts.ejs', - '<% if (addOnOption.drizzle.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL Drizzle config\nexport default { driver: "postgres" }' + './__postgres__testAddon.config.ts.ejs', + '<% if (addOnOption.testAddon.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL Drizzle config\nexport default { driver: "postgres" }' ) await templateFile( - './__mysql__drizzle.config.ts.ejs', - '<% if (addOnOption.drizzle.database !== "mysql") { ignoreFile() } %>\n// MySQL Drizzle config\nexport default { driver: "mysql" }' + './__mysql__testAddon.config.ts.ejs', + '<% if (addOnOption.testAddon.database !== "mysql") { ignoreFile() } %>\n// MySQL Drizzle config\nexport default { driver: "mysql" }' ) environment.finishRun() // File should be created with prefix stripped - expect(output.files['/test/drizzle.config.ts']).toBeDefined() - expect(output.files['/test/drizzle.config.ts'].trim()).toEqual('// PostgreSQL Drizzle config\nexport default { driver: \'postgres\' }') + expect(output.files['/test/testAddon.config.ts']).toBeDefined() + expect(output.files['/test/testAddon.config.ts'].trim()).toEqual('// PostgreSQL Drizzle config\nexport default { driver: \'postgres\' }') // Prefixed filename should not exist - expect(output.files['/test/__postgres__drizzle.config.ts']).toBeUndefined() - expect(output.files['/test/__mysql__drizzle.config.ts']).toBeUndefined() + expect(output.files['/test/__postgres__testAddon.config.ts']).toBeUndefined() + expect(output.files['/test/__mysql__testAddon.config.ts']).toBeUndefined() }) it('should handle nested directory with prefixed files', async () => { @@ -287,7 +287,7 @@ export const db = drizzle(/* connection */)` const templateFile = createTemplateFile(environment, { ...simpleOptions, addOnOptions: { - drizzle: { + testAddon: { database: 'sqlite' } } @@ -295,11 +295,11 @@ export const db = drizzle(/* connection */)` environment.startRun() await templateFile( './src/db/__sqlite__index.ts.ejs', - '<% if (addOnOption.drizzle.database !== "sqlite") { ignoreFile() } %>\n// SQLite database connection\nexport const db = "sqlite"' + '<% if (addOnOption.testAddon.database !== "sqlite") { ignoreFile() } %>\n// SQLite database connection\nexport const db = "sqlite"' ) await templateFile( './src/db/__postgres__index.ts.ejs', - '<% if (addOnOption.drizzle.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL database connection\nexport const db = "postgres"' + '<% if (addOnOption.testAddon.database !== "postgres") { ignoreFile() } %>\n// PostgreSQL database connection\nexport const db = "postgres"' ) environment.finishRun()