diff --git a/README.md b/README.md index d4b6a7248..bff39d687 100644 --- a/README.md +++ b/README.md @@ -37,35 +37,37 @@ Why AdminForth: ## Project initialisation -To create an AdminForth project, run: +AdminForth supports two setup paths: -```bash -npx adminforth create-app -``` +### Path 1: Existing database -During the interactive initialization process, AdminForth will ask you to provide a local database URL. +Use this path when you already have a database and your own schema or migrations. Provide your database URL with `--db`, or enter it when the CLI asks `Please specify the database URL to use`. -### Integrating AdminForth into your existing application +```bash +npx adminforth create-app --app-name myadmin --db "postgresql://user:password@localhost:5432/dbname" +cd myadmin +``` -If you want to build an admin panel for an existing project that already has a database with tables, you can provide the connection URL to your existing development database, such as a local or deployed one. +When you provide your own database URL, AdminForth connects to your database but does not create Prisma schema or migrations for it. The generated project README includes the SQL or schema notes needed to add the required `adminuser` table with your own migration tool. -After that, you may want to generate AdminForth resource files from your existing database tables: +After project creation, generate AdminForth resource files from your existing tables: ```bash npx adminforth resource ``` -Resource files are needed for AdminForth to “know” about your tables and define how to work with them. +### Path 2: New database -Use the command above every time you add new tables or change their schema. +Use this path when you want AdminForth to scaffold a standalone app with a new local SQLite database. Omit `--db`, or accept the default `sqlite://.db.sqlite` value in the interactive prompt: -### Starting from scratch - -If you do not have a database yet, start an empty local database, for example PostgreSQL in Docker, and provide its URL to the AdminForth CLI. - -If the adminforth CLI does not detect any tables, it will suggest adding Prisma as a migration tool. Prisma is not related to AdminForth, but it is one of the most convenient migration tools. +```bash +npx adminforth create-app --app-name myadmin +cd myadmin +pnpm makemigration --name init && pnpm migrate:local +pnpm dev +``` -Please follow [getting started](https://adminforth.dev/docs/tutorial/gettingStarted/). +For the new database path, the CLI can scaffold Prisma files and migration scripts for the default `sqlite://.db.sqlite` database. Please follow [getting started](https://adminforth.dev/docs/tutorial/gettingStarted/) for the full guide. # For AdminForth developers diff --git a/adminforth/commands/createApp/templates/readme.md.hbs b/adminforth/commands/createApp/templates/readme.md.hbs index 355413e45..2b1c6d495 100644 --- a/adminforth/commands/createApp/templates/readme.md.hbs +++ b/adminforth/commands/createApp/templates/readme.md.hbs @@ -6,11 +6,22 @@ Install dependencies: {{packageManager}} install ``` -Migrate the database: +{{#if adminUserTableInstructions}} +Prepare the admin users table in your existing database before starting the app. AdminForth uses this table for back-office authentication, and your own migration tool should own this schema change. The schema below is only an example: + +{{{adminUserTableInstructions}}} + +The generated app will seed the default `adminforth` / `adminforth` user on first start if the table is empty. +{{/if}} + +{{#if prismaDbUrl}} +Create the initial migration and apply it to the database: ```bash +{{packageManagerRun}} makemigration{{packageManagerScriptArgSeparator}}--name init {{packageManagerRun}} migrate:local ``` +{{/if}} Start the server: diff --git a/adminforth/commands/createApp/utils.js b/adminforth/commands/createApp/utils.js index 558cf3b20..9f1105a00 100644 --- a/adminforth/commands/createApp/utils.js +++ b/adminforth/commands/createApp/utils.js @@ -39,6 +39,15 @@ function detectAdminforthVersion() { const adminforthVersion = detectAdminforthVersion(); const SUPPORTED_DB_URL_SCHEMES = ['sqlite://', 'postgresql://', 'mongodb://', 'mysql://', 'clickhouse://']; const PRISMA_MIGRATION_DB_PROTOCOLS = ['sqlite', 'postgres', 'postgresql', 'mysql']; +const DEFAULT_DB_URL = 'sqlite://.db.sqlite'; +const DATABASE_CONNECTOR_IMPORTS = { + sqlite: '../../dist/dataConnectors/sqlite.js', + postgresql: '../../dist/dataConnectors/postgres.js', + mysql: '../../dist/dataConnectors/mysql.js', + mongodb: '../../dist/dataConnectors/mongo.js', + clickhouse: '../../dist/dataConnectors/clickhouse.js', +}; +const ADMINUSER_TABLE_EXAMPLE_NOTE = 'This is only an example schema. We recommend using your favorite migration tool to create and evolve this table, and adding database indexes or constraints only when they match your project requirements.'; export function parseArgumentsIntoOptions(rawArgs) { @@ -61,6 +70,68 @@ export function parseArgumentsIntoOptions(rawArgs) { }; } +function generateAdminUserTableInstructions(provider) { + if (provider === 'postgresql') { + return `\`\`\`sql +CREATE TABLE adminuser ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + created_at TIMESTAMP NOT NULL +); +\`\`\` + +${ADMINUSER_TABLE_EXAMPLE_NOTE}`; + } + + if (provider === 'mysql') { + return `\`\`\`sql +CREATE TABLE adminuser ( + id VARCHAR(191) PRIMARY KEY, + email VARCHAR(191) NOT NULL, + password_hash TEXT NOT NULL, + role VARCHAR(191) NOT NULL, + created_at DATETIME NOT NULL +); +\`\`\` + +${ADMINUSER_TABLE_EXAMPLE_NOTE}`; + } + + if (provider === 'sqlite') { + return `\`\`\`sql +CREATE TABLE adminuser ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + created_at DATETIME NOT NULL +); +\`\`\` + +${ADMINUSER_TABLE_EXAMPLE_NOTE}`; + } + + if (provider === 'clickhouse') { + return `\`\`\`sql +CREATE TABLE adminuser ( + id String, + email String, + password_hash String, + role String, + created_at DateTime +) +ENGINE = MergeTree() +ORDER BY id; +\`\`\` + +${ADMINUSER_TABLE_EXAMPLE_NOTE}`; + } + + return null; +} + export async function promptForMissingOptions(options) { const questions = []; @@ -78,7 +149,7 @@ export async function promptForMissingOptions(options) { type: 'input', name: 'db', message: 'Please specify the database URL to use >', - default: 'sqlite://.db.sqlite', + default: DEFAULT_DB_URL, }); }; @@ -102,10 +173,14 @@ export async function promptForMissingOptions(options) { db: options.db || answers.db, useNpm: options.useNpm || answers.useNpm, }; + resolvedOptions.existingDb = false; + + await inspectDatabaseCleanState(resolvedOptions); if ( resolvedOptions.includePrismaMigrations === undefined && - isPrismaMigrationDbUrl(resolvedOptions.db) + isPrismaMigrationDbUrl(resolvedOptions.db) && + !resolvedOptions.existingDb ) { const prismaAnswer = await inquirer.prompt([{ type: 'select', @@ -119,7 +194,7 @@ export async function promptForMissingOptions(options) { }]); resolvedOptions.includePrismaMigrations = prismaAnswer.includePrismaMigrations; } else { - resolvedOptions.includePrismaMigrations = Boolean(resolvedOptions.includePrismaMigrations); + resolvedOptions.includePrismaMigrations = Boolean(resolvedOptions.includePrismaMigrations) && !resolvedOptions.existingDb; } return resolvedOptions; @@ -189,6 +264,32 @@ function generateDbUrlForAfProd(connectionString) { return connectionString.toString(); } +async function inspectDatabaseCleanState(options) { + const connectionString = parseConnectionString(options.db); + const provider = detectDbProvider(connectionString.protocol); + + const Connector = (await import(DATABASE_CONNECTOR_IMPORTS[provider])).default; + const connector = new Connector(); + + try { + await connector.setupClient(connectionString.toString()); + } catch (error) { + if (provider === 'sqlite' && error.message?.includes('directory does not exist')) { + options.existingDb = false; + return; + } + throw error; + } + + try { + options.existingDb = !(await connector.isDatabaseEmpty()); + } finally { + if (typeof connector.close === 'function') { + await connector.close(); + } + } +} + function initialChecks(options) { return [ { @@ -262,7 +363,7 @@ async function scaffoldProject(ctx, options, cwd) { const prismaDbUrlProd = generateDbUrlForPrismaProd(connectionString); - ctx.skipPrismaSetup = !prismaDbUrl; + ctx.skipPrismaSetup = !options.includePrismaMigrations || !prismaDbUrl; const appName = options.appName; const filename = fileURLToPath(import.meta.url); @@ -287,6 +388,7 @@ async function scaffoldProject(ctx, options, cwd) { prismaDbUrlProd, appName, provider, + existingDb: options.existingDb, nodeMajor: parseInt(process.versions.node.split('.')[0], 10), sqliteFile: connectionString.protocol.startsWith('sqlite') ? connectionString.host : null, }); @@ -310,7 +412,7 @@ function getPackageManagerTemplateData(useNpm, nodeMajor) { async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations, options) { const { - dbUrl, prismaDbUrl, appName, provider, nodeMajor, + dbUrl, prismaDbUrl, appName, provider, existingDb, nodeMajor, dbUrlProd, prismaDbUrlProd, sqliteFile } = options; const packageManagerTemplateData = getPackageManagerTemplateData(useNpm, nodeMajor); @@ -352,7 +454,14 @@ async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations, { src: 'readme.md.hbs', dest: 'README.md', - data: { dbUrl, prismaDbUrl: resolvedPrismaDbUrl, appName, sqliteFile }, + data: { + dbUrl, + prismaDbUrl: resolvedPrismaDbUrl, + appName, + sqliteFile, + existingDb, + adminUserTableInstructions: existingDb ? generateAdminUserTableInstructions(provider) : null, + }, }, { src: 'AGENTS.md.hbs', @@ -519,16 +628,21 @@ async function installDependenciesNpm(ctx, cwd) { function generateFinalInstructionsPnpm(skipPrismaSetup, options) { let instruction = '⏭️ Run the following commands to get started:\n'; - if (!skipPrismaSetup) - instruction += ` + const provider = detectDbProvider(parseConnectionString(options.db).protocol); + instruction += ` ${chalk.dim('// Go to the project directory')} ${chalk.dim('$')}${chalk.cyan(` cd ${options.appName}`)}\n`; - if (options.includePrismaMigrations && !skipPrismaSetup) + if (!skipPrismaSetup) instruction += ` ${chalk.dim('// Generate and apply initial migration')} ${chalk.dim('$')}${chalk.cyan(' pnpm makemigration --name init && pnpm migrate:local')}\n`; + if (options.existingDb) + instruction += ` + ${chalk.dim('// Create the adminuser table in your database before starting the app')} + ${generateAdminUserTableInstructions(provider)}\n`; + instruction += ` ${chalk.dim('// Start dev server with tsx watch for hot-reloading')} ${chalk.dim('$')}${chalk.cyan(' pnpm dev')}\n @@ -541,16 +655,21 @@ function generateFinalInstructionsPnpm(skipPrismaSetup, options) { function generateFinalInstructionsNpm(skipPrismaSetup, options) { let instruction = '⏭️ Run the following commands to get started:\n'; - if (!skipPrismaSetup) - instruction += ` + const provider = detectDbProvider(parseConnectionString(options.db).protocol); + instruction += ` ${chalk.dim('// Go to the project directory')} ${chalk.dim('$')}${chalk.cyan(` cd ${options.appName}`)}\n`; - if (options.includePrismaMigrations && !skipPrismaSetup) + if (!skipPrismaSetup) instruction += ` ${chalk.dim('// Generate and apply initial migration')} ${chalk.dim('$')}${chalk.cyan(' npm run makemigration -- --name init && npm run migrate:local')}\n`; + if (options.existingDb) + instruction += ` + ${chalk.dim('// Create the adminuser table in your database before starting the app')} + ${generateAdminUserTableInstructions(provider)}\n`; + instruction += ` ${chalk.dim('// Start dev server with tsx watch for hot-reloading')} ${chalk.dim('$')}${chalk.cyan(' npm run dev')}\n diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index e42dcd210..0130a42e8 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -696,5 +696,9 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon throw new Error('getAllColumnsInTable() must be implemented in subclass'); } + async isDatabaseEmpty(): Promise { + throw new Error('isDatabaseEmpty() must be implemented in subclass'); + } + } diff --git a/adminforth/dataConnectors/clickhouse.ts b/adminforth/dataConnectors/clickhouse.ts index f714a5b06..d16027952 100644 --- a/adminforth/dataConnectors/clickhouse.ts +++ b/adminforth/dataConnectors/clickhouse.ts @@ -87,6 +87,25 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth sampleValue: sampleRow[col.name], })); } + + async isDatabaseEmpty(): Promise { + const res = await this.client.query({ + query: ` + SELECT database, name, engine + FROM system.tables + WHERE database = {database:String} + AND database NOT IN ('system', 'information_schema', 'INFORMATION_SCHEMA') + AND is_temporary = 0 + LIMIT 1 + `, + format: 'JSONEachRow', + query_params: { + database: this.dbName, + }, + }); + const rows = await res.json(); + return rows.length === 0; + } async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> { const tableName = resource.table; @@ -662,8 +681,8 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth return recordIds.length; } - close() { - this.client.disconnect(); + async close() { + await this.client.close(); } } diff --git a/adminforth/dataConnectors/mongo.ts b/adminforth/dataConnectors/mongo.ts index 2ad7f0926..6d6e8a84e 100644 --- a/adminforth/dataConnectors/mongo.ts +++ b/adminforth/dataConnectors/mongo.ts @@ -166,6 +166,11 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS }; }); } + + async isDatabaseEmpty(): Promise { + const collections = await this.client.db().listCollections({}, { nameOnly: true }).toArray(); + return collections.every((collection) => collection.name.startsWith('system.')); + } async discoverFields(resource) { diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index 9150d02a6..8733a589c 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -77,6 +77,17 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS })); } + async isDatabaseEmpty(): Promise { + const [rows] = await this.client.execute(` + SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_type = 'BASE TABLE' + LIMIT 1 + `); + return rows.length === 0; + } + async hasMySQLCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise { const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade'); diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index d542dda49..edbbb7227 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -89,6 +89,18 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa const sampleRow = sampleRowRes.rows[0] ?? {}; return res.rows.map(row => ({ name: row.column_name, sampleValue: sampleRow[row.column_name] })); } + + async isDatabaseEmpty(): Promise { + const res = await this.client.query(` + SELECT table_schema, table_name + FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + AND table_schema NOT IN ('pg_catalog', 'information_schema') + AND table_schema NOT LIKE 'pg_toast%' + LIMIT 1 + `); + return res.rows.length === 0; + } async checkForeignResourceCascade(resource: AdminForthResource, config: AdminForthConfig, schema = 'public'): Promise { const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade'); diff --git a/adminforth/dataConnectors/qdrant.ts b/adminforth/dataConnectors/qdrant.ts index a75d57c80..35d4795a5 100644 --- a/adminforth/dataConnectors/qdrant.ts +++ b/adminforth/dataConnectors/qdrant.ts @@ -95,6 +95,11 @@ class QdrantConnector extends AdminForthBaseConnector implements IAdminForthData return (collections.collections ?? []).map((collection: { name: string }) => collection.name); } + async isDatabaseEmpty(): Promise { + const collections = await this.client.getCollections(); + return (collections.collections ?? []).length === 0; + } + // discover fields async discoverFields(resource) { return resource.columns.filter((col) => !col.virtual).reduce((acc, col) => { diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index 9dd546763..b3753eff7 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -51,6 +51,17 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData sampleValue: sampleRow[col.name], })); } + + async isDatabaseEmpty(): Promise { + const stmt = this.client.prepare(` + SELECT name + FROM sqlite_schema + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + LIMIT 1 + `); + return !stmt.get(); + } async hasSQLiteCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise { const cascadeColumn = resource.columns?.find(c => c.foreignResource?.onDelete === 'cascade'); @@ -540,8 +551,8 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData return res.changes ?? 0; } - close() { - this.client.close(); + async close(): Promise { + this.client?.close(); } } diff --git a/adminforth/documentation/docs/tutorial/001-gettingStarted.md b/adminforth/documentation/docs/tutorial/001-gettingStarted.md index 842b7c00d..2af4280a1 100644 --- a/adminforth/documentation/docs/tutorial/001-gettingStarted.md +++ b/adminforth/documentation/docs/tutorial/001-gettingStarted.md @@ -21,15 +21,38 @@ nvm use 20 ## Creating an AdminForth Project -The recommended way to get started with AdminForth is via the **`create-app`** CLI, which scaffolds a basic fully functional back-office application. Apart boilerplate it creates one resource for users management. +The recommended way to get started with AdminForth is via the **`create-app`** CLI, which scaffolds a basic fully functional back-office application. Apart from boilerplate, it creates one resource for user management. -You can provide options directorly: +There are two common setup paths: + +### Path 1: Existing Database + +Use this path when you already have a database and your own schema or migrations. Pass your database URL with `--db`, or enter it when the CLI asks `Please specify the database URL to use`: + +```bash +npx adminforth create-app --app-name myadmin --db "postgresql://user:password@localhost:5432/dbname" +``` + +When you provide your own database URL, the CLI treats this as your own database. It does not create Prisma schema or Prisma migration scripts for that database. Instead, the generated project README contains the SQL or schema notes for adding the required `adminuser` table with your own migration tool. + +After the project is created, navigate into it and generate resources from your existing tables: + +```bash +cd myadmin +npx adminforth resource +``` + +Resource files are needed for AdminForth to know about your tables and define how to work with them. Use `npx adminforth resource` again when you add new tables or change their schema. + +### Path 2: New Database + +Use this path when you want AdminForth to scaffold a standalone app with a new local SQLite database. Omit `--db`, or accept the default `sqlite://.db.sqlite` value in the interactive prompt: ```bash -npx adminforth create-app --app-name myadmin --db "sqlite://.db.sqlite" +npx adminforth create-app --app-name myadmin ``` -Or omit them to be prompted interactively: +Or omit all options to be prompted interactively: ```bash npx adminforth create-app @@ -41,6 +64,8 @@ Once the project is created, navigate into its directory: cd myadmin # or any other name you provided ``` +For the new database path, the CLI can scaffold Prisma files and migration scripts for the default SQLite database. + CLI options: * **`--app-name`** - name for your project. Used in `package.json`, `index.ts` branding, etc. Default value: **`adminforth-app`**. @@ -69,7 +94,7 @@ myadmin/ │ └── tsconfig.json # Tsconfig for Vue project (adds completion for AdminForth core components) ├── resources │ └── adminuser.ts # Example resource file for users management -├── schema.prisma # Prisma schema file for database schema +├── schema.prisma # Prisma schema file, generated only for the new database path ├── index.ts # Main entry point: configures AdminForth & starts the server ├── package.json # Project dependencies ├── pnpm-workspace.yaml @@ -82,15 +107,15 @@ myadmin/ ### Initial Migration & Future Migrations -> ☝️ CLI creates Prisma schema file for managing migrations in relational databases, however you are not forced to use it. Instead you are free to use your favourite or existing migration tool. In this case just ignore generated prisma file, and don't run migration command which will be suggested by CLI. However you have to ensure that your migration tool will generate required table `adminuser` with same fields and types for Admin Users resource to implmenet BackOffice authentication. +For the new database path, the CLI creates Prisma files for managing migrations. Prisma is not required by AdminForth itself, but it is a convenient migration tool for standalone projects that do not have database management yet. CLI will suggest you a command to initialize the database with Prisma: ```bash -pnpm makemigration --name init +pnpm makemigration --name init && pnpm migrate:local ``` -This will create a migration file in `migrations` and apply it to the database. +This will create a migration file and apply it to the database. In future, when you need to add new resources, you need to modify `schema.prisma` (add models, change fields, etc.). After doing any modification you need to create a new migration using next command: @@ -100,6 +125,8 @@ pnpm makemigration --name init ; pnpm migrate:local Other developers need to pull migration and run `pnpm migrate:local` to apply any unapplied migrations. +For the existing database path, use your own migration tool instead. The generated project README shows how to add the required `adminuser` table to your database. + ## Run the Server Now you can run your app: diff --git a/adminforth/modules/utils.ts b/adminforth/modules/utils.ts index c14e76003..9ff4a5444 100644 --- a/adminforth/modules/utils.ts +++ b/adminforth/modules/utils.ts @@ -3,7 +3,8 @@ import { fileURLToPath } from 'url'; import fs from 'fs'; import Fuse from 'fuse.js'; import crypto from 'crypto'; -import { AdminForthConfig, AdminForthResource, AdminForthResourceColumnInputCommon,Filters, IAdminForth, Predicate } from '../index.js'; +import { Filters } from '../types/Back.js'; +import type { AdminForthResource, IAdminForth } from '../types/Back.js'; import { RateLimiterMemory, RateLimiterAbstract } from "rate-limiter-flexible"; // @ts-ignore-next-line diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index 1a43f547a..0589c8b8f 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -297,6 +297,12 @@ export interface IAdminForthDataSourceConnector { * Function to get all columns in table. */ getAllColumnsInTable(tableName: string): Promise>; + + /** + * Function to check whether database has no user data. + */ + isDatabaseEmpty(): Promise; + /** * Optional. * You an redefine this function to define how one record should be fetched from database.