Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion adminforth/commands/createApp/templates/readme.md.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Comment thread
NoOne7135 marked this conversation as resolved.

Start the server:

Expand Down
143 changes: 131 additions & 12 deletions adminforth/commands/createApp/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 = [];

Expand All @@ -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,
});
};

Expand All @@ -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',
Expand All @@ -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;
Expand Down Expand Up @@ -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 [
{
Expand Down Expand Up @@ -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);
Expand All @@ -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,
});
Expand All @@ -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);
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions adminforth/dataConnectors/baseConnector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,5 +696,9 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
throw new Error('getAllColumnsInTable() must be implemented in subclass');
}

async isDatabaseEmpty(): Promise<boolean> {
throw new Error('isDatabaseEmpty() must be implemented in subclass');
}


}
23 changes: 21 additions & 2 deletions adminforth/dataConnectors/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
sampleValue: sampleRow[col.name],
}));
}

async isDatabaseEmpty(): Promise<boolean> {
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;
Expand Down Expand Up @@ -662,8 +681,8 @@ class ClickhouseConnector extends AdminForthBaseConnector implements IAdminForth
return recordIds.length;
}

close() {
this.client.disconnect();
async close() {
await this.client.close();
}
}

Expand Down
5 changes: 5 additions & 0 deletions adminforth/dataConnectors/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS
};
});
}

async isDatabaseEmpty(): Promise<boolean> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think prisma for mongo has no sense at all. Never. It works with mongo - yes. But not for migrations - this is for typesafe prisma client which we dont use anyway!
So nothing decide here - simply lets never include prisma for mongo. Thoughts?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prisma is already not offered for MongoDB. The dialog is not shown for MongoDB creation, so this part already works as expected.

isDatabaseEmpty was implemented for all connectors intentionally, for consistency. Even though it is currently used only in the Prisma flow, the function itself is generic and may be needed later for other database-related flows.

const collections = await this.client.db().listCollections({}, { nameOnly: true }).toArray();
return collections.every((collection) => collection.name.startsWith('system.'));
}


async discoverFields(resource) {
Expand Down
11 changes: 11 additions & 0 deletions adminforth/dataConnectors/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
}));
}

async isDatabaseEmpty(): Promise<boolean> {
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<boolean> {

const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade');
Expand Down
Loading