0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor: move cli -> core

This commit is contained in:
Gao Sun 2022-04-26 14:07:28 +08:00
parent a10b427c87
commit f5c7faf775
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
16 changed files with 122 additions and 238 deletions

View file

@ -1,5 +1,5 @@
{
"name": "root",
"name": "@logto/root",
"private": true,
"license": "MPL-2.0",
"scripts": {

View file

@ -1,3 +0,0 @@
#!/usr/bin/env node
require('../');

View file

@ -1,73 +0,0 @@
import { Argument, Command, Option } from 'commander';
import { createDatabase, createDatabaseCli } from './library';
import { replaceDsnDatabase } from './utilities';
const versionNote = 'Note Logto requires Postgres v14 or higher.';
const database = new Command('database');
const args = {
database: new Argument('[database]', 'The Logto database name.').default('logto'),
};
const options = {
dsn: new Option(
'-d --dsn <address>',
`Your Postgres DSN *WITHOUT* database name.\n${versionNote}`
)
.env('DSN')
.makeOptionMandatory(),
domain: new Option('--domain <domain>', 'The Logto domain for table seeding.')
.env('DOMAIN')
.makeOptionMandatory(),
};
database.alias('db');
database
.command('create')
.description('Create a Logto databse.')
.addArgument(args.database)
.addOption(options.dsn)
.action(async (databaseName: unknown, options: Record<string, unknown>) => {
await createDatabase(String(options.dsn), String(databaseName));
});
database
.command('create-tables')
.description('Create Logto database tables without data.')
.addArgument(args.database)
.addOption(options.dsn)
.action(async (databaseName: unknown, options: Record<string, unknown>) => {
const cli = createDatabaseCli(replaceDsnDatabase(String(options.dsn), String(databaseName)));
await cli.createTables();
await cli.end();
});
database
.command('seed-tables')
.description('Seed tables with necessary data to run Logto.')
.addArgument(args.database)
.addOption(options.dsn)
.addOption(options.domain)
.action(async (databaseName: unknown, options: Record<string, unknown>) => {
const cli = createDatabaseCli(replaceDsnDatabase(String(options.dsn), String(databaseName)));
await cli.seedTables(String(options.domain));
await cli.end();
});
database
.command('init')
.description(`Create and initialize a Logto database with tables and data in the DSN.`)
.addArgument(args.database)
.addOption(options.dsn)
.addOption(options.domain)
.action(async (databaseName: unknown, options: Record<string, unknown>) => {
const dsn = await createDatabase(String(options.dsn), String(databaseName));
const cli = createDatabaseCli(dsn);
await cli.createTables();
await cli.seedTables(String(options.domain));
await cli.end();
});
export default database;

View file

@ -1,57 +0,0 @@
// LOG-2133 Create `shared` package for common utilities
import { SchemaLike, SchemaValue, SchemaValuePrimitive } from '@logto/schemas';
import decamelize from 'decamelize';
import { parseDsn, sql, SqlToken, stringifyDsn } from 'slonik';
/**
* Note `undefined` is removed from the acceptable list,
* since you should NOT call this function if ignoring the field is the desired behavior.
* Calling this function with `null` means an explicit `null` setting in database is expected.
* @param key The key of value. Will treat as `timestamp` if it ends with `_at` or 'At' AND value is a number;
* @param value The value to convert.
* @returns A primitive that can be saved into database.
*/
export const convertToPrimitiveOrSql = (
key: string,
// eslint-disable-next-line @typescript-eslint/ban-types
value: NonNullable<SchemaValue> | null
// eslint-disable-next-line @typescript-eslint/ban-types
): NonNullable<SchemaValuePrimitive> | SqlToken | null => {
if (value === null) {
return null;
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
if (['_at', 'At'].some((value) => key.endsWith(value)) && typeof value === 'number') {
return sql`to_timestamp(${value / 1000})`;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
throw new Error(`Cannot convert ${key} to primitive`);
};
export const insertInto = <T extends SchemaLike>(object: T, table: string) => {
const keys = Object.keys(object);
return sql`
insert into ${sql.identifier([table])}
(${sql.join(
keys.map((key) => sql.identifier([decamelize(key)])),
sql`, `
)})
values (${sql.join(
keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)),
sql`, `
)})
`;
};
export const replaceDsnDatabase = (dsn: string, databaseName: string): string =>
stringifyDsn({ ...parseDsn(dsn), databaseName });

View file

@ -1,10 +0,0 @@
declare module 'slonik-interceptor-preset' {
import { InterceptorType } from 'slonik';
export const createInterceptors: (config?: {
benchmarkQueries: boolean;
logQueries: boolean;
normaliseQueries: boolean;
transformFieldNames: boolean;
}) => readonly InterceptorType[];
}

View file

@ -1,11 +0,0 @@
import { Command } from 'commander';
import dotenv from 'dotenv';
import database from './commands/database';
dotenv.config();
const program = new Command();
program.addCommand(database);
program.parse(process.argv);

View file

@ -1,10 +0,0 @@
{
"extends": "@silverhand/ts-config/tsconfig.base",
"compilerOptions": {
"outDir": "lib",
"declaration": true
},
"include": [
"src"
]
}

View file

@ -23,6 +23,7 @@
"@logto/phrases": "^0.1.0",
"@logto/schemas": "^0.1.0",
"@silverhand/essentials": "^1.1.0",
"chalk": "^4",
"dayjs": "^1.10.5",
"decamelize": "^5.0.0",
"deepmerge": "^4.2.2",
@ -45,8 +46,10 @@
"oidc-provider": "^7.10.0",
"p-retry": "^4.6.1",
"query-string": "^7.0.1",
"roarr": "^7.11.0",
"slonik": "^28.0.0",
"slonik-interceptor-preset": "^1.2.10",
"slonik-sql-tag-raw": "^1.1.4",
"snakecase-keys": "^5.1.0",
"zod": "^3.14.3"
},

View file

@ -0,0 +1,9 @@
import { IdentifierSqlToken, sql } from 'slonik';
import envSet from '@/env-set';
export const getTotalRowCount = async (table: IdentifierSqlToken) =>
envSet.pool.one<{ count: number }>(sql`
select count(*)
from ${table}
`);

View file

@ -1,17 +1,21 @@
import { readdir, readFile } from 'fs/promises';
import path from 'path';
import { seeds } from '@logto/schemas';
import { SchemaLike, seeds } from '@logto/schemas';
import chalk from 'chalk';
import { createPool, sql } from 'slonik';
import decamelize from 'decamelize';
import { createPool, parseDsn, sql, stringifyDsn } from 'slonik';
import { createInterceptors } from 'slonik-interceptor-preset';
import { raw } from 'slonik-sql-tag-raw';
import { insertInto, replaceDsnDatabase } from './utilities';
import { convertToPrimitiveOrSql } from './utils';
const { managementResource, defaultSignInExperience, createDefaultSetting } = seeds;
const tableDirectory = 'node_modules/@logto/schemas/tables';
export const replaceDsnDatabase = (dsn: string, databaseName: string): string =>
stringifyDsn({ ...parseDsn(dsn), databaseName });
/**
* Create a database.
* @returns DSN with the created database name.
@ -23,8 +27,6 @@ export const createDatabase = async (dsn: string, databaseName: string): Promise
create database ${sql.identifier([databaseName])}
with
encoding = 'UTF8'
lc_collate = 'C'
lc_ctype = 'en_US.utf8'
connection_limit = -1;
`);
await pool.end();
@ -34,6 +36,22 @@ export const createDatabase = async (dsn: string, databaseName: string): Promise
return replaceDsnDatabase(dsn, databaseName);
};
export const insertInto = <T extends SchemaLike>(object: T, table: string) => {
const keys = Object.keys(object);
return sql`
insert into ${sql.identifier([table])}
(${sql.join(
keys.map((key) => sql.identifier([decamelize(key)])),
sql`, `
)})
values (${sql.join(
keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)),
sql`, `
)})
`;
};
export const createDatabaseCli = (dsn: string) => {
const pool = createPool(dsn, { interceptors: createInterceptors() });
@ -64,5 +82,5 @@ export const createDatabaseCli = (dsn: string) => {
console.log(`${chalk.blue('[seed-tables]')} Seed tables succeeded.`);
};
return { createTables, seedTables, end: pool.end };
return { createTables, seedTables, pool };
};

View file

@ -1,9 +1,7 @@
import { SchemaValuePrimitive, SchemaValue } from '@logto/schemas';
import { Falsy, notFalsy } from '@silverhand/essentials';
import dayjs from 'dayjs';
import { sql, SqlSqlToken, SqlToken, IdentifierSqlToken } from 'slonik';
import envSet from '@/env-set';
import { sql, SqlSqlToken, SqlToken } from 'slonik';
import { FieldIdentifiers, Table } from './types';
@ -70,9 +68,3 @@ export const convertToIdentifiers = <T extends Table>(
});
export const convertToTimestamp = (time = dayjs()) => sql`to_timestamp(${time.valueOf() / 1000})`;
export const getTotalRowCount = async (table: IdentifierSqlToken) =>
envSet.pool.one<{ count: number }>(sql`
select count(*)
from ${table}
`);

View file

@ -1,12 +1,61 @@
import { assertEnv } from '@silverhand/essentials';
import { assertEnv, conditional, conditionalString, Optional } from '@silverhand/essentials';
import inquirer from 'inquirer';
import { createPool } from 'slonik';
import { createInterceptors } from 'slonik-interceptor-preset';
import { createDatabase, createDatabaseCli } from '@/database/seed';
import { appendDotEnv } from './dot-env';
import { noInquiry } from './parameters';
const createPoolByEnv = async (isTest: boolean) => {
const defaultDatabaseUrl = 'postgres://localhost:5432';
const defaultDatabaseName = 'logto';
const inquireForLogtoDsn = async (key: string): Promise<[Optional<string>, boolean]> => {
const setUp = await inquirer.prompt({
type: 'confirm',
name: 'value',
message: `No Postgres DSN (${key}) found in env variables. Would you like to set up a new Logto database?`,
});
if (!setUp.value) {
const dsn = await inquirer.prompt({
name: 'value',
default: new URL(defaultDatabaseName, defaultDatabaseUrl).href,
message: 'Please input the DSN which points to an existing Logto database:',
});
return [conditional<string>(dsn.value && String(dsn.value)), false];
}
const hasEmptyDatabase = await inquirer.prompt({
type: 'confirm',
name: 'value',
default: false,
message: 'Do you have an empty databse for Logto?',
});
const dsnAnswer = await inquirer.prompt({
name: 'value',
default: new URL(hasEmptyDatabase.value ? defaultDatabaseName : '', defaultDatabaseUrl).href,
message: `Please input the DSN _WITH${conditionalString(
!hasEmptyDatabase.value && 'OUT'
)}_ database name:`,
});
const dsn = conditional<string>(dsnAnswer.value && String(dsnAnswer.value));
if (!dsn) {
return [dsn, false];
}
if (!hasEmptyDatabase.value) {
return [await createDatabase(dsn, defaultDatabaseName), true];
}
return [dsn, true];
};
const createPoolByEnv = async (isTest: boolean, localhostUrl: string) => {
// Database connection is disabled in unit test environment
if (isTest) {
return;
@ -24,18 +73,28 @@ const createPoolByEnv = async (isTest: boolean) => {
throw error;
}
const answer = await inquirer.prompt({
name: 'dsn',
message: `No Postgres DSN (${key}) found in env variables. Please input the DSN which points to Logto database:`,
});
const [dsn, needsSeed] = await inquireForLogtoDsn(key);
if (!answer.dsn) {
if (!dsn) {
throw error;
}
appendDotEnv(key, answer.dsn);
const cli = createDatabaseCli(dsn);
return createPool(answer.dsn, { interceptors });
if (needsSeed) {
const domain = await inquirer.prompt({
name: 'value',
default: localhostUrl,
message: 'Enter your domain for Logto:',
});
await cli.createTables();
await cli.seedTables(domain.value);
}
appendDotEnv(key, dsn);
return cli.pool;
}
};

View file

@ -1,4 +1,4 @@
import { getEnv, Optional } from '@silverhand/essentials';
import { conditionalString, getEnv, Optional } from '@silverhand/essentials';
import { DatabasePool } from 'slonik';
import createPoolByEnv from './create-pool-by-env';
@ -19,6 +19,7 @@ const loadEnvValues = async () => {
return Object.freeze({
isTest,
isProduction,
isHttpsEnabled: Boolean(process.env.HTTPS_CERT && process.env.HTTPS_KEY),
httpsCert: process.env.HTTPS_CERT,
httpsKey: process.env.HTTPS_KEY,
port,
@ -58,7 +59,10 @@ function createEnvSet() {
load: async () => {
values = await loadEnvValues();
pool = await createPoolByEnv(values.isTest);
pool = await createPoolByEnv(
values.isTest,
`http${conditionalString(values.isHttpsEnabled && 's')}://localhost:${values.port}`
);
},
};
}

View file

@ -2,13 +2,9 @@ import { Application, CreateApplication, Applications } from '@logto/schemas';
import { sql } from 'slonik';
import { buildInsertInto } from '@/database/insert-into';
import { getTotalRowCount } from '@/database/row-count';
import { buildUpdateWhere } from '@/database/update-where';
import {
convertToIdentifiers,
OmitAutoSetFields,
getTotalRowCount,
conditionalSql,
} from '@/database/utils';
import { convertToIdentifiers, OmitAutoSetFields, conditionalSql } from '@/database/utils';
import envSet from '@/env-set';
import { DeletionError } from '@/errors/SlonikError';

View file

@ -2,13 +2,9 @@ import { Resource, CreateResource, Resources } from '@logto/schemas';
import { sql } from 'slonik';
import { buildInsertInto } from '@/database/insert-into';
import { getTotalRowCount } from '@/database/row-count';
import { buildUpdateWhere } from '@/database/update-where';
import {
convertToIdentifiers,
OmitAutoSetFields,
getTotalRowCount,
conditionalSql,
} from '@/database/utils';
import { convertToIdentifiers, OmitAutoSetFields, conditionalSql } from '@/database/utils';
import envSet from '@/env-set';
import { DeletionError } from '@/errors/SlonikError';

View file

@ -270,6 +270,7 @@ importers:
'@types/node': ^16.3.1
'@types/oidc-provider': ^7.8.0
'@types/supertest': ^2.0.11
chalk: ^4
copyfiles: ^2.4.1
dayjs: ^1.10.5
decamelize: ^5.0.0
@ -300,8 +301,10 @@ importers:
p-retry: ^4.6.1
prettier: ^2.3.2
query-string: ^7.0.1
roarr: ^7.11.0
slonik: ^28.0.0
slonik-interceptor-preset: ^1.2.10
slonik-sql-tag-raw: ^1.1.4
snake-case: ^3.0.4
snakecase-keys: ^5.1.0
supertest: ^6.2.2
@ -313,6 +316,7 @@ importers:
'@logto/phrases': link:../phrases
'@logto/schemas': link:../schemas
'@silverhand/essentials': 1.1.2
chalk: 4.1.2
dayjs: 1.10.7
decamelize: 5.0.1
deepmerge: 4.2.2
@ -335,8 +339,10 @@ importers:
oidc-provider: 7.10.4
p-retry: 4.6.1
query-string: 7.0.1
roarr: 7.11.0
slonik: 28.1.0
slonik-interceptor-preset: 1.2.10
slonik-sql-tag-raw: 1.1.4_roarr@7.11.0+slonik@28.1.0
snakecase-keys: 5.1.2
zod: 3.14.3
devDependencies:
@ -7956,11 +7962,6 @@ packages:
engines: {node: '>= 12'}
dev: true
/commander/9.1.0:
resolution: {integrity: sha512-i0/MaqBtdbnJ4XQs4Pmyb+oFQl+q0lsAmokVUH92SlSw4fkeAcG3bVon+Qt7hmtF+u3Het6o4VgrcY3qAoEB6w==}
engines: {node: ^12.20.0 || >=14}
dev: false
/commondir/1.0.1:
resolution: {integrity: sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=}
dev: true
@ -18982,36 +18983,6 @@ packages:
yn: 3.1.1
dev: true
/ts-node/10.4.0_e6a8a9b497f380f485f6d23f5cd591ca:
resolution: {integrity: sha512-g0FlPvvCXSIO1JDF6S232P5jPYqBkRL9qly81ZgAOSU7rwI0stphCgd2kLiCrU9DjQCrJMWEqcNSjQL02s6d8A==}
hasBin: true
peerDependencies:
'@swc/core': '>=1.2.50'
'@swc/wasm': '>=1.2.50'
'@types/node': '*'
typescript: '>=2.7'
peerDependenciesMeta:
'@swc/core':
optional: true
'@swc/wasm':
optional: true
dependencies:
'@cspotcode/source-map-support': 0.7.0
'@tsconfig/node10': 1.0.8
'@tsconfig/node12': 1.0.9
'@tsconfig/node14': 1.0.1
'@tsconfig/node16': 1.0.2
'@types/node': 16.11.12
acorn: 8.6.0
acorn-walk: 8.2.0
arg: 4.1.3
create-require: 1.1.1
diff: 4.0.2
make-error: 1.3.6
typescript: 4.6.3
yn: 3.1.1
dev: true
/ts-node/10.7.0_e6a8a9b497f380f485f6d23f5cd591ca:
resolution: {integrity: sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==}
hasBin: true