From fa3fa5c10060f5ff0c1c01acc075a21576b62d41 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Fri, 5 Jun 2026 15:08:24 +0300 Subject: [PATCH 1/3] fix: enhance project initialization and menu transformation capabilities --- README.md | 34 +++--- .../createApp/templates/readme.md.hbs | 10 ++ adminforth/commands/createApp/utils.js | 115 +++++++++++++----- .../docs/tutorial/001-gettingStarted.md | 43 +++++-- adminforth/index.ts | 36 ++++-- 5 files changed, 176 insertions(+), 62 deletions(-) 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..0945c6696 100644 --- a/adminforth/commands/createApp/templates/readme.md.hbs +++ b/adminforth/commands/createApp/templates/readme.md.hbs @@ -6,11 +6,21 @@ Install dependencies: {{packageManager}} install ``` +{{#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: + +{{{adminUserTableInstructions}}} + +The generated app will seed the default `adminforth` / `adminforth` user on first start if the table is empty. +{{/if}} + +{{#if prismaDbUrl}} Migrate the database: ```bash {{packageManagerRun}} migrate:local ``` +{{/if}} Start the server: diff --git a/adminforth/commands/createApp/utils.js b/adminforth/commands/createApp/utils.js index 558cf3b20..03808ee38 100644 --- a/adminforth/commands/createApp/utils.js +++ b/adminforth/commands/createApp/utils.js @@ -39,6 +39,7 @@ 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'; export function parseArgumentsIntoOptions(rawArgs) { @@ -57,10 +58,69 @@ export function parseArgumentsIntoOptions(rawArgs) { return { appName: args['--app-name'], db: args['--db'], + dbProvided: args['--db'] !== undefined, useNpm: args['--use-npm'], }; } +function generateAdminUserTableInstructions(provider) { + if (provider === 'postgresql') { + return `\`\`\`sql +CREATE TABLE adminuser ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + created_at TIMESTAMP NOT NULL +); +\`\`\``; + } + + if (provider === 'mysql') { + return `\`\`\`sql +CREATE TABLE adminuser ( + id VARCHAR(191) PRIMARY KEY, + email VARCHAR(191) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role VARCHAR(191) NOT NULL, + created_at DATETIME NOT NULL +); +\`\`\``; + } + + if (provider === 'sqlite') { + return `\`\`\`sql +CREATE TABLE adminuser ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + created_at DATETIME NOT NULL +); +\`\`\``; + } + + 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; +\`\`\``; + } + + if (provider === 'mongodb') { + return 'Create an `adminuser` collection with `id`, `email`, `password_hash`, `role`, and `created_at` fields. Keep `email` unique in your own schema/index setup.'; + } + + return null; +} + export async function promptForMissingOptions(options) { const questions = []; @@ -78,7 +138,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,25 +162,8 @@ export async function promptForMissingOptions(options) { db: options.db || answers.db, useNpm: options.useNpm || answers.useNpm, }; - - if ( - resolvedOptions.includePrismaMigrations === undefined && - isPrismaMigrationDbUrl(resolvedOptions.db) - ) { - const prismaAnswer = await inquirer.prompt([{ - type: 'select', - name: 'includePrismaMigrations', - message: 'Include Prisma migrations? >', - choices: [ - { name: 'Yes', value: true }, - { name: 'No', value: false }, - ], - default: true, - }]); - resolvedOptions.includePrismaMigrations = prismaAnswer.includePrismaMigrations; - } else { - resolvedOptions.includePrismaMigrations = Boolean(resolvedOptions.includePrismaMigrations); - } + resolvedOptions.existingDb = options.dbProvided || resolvedOptions.db !== DEFAULT_DB_URL; + resolvedOptions.includePrismaMigrations = !resolvedOptions.existingDb && isPrismaMigrationDbUrl(resolvedOptions.db); return resolvedOptions; } @@ -262,7 +305,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 +330,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 +354,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 +396,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 +570,19 @@ async function installDependenciesNpm(ctx, cwd) { function generateFinalInstructionsPnpm(skipPrismaSetup, options) { let instruction = '⏭️ Run the following commands to get started:\n'; - if (!skipPrismaSetup) - instruction += ` + 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 using the README instructions')}\n`; + instruction += ` ${chalk.dim('// Start dev server with tsx watch for hot-reloading')} ${chalk.dim('$')}${chalk.cyan(' pnpm dev')}\n @@ -541,16 +595,19 @@ function generateFinalInstructionsPnpm(skipPrismaSetup, options) { function generateFinalInstructionsNpm(skipPrismaSetup, options) { let instruction = '⏭️ Run the following commands to get started:\n'; - if (!skipPrismaSetup) - instruction += ` + 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 using the README instructions')}\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/documentation/docs/tutorial/001-gettingStarted.md b/adminforth/documentation/docs/tutorial/001-gettingStarted.md index 842b7c00d..7f55aa9f8 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 users 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/index.ts b/adminforth/index.ts index 1a78495d9..e5dd91d9d 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -26,6 +26,7 @@ import { UpdateResourceRecordResult, DeleteResourceRecordResult, AdminForthMenuContributionProvider, + AdminForthMenuTransformProvider, } from './types/Back.js'; import { AdminForthFilterOperators, @@ -133,6 +134,7 @@ class AdminForth implements IAdminForth { pluginsById: Record = {}; private menuContributions: AdminForthMenuContribution[] = []; private menuContributionProviders: AdminForthMenuContributionProvider[] = []; + private menuTransformProviders: AdminForthMenuTransformProvider[] = []; configValidator: IConfigValidator; restApi: AdminForthRestAPI; @@ -146,6 +148,10 @@ class AdminForth implements IAdminForth { this.menuContributionProviders.push(provider); } + registerMenuTransformProvider(provider: AdminForthMenuTransformProvider): void { + this.menuTransformProviders.push(provider); + } + getMenuContributions(): AdminForthMenuContribution[] { return [...this.menuContributions]; } @@ -153,6 +159,15 @@ class AdminForth implements IAdminForth { async getMenuWithContributions(adminUser?: AdminUser, menu: AdminForthConfigMenuItem[] = this.config.menu): Promise { const generateItemId = (item: AdminForthConfigMenuItem) => md5hash(`menu-item-${item.label}-${item.resourceId || ''}-${item.path || ''}`); + const cloneMenuItem = (item: AdminForthConfigMenuItem): AdminForthConfigMenuItem => ({ + ...item, + children: item.children?.map(cloneMenuItem), + }); + const resolveMenuItemIds = (items: AdminForthConfigMenuItem[]): AdminForthConfigMenuItem[] => items.map((item) => ({ + ...item, + itemId: item.itemId || generateItemId(item), + children: item.children ? resolveMenuItemIds(item.children) : item.children, + })); const matchesTarget = (item: AdminForthConfigMenuItem, target: AdminForthMenuTarget) => typeof target === 'string' ? item.itemId === target @@ -160,14 +175,7 @@ class AdminForth implements IAdminForth { || (target.resourceId !== undefined && item.resourceId === target.resourceId) || (target.path !== undefined && item.path === target.path); - const resolvedMenu: AdminForthConfigMenuItem[] = menu.map((item) => ({ - ...item, - itemId: item.itemId || generateItemId(item), - children: item.children?.map((child) => ({ - ...child, - itemId: child.itemId || generateItemId(child), - })), - })); + const resolvedMenu: AdminForthConfigMenuItem[] = resolveMenuItemIds(menu); const usedItemIds = new Set(resolvedMenu.map((item) => item.itemId)); const providerContributions = await Promise.all( @@ -203,7 +211,17 @@ class AdminForth implements IAdminForth { } } - return resolvedMenu; + let transformedMenu = resolvedMenu; + + for (const provider of this.menuTransformProviders) { + transformedMenu = resolveMenuItemIds(await provider({ + adminUser, + adminforth: this, + menu: transformedMenu.map(cloneMenuItem), + })); + } + + return transformedMenu; } async refreshMenu(adminUser: AdminUser) { From 9f0e7abebe78c5288b7558012e823eec1f721090 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Fri, 5 Jun 2026 15:20:37 +0300 Subject: [PATCH 2/3] refactor: remove unused menu transform provider and simplify menu resolution logic --- adminforth/index.ts | 36 +++++++++--------------------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/adminforth/index.ts b/adminforth/index.ts index e5dd91d9d..1a78495d9 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -26,7 +26,6 @@ import { UpdateResourceRecordResult, DeleteResourceRecordResult, AdminForthMenuContributionProvider, - AdminForthMenuTransformProvider, } from './types/Back.js'; import { AdminForthFilterOperators, @@ -134,7 +133,6 @@ class AdminForth implements IAdminForth { pluginsById: Record = {}; private menuContributions: AdminForthMenuContribution[] = []; private menuContributionProviders: AdminForthMenuContributionProvider[] = []; - private menuTransformProviders: AdminForthMenuTransformProvider[] = []; configValidator: IConfigValidator; restApi: AdminForthRestAPI; @@ -148,10 +146,6 @@ class AdminForth implements IAdminForth { this.menuContributionProviders.push(provider); } - registerMenuTransformProvider(provider: AdminForthMenuTransformProvider): void { - this.menuTransformProviders.push(provider); - } - getMenuContributions(): AdminForthMenuContribution[] { return [...this.menuContributions]; } @@ -159,15 +153,6 @@ class AdminForth implements IAdminForth { async getMenuWithContributions(adminUser?: AdminUser, menu: AdminForthConfigMenuItem[] = this.config.menu): Promise { const generateItemId = (item: AdminForthConfigMenuItem) => md5hash(`menu-item-${item.label}-${item.resourceId || ''}-${item.path || ''}`); - const cloneMenuItem = (item: AdminForthConfigMenuItem): AdminForthConfigMenuItem => ({ - ...item, - children: item.children?.map(cloneMenuItem), - }); - const resolveMenuItemIds = (items: AdminForthConfigMenuItem[]): AdminForthConfigMenuItem[] => items.map((item) => ({ - ...item, - itemId: item.itemId || generateItemId(item), - children: item.children ? resolveMenuItemIds(item.children) : item.children, - })); const matchesTarget = (item: AdminForthConfigMenuItem, target: AdminForthMenuTarget) => typeof target === 'string' ? item.itemId === target @@ -175,7 +160,14 @@ class AdminForth implements IAdminForth { || (target.resourceId !== undefined && item.resourceId === target.resourceId) || (target.path !== undefined && item.path === target.path); - const resolvedMenu: AdminForthConfigMenuItem[] = resolveMenuItemIds(menu); + const resolvedMenu: AdminForthConfigMenuItem[] = menu.map((item) => ({ + ...item, + itemId: item.itemId || generateItemId(item), + children: item.children?.map((child) => ({ + ...child, + itemId: child.itemId || generateItemId(child), + })), + })); const usedItemIds = new Set(resolvedMenu.map((item) => item.itemId)); const providerContributions = await Promise.all( @@ -211,17 +203,7 @@ class AdminForth implements IAdminForth { } } - let transformedMenu = resolvedMenu; - - for (const provider of this.menuTransformProviders) { - transformedMenu = resolveMenuItemIds(await provider({ - adminUser, - adminforth: this, - menu: transformedMenu.map(cloneMenuItem), - })); - } - - return transformedMenu; + return resolvedMenu; } async refreshMenu(adminUser: AdminUser) { From 5a5cc98b25589054e136ea2facd73b04cd153b77 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Fri, 5 Jun 2026 15:25:40 +0300 Subject: [PATCH 3/3] docs: clarify migration instructions and add note on ClickHouse unique constraints --- adminforth/commands/createApp/templates/readme.md.hbs | 3 ++- adminforth/commands/createApp/utils.js | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/adminforth/commands/createApp/templates/readme.md.hbs b/adminforth/commands/createApp/templates/readme.md.hbs index 0945c6696..f921fd4cd 100644 --- a/adminforth/commands/createApp/templates/readme.md.hbs +++ b/adminforth/commands/createApp/templates/readme.md.hbs @@ -15,9 +15,10 @@ The generated app will seed the default `adminforth` / `adminforth` user on firs {{/if}} {{#if prismaDbUrl}} -Migrate the database: +Create the initial migration and apply it to the database: ```bash +{{packageManagerRun}} makemigration{{packageManagerScriptArgSeparator}}--name init {{packageManagerRun}} migrate:local ``` {{/if}} diff --git a/adminforth/commands/createApp/utils.js b/adminforth/commands/createApp/utils.js index 03808ee38..f569b4d89 100644 --- a/adminforth/commands/createApp/utils.js +++ b/adminforth/commands/createApp/utils.js @@ -111,7 +111,9 @@ CREATE TABLE adminuser ( ) ENGINE = MergeTree() ORDER BY id; -\`\`\``; +\`\`\` + +ClickHouse does not enforce UNIQUE constraints like PostgreSQL, MySQL, or SQLite. AdminForth authentication expects `email` values in `adminuser` to be unique, so enforce this in your ingestion/application logic and remove duplicate email rows to avoid ambiguous logins.`; } if (provider === 'mongodb') {